@lucidbrain/sdk
v0.0.10
Published
LucidBrain SDK — MCP tool server with OAuth 2.1 + PKCE, the WorkSpec v1.2 pattern packaged.
Readme
@lucidbrain/sdk
MCP tool server with OAuth 2.1 + PKCE, packaged. One call yields a fully spec-compliant server with DCR (RFC 7591/7592), Resource Indicators (RFC 8707), Protected Resource Metadata (RFC 9728), opaque tokens, refresh rotation with replay detection, revocation, introspection, and Streamable HTTP transport.
See the Spec v0.1 if present, or the in-code comments in src/ for full detail.
Install
pnpm add @lucidbrain/sdk
pnpm add -D @types/pgPeer: express ^4.19 || ^5. Requires Node ≥ 22.
Migrate the database
The SDK owns two Postgres tables, shipped as SQL migrations.
pnpm --filter @lucidbrain/sdk run migrate
# or
DATABASE_URL=... npx lucidbrain migrateRe-runnable; tracks applied migrations in lucidbrain_migrations.
Hello world
import express from "express";
import { createToolServer } from "@lucidbrain/sdk";
const app = express();
const mcp = createToolServer({
name: "hello",
version: "1.0.0",
issuer: "https://hello.example.com",
db: { url: process.env.DATABASE_URL! },
cookieSecrets: [process.env.COOKIE_1!, process.env.COOKIE_2!],
resource: {
pattern: "/mcp",
resolve: () => "https://hello.example.com/mcp",
},
consent: { url: (uid) => `/consent/${uid}` },
scopes: { "hello:read": ["greet"] },
findUser: async (id) => ({ id, name: "Hello user" }),
});
mcp.tool(async function greet({ name }: { name: string }, ctx) {
return { message: `Hello, ${name}! Authenticated as ${ctx.user.id}.` };
});
mcp.mount(app);
app.listen(3000);Per-resource (multi-tenant) pattern
createToolServer({
// ...
resource: {
pattern: "/mcp/:workspaceSlug",
resolve: (req) => `${env.ISSUER}/mcp/${req.params.workspaceSlug}`,
validate: async ({ workspaceSlug }) => {
const ws = await db.workspaces.findBySlug(workspaceSlug);
if (!ws) throw new Error("unknown_workspace");
return { workspace: ws };
},
},
});validate runs on every MCP request. Whatever it returns is merged into ctx.resourceParams.
Tool registration (three forms)
// 1. Function form — name derived from fn.name
mcp.tool(async function list_items({}, ctx) { return db.items.forUser(ctx.user.id); });
// 2. Explicit form — preferred for production
mcp.tool({
name: "post_message",
description: "Post a message",
input: z.object({ channelId: z.string(), body: z.string() }),
scopes: ["synapse:write"],
handler: ({ channelId, body }, ctx) =>
postMessage({ channelId, body, userId: ctx.user.id }),
});
// 3. Batch form — for groups of simple tools
mcp.tools({
list_workspaces: (_, ctx) => db.workspaces.forUser(ctx.user.id),
get_workspace: ({ id }, ctx) => db.workspaces.get(id, ctx.user.id),
});Scope enforcement: explicit scopes: on the tool def + the scopes map at config level are unioned. Missing scope becomes an MCP error.
Flow B — external OAuth clients (Claude Code, Cursor, etc.)
Standard OAuth 2.1 + PKCE + Resource Indicators. The SDK mounts every endpoint. The consumer renders consent.
// Consumer's Express setup — one call mounts all three consent routes
app.get("/oauth/consent/:uid", (req, res) => res.sendFile("consent-spa.html"));
mcp.mountConsent(app);
// equivalent to:
// app.get ("/api/oauth/consent/:uid/context", mcp.handlers.consentContext);
// app.post("/api/oauth/consent/:uid/confirm", express.json(), mcp.handlers.consentConfirm);
// app.post("/api/oauth/consent/:uid/abort", express.json(), mcp.handlers.consentAbort);mountConsent(app, { basePath?, jsonParser? }) — override either if your app already owns those paths.
The consent SPA calls:
GET /api/oauth/consent/:uid/context→ renders UI with client + scopes + user infoPOST /api/oauth/consent/:uid/confirmwith{ scopes: [...] }→ receives{ returnTo }to navigate to
Scope descriptions
Register scope copy once at config time so every consent SPA across your orgs renders the same labels:
createToolServer({
// ...
scopes: { "workspec:write": ["claim_slice", "release_slice"] },
scopeDescriptions: {
"workspec:write": {
human: "Act on your behalf in WorkSpec",
permits: ["claim slices", "release slices", "update slice status"],
},
},
});consentContext returns the matching entries for each requested scope under scopes.descriptions[scope] — the SPA renders them verbatim.
Consent context shape
interface ConsentContextResponse {
interactionId: string;
prompt: "login" | "consent" | string;
client: { id, name, redirectUris, trusted };
scopes: {
requested: string[];
descriptions: Record<string, { human: string; permits: string[] }>;
};
resource: string;
resourceParams: Record<string, unknown>; // pathParams + resource.validate() result
user: { id, ... } | null; // from findUser(session.accountId)
}resourceParams runs config.resource.validate() (same function tool handlers get in ctx.resourceParams), so the SPA can render "Connect to Fieldstate workspace" without a second round-trip.
Pre-seeded trusted clients
await mcp.registerClient({
clientId: "lucidbrain",
clientName: "LucidBrain Agents",
redirectUris: ["https://lucidbrain.nz/oauth/callback/mycompany"],
trusted: true,
});Idempotent. Use for known integrations. Skips DCR. Still subject to consent.
Flow A — consumer-launched agents (no consent)
When the user clicks "Launch agent" inside your app, mint tokens directly:
const tokens = await mcp.mintDelegatedTokens({
userId: session.userId,
resource: `${env.ISSUER}/mcp/fieldstate`,
scopes: ["workspec:read", "workspec:write"],
ttl: 3600,
actor: { type: "agent", name: "Story Planner" },
});
await lucidbrainClient.launchAgent({
agent: { persona, model },
toolServers: [{
url: `${env.ISSUER}/mcp/fieldstate`,
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
refreshEndpoint: `${env.ISSUER}/oauth/token`,
}],
});No consent screen. No OAuth redirect. The user never sees LucidBrain.
Delegated grants use a reserved clientId of @delegated — filtered out of listGrants() by default. Pass { includeDelegated: true } to include them in an admin UI.
Connected-apps portal
app.get("/api/connected-apps", requireUser, async (req, res) => {
res.json(await mcp.listGrants({ userId: req.user.id }));
});
app.delete("/api/connected-apps/:grantId", requireUser, async (req, res) => {
await mcp.revokeGrant({ userId: req.user.id, grantId: req.params.grantId });
res.status(204).end();
});Revocation is immediate — all tokens under the grant are destroyed in DB.
body-parser ordering
node-oidc-provider parses its own bodies. Do NOT mount express.json() globally before mcp.mount(app):
// WRONG
app.use(express.json());
mcp.mount(app);
// RIGHT
mcp.mount(app); // SDK scopes JSON parsing to the MCP path
app.use("/api", express.json()); // mount json for your routes AFTERLucidBrain platform registration
Opt-in. Fires once on mount() if enabled. Failure is warned, not fatal.
createToolServer({
// ...
lucidbrain: {
register: true,
url: "https://lucidbrain.nz",
apiKey: env.LUCIDBRAIN_TENANT_KEY,
},
});Set register: false (or omit the block) and the SDK has zero runtime coupling to LucidBrain.
Cleanup
Expired payloads > 1 day old are deleted by:
await mcp.cleanup();Call from your cron. The SDK doesn't schedule it.
Scopes convention
<product>:<capability> — conventional capabilities: :read, :propose, :write, :admin.
Architecture principles
- Consumer owns the user relationship. The SDK renders no UI. Consent screens, login pages, connected-apps lists — all consumer.
- Opaque tokens, hashed at rest. DB dump yields no usable tokens.
- Fail closed. Every rejection path in the middleware returns WWW-Authenticate with resource metadata URL.
- Audience binding. Every token is audience-locked via RFC 8707. Cross-resource replay is impossible at the OAuth layer.
- DCR open with policy. Public clients, PKCE S256 required,
@delegatedclient-id reserved.
