mcp-trellis 0.2.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +41 -561
  2. package/dist/adapters/node.d.ts +15 -6
  3. package/dist/adapters/node.d.ts.map +1 -1
  4. package/dist/adapters/node.js +56 -29
  5. package/dist/adapters/node.js.map +1 -1
  6. package/dist/adapters/origins.d.ts.map +1 -1
  7. package/dist/adapters/origins.js +1 -4
  8. package/dist/adapters/origins.js.map +1 -1
  9. package/dist/app.d.ts +27 -4
  10. package/dist/app.d.ts.map +1 -1
  11. package/dist/app.js +31 -18
  12. package/dist/app.js.map +1 -1
  13. package/dist/auth/bearer.d.ts.map +1 -1
  14. package/dist/auth/bearer.js +6 -1
  15. package/dist/auth/bearer.js.map +1 -1
  16. package/dist/body.d.ts +48 -0
  17. package/dist/body.d.ts.map +1 -0
  18. package/dist/body.js +145 -0
  19. package/dist/body.js.map +1 -0
  20. package/dist/clients.d.ts.map +1 -1
  21. package/dist/clients.js +2 -5
  22. package/dist/clients.js.map +1 -1
  23. package/dist/dispatch.d.ts +1 -1
  24. package/dist/dispatch.d.ts.map +1 -1
  25. package/dist/dispatch.js +58 -18
  26. package/dist/dispatch.js.map +1 -1
  27. package/dist/http.d.ts.map +1 -1
  28. package/dist/http.js.map +1 -1
  29. package/dist/jsonrpc.d.ts +1 -0
  30. package/dist/jsonrpc.d.ts.map +1 -1
  31. package/dist/jsonrpc.js +1 -0
  32. package/dist/jsonrpc.js.map +1 -1
  33. package/dist/methods.d.ts +38 -15
  34. package/dist/methods.d.ts.map +1 -1
  35. package/dist/methods.js +38 -22
  36. package/dist/methods.js.map +1 -1
  37. package/dist/oauth/authorize.d.ts.map +1 -1
  38. package/dist/oauth/authorize.js +100 -68
  39. package/dist/oauth/authorize.js.map +1 -1
  40. package/dist/oauth/base64url.d.ts +1 -1
  41. package/dist/oauth/base64url.d.ts.map +1 -1
  42. package/dist/oauth/base64url.js +1 -4
  43. package/dist/oauth/base64url.js.map +1 -1
  44. package/dist/oauth/clientauth.d.ts +7 -2
  45. package/dist/oauth/clientauth.d.ts.map +1 -1
  46. package/dist/oauth/clientauth.js +59 -16
  47. package/dist/oauth/clientauth.js.map +1 -1
  48. package/dist/oauth/codes.d.ts +4 -4
  49. package/dist/oauth/codes.d.ts.map +1 -1
  50. package/dist/oauth/codes.js +43 -51
  51. package/dist/oauth/codes.js.map +1 -1
  52. package/dist/oauth/consent.d.ts +65 -0
  53. package/dist/oauth/consent.d.ts.map +1 -0
  54. package/dist/oauth/consent.js +173 -0
  55. package/dist/oauth/consent.js.map +1 -0
  56. package/dist/oauth/constants.d.ts +4 -0
  57. package/dist/oauth/constants.d.ts.map +1 -1
  58. package/dist/oauth/constants.js +4 -0
  59. package/dist/oauth/constants.js.map +1 -1
  60. package/dist/oauth/metadata.d.ts +6 -6
  61. package/dist/oauth/metadata.d.ts.map +1 -1
  62. package/dist/oauth/metadata.js.map +1 -1
  63. package/dist/oauth/pkce.d.ts.map +1 -1
  64. package/dist/oauth/pkce.js +1 -1
  65. package/dist/oauth/pkce.js.map +1 -1
  66. package/dist/oauth/redirect.d.ts +6 -1
  67. package/dist/oauth/redirect.d.ts.map +1 -1
  68. package/dist/oauth/redirect.js +23 -10
  69. package/dist/oauth/redirect.js.map +1 -1
  70. package/dist/oauth/reqbody.d.ts +10 -0
  71. package/dist/oauth/reqbody.d.ts.map +1 -0
  72. package/dist/oauth/reqbody.js +29 -0
  73. package/dist/oauth/reqbody.js.map +1 -0
  74. package/dist/oauth/resource.d.ts +30 -0
  75. package/dist/oauth/resource.d.ts.map +1 -1
  76. package/dist/oauth/resource.js +38 -8
  77. package/dist/oauth/resource.js.map +1 -1
  78. package/dist/oauth/revoke.d.ts.map +1 -1
  79. package/dist/oauth/revoke.js +15 -3
  80. package/dist/oauth/revoke.js.map +1 -1
  81. package/dist/oauth/router.d.ts +14 -11
  82. package/dist/oauth/router.d.ts.map +1 -1
  83. package/dist/oauth/router.js +62 -22
  84. package/dist/oauth/router.js.map +1 -1
  85. package/dist/oauth/scope.d.ts +11 -2
  86. package/dist/oauth/scope.d.ts.map +1 -1
  87. package/dist/oauth/scope.js +18 -7
  88. package/dist/oauth/scope.js.map +1 -1
  89. package/dist/oauth/sealed.d.ts +17 -0
  90. package/dist/oauth/sealed.d.ts.map +1 -0
  91. package/dist/oauth/sealed.js +57 -0
  92. package/dist/oauth/sealed.js.map +1 -0
  93. package/dist/oauth/secrethash.d.ts +44 -0
  94. package/dist/oauth/secrethash.d.ts.map +1 -0
  95. package/dist/oauth/secrethash.js +66 -0
  96. package/dist/oauth/secrethash.js.map +1 -0
  97. package/dist/oauth/token.d.ts.map +1 -1
  98. package/dist/oauth/token.js +13 -34
  99. package/dist/oauth/token.js.map +1 -1
  100. package/dist/oauth/types.d.ts +106 -9
  101. package/dist/oauth/types.d.ts.map +1 -1
  102. package/dist/oauth/types.js +67 -4
  103. package/dist/oauth/types.js.map +1 -1
  104. package/dist/oauth/wellknown.d.ts.map +1 -1
  105. package/dist/oauth/wellknown.js +6 -4
  106. package/dist/oauth/wellknown.js.map +1 -1
  107. package/dist/protocol.d.ts +15 -1
  108. package/dist/protocol.d.ts.map +1 -1
  109. package/dist/protocol.js +16 -6
  110. package/dist/protocol.js.map +1 -1
  111. package/dist/registry.d.ts +4 -4
  112. package/dist/registry.d.ts.map +1 -1
  113. package/dist/registry.js +15 -7
  114. package/dist/registry.js.map +1 -1
  115. package/dist/server.d.ts +14 -10
  116. package/dist/server.d.ts.map +1 -1
  117. package/dist/server.js +8 -8
  118. package/dist/server.js.map +1 -1
  119. package/dist/tools.d.ts +11 -2
  120. package/dist/tools.d.ts.map +1 -1
  121. package/dist/tools.js +14 -13
  122. package/dist/tools.js.map +1 -1
  123. package/dist/validate.d.ts +16 -1
  124. package/dist/validate.d.ts.map +1 -1
  125. package/dist/validate.js +46 -8
  126. package/dist/validate.js.map +1 -1
  127. package/docs/ROADMAP.md +295 -0
  128. package/docs/guide.md +417 -0
  129. package/docs/reference.md +178 -0
  130. package/docs/security.md +63 -0
  131. package/package.json +7 -3
package/README.md CHANGED
@@ -9,27 +9,30 @@ Web-standard `Request` → `Response`. Same handler on Cloudflare Workers, Next.
9
9
 
10
10
  ## Why mcp-trellis
11
11
 
12
- Pick this when you want **the whole connector stack in one package** — the MCP
13
- handler *and* a real OAuth 2.1 authorization server — with no database to run
14
- and no vendor to sign up with:
12
+ The whole connector stack in one package — MCP handler **and** OAuth 2.1 authorization server — with no database and no vendor signup. You own login, token minting, and storage; the library owns the protocol.
15
13
 
16
14
  | | mcp-trellis | Official MCP SDK | `workers-oauth-provider` | `@mcpauth/auth` | Auth0 / Clerk / Authlete |
17
15
  |---|---|---|---|---|---|
18
16
  | MCP handler | ✅ | ✅ | ❌ | ❌ | ❌ |
19
17
  | OAuth 2.1 authorization server | ✅ | ❌ bring your own | ✅ | ✅ | ✅ |
20
18
  | Runtime | any Web-standard | any Web-standard | Workers only | Node | — |
21
- | Database | none | — | KV (optional) | **required** — Prisma/Drizzle, with migrations | — |
19
+ | Database | none | — | KV (optional) | **required** | — |
22
20
  | Runtime dependencies | **zero** | several | several | several | — |
23
21
  | Self-hosted | ✅ | ✅ | ✅ | ✅ | ❌ SaaS |
24
- | Named connector profiles (Claude / Gemini / Codex), enforced not just advertised | ✅ | ❌ | ❌ | ❌ | ❌ |
22
+ | Named connector profiles (Claude / Gemini / Codex), enforced | ✅ | ❌ | ❌ | ❌ | ❌ |
25
23
 
26
- Prefer the official SDK when you already have (or want) a separate
27
- authorization server — it doesn't ship one. Prefer `workers-oauth-provider`
28
- when you're committed to Workers and want Cloudflare's own implementation.
29
- Prefer a managed IdP (Auth0, Clerk, Authlete, WorkOS) when you'd rather pay
30
- than operate one. Prefer mcp-trellis when you want the protocol layer and the
31
- AS in one package, with **nothing to provision** — you still own login, token
32
- minting, and storage; the library owns the protocol.
24
+ Prefer the official SDK when you already have a separate AS. Prefer
25
+ `workers-oauth-provider` when you want Cloudflare's Workers-only
26
+ implementation. Prefer a managed IdP when you'd rather pay than operate one.
27
+
28
+ Named alternatives in the same problem space:
29
+
30
+ - **`@mcpauth/auth` / `getmcpauth` / `mcp-auth`** — OAuth for MCP, typically
31
+ with a DB or a different runtime/stack assumption. mcp-trellis is the
32
+ **zero-dependency** option that ships the MCP handler **and** the OAuth 2.1
33
+ AS in one package.
34
+ - **`fastmcp-oauth`** — OAuth helpers around FastMCP. mcp-trellis is host-agnostic
35
+ (`Request`/`Response`) and not tied to a particular MCP framework.
33
36
 
34
37
  ## Requirements
35
38
 
@@ -42,10 +45,9 @@ minting, and storage; the library owns the protocol.
42
45
  npm install mcp-trellis
43
46
  ```
44
47
 
45
- ## 60-second server
48
+ ## Quick start
46
49
 
47
- One call mounts the MCP endpoint, the OAuth authorization server, and both
48
- discovery documents — and routes between them:
50
+ One call mounts the MCP endpoint, the OAuth authorization server, and both discovery documents:
49
51
 
50
52
  ```ts
51
53
  import { createMcpApp } from "mcp-trellis";
@@ -90,9 +92,9 @@ const app = createMcpApp({
90
92
  export default { fetch: (req: Request) => app.fetch(req) };
91
93
  ```
92
94
 
93
- You return the token's `audience`; **the library rejects tokens minted for a
94
- different resource** before any tool runs. That check is the one preventing
95
- confused-deputy attacks, so it is not yours to remember.
95
+ You return the token's `audience`; **the library rejects tokens minted for a different resource** before any tool runs. Details: [docs/security.md](docs/security.md).
96
+
97
+ A real `/authorize` walk includes an approval step: a resolved session doesn't redirect straight back with a code, it renders a consent screen first (built in, or your own via `consent`). First-time integrators clicking through by hand should expect an HTML page there, not an immediate redirect — see [Consent](docs/guide.md#consent).
96
98
 
97
99
  Smoke-test with `initialize` (public — no Bearer needed):
98
100
 
@@ -102,562 +104,44 @@ curl -s http://127.0.0.1:8787/mcp \
102
104
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
103
105
  ```
104
106
 
105
- ## Client compatibility
107
+ ```json
108
+ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"demo","version":"1.0.0"},"instructions":""}}
109
+ ```
106
110
 
107
- Name the connector clients you serve; the AS is configured to match.
111
+ ## Clients
108
112
 
109
113
  | Client | Registration | Token endpoint auth | Notes |
110
114
  |--------|--------------|---------------------|-------|
111
- | `claude` | Dynamic (DCR), public + PKCE | `none` | Claude Custom Connectors callback is allowlisted for you |
112
- | `gemini` | **Pre-registered**, confidential | `client_secret_basic`, `client_secret_post` | Gemini Enterprise is registered with your org's IdP — supply `auth.clientStore` |
113
- | `codex` | OAuth 2.1 per MCP auth spec, public + PKCE | `none` | ChatGPT / Codex share one connector contract |
114
-
115
- ```ts
116
- createMcpApp({
117
- clients: ["claude", "gemini"],
118
- auth: {
119
- // …
120
- clientStore: {
121
- get: async (clientId) => lookupClient(clientId),
122
- verifySecret: async (clientId, presented) =>
123
- checkSecret(clientId, presented),
124
- },
125
- },
126
- });
127
- ```
115
+ | `claude` | Dynamic (DCR), public + PKCE | `none` | Claude Custom Connectors callback allowlisted |
116
+ | `gemini` | **Pre-registered**, confidential | `client_secret_basic`, `client_secret_post` | Supply `auth.clientStore` |
117
+ | `codex` | OAuth 2.1 per MCP auth spec, public + PKCE | `none` | ChatGPT / Codex share one contract |
128
118
 
129
- Naming a pre-registered client without a `clientStore` throws at construction
130
- rather than failing at the first token exchange. Secret storage and comparison
131
- stay in your `clientStore` — the library never sees or persists credentials.
119
+ Pre-registered clients, DCR enforcement, and `clientStore` wiring: [docs/guide.md#clients](docs/guide.md#clients).
132
120
 
133
- Naming only pre-registered clients (`clients: ["gemini"]`, no `claude` /
134
- `codex`) sets `allowUnregisteredClients: false`: `/register` is unmounted
135
- and dropped from AS metadata, unknown ids are rejected with
136
- `unauthorized_client` at `authorize` and `invalid_client` at `token` and
137
- `revoke`. Naming `claude` or `codex` alongside `gemini` reopens dynamic
138
- registration.
121
+ ## Architecture
139
122
 
140
- ## How it fits together
141
-
142
- What `createMcpApp` wires for you — two pieces, one deciding which requests go
143
- to the other:
123
+ `createMcpApp` wires MCP and OAuth and routes between them:
144
124
 
145
125
  ![Architecture: connector → edge → OAuth or MCP → your ports](docs/diagrams/architecture.svg)
146
126
 
147
- Typical first-connection path:
148
-
149
127
  ![Sequence: OAuth authorize and token, then MCP tool call](docs/diagrams/first-connection.svg)
150
128
 
151
- You only touch this routing directly if you skip `createMcpApp` — see
152
- [Advanced: compose the primitives](#advanced-compose-the-primitives).
153
-
154
- ## Package surface
155
-
156
- | Import | What you get |
157
- |--------|----------------|
158
- | `mcp-trellis` | `createMcpApp`, client profiles, MCP handler, tool registry, bearer helpers, HTTP utils |
159
- | `mcp-trellis/oauth` | OAuth 2.1 AS router, PKCE, auth codes, client auth, metadata |
160
- | `mcp-trellis/node` | `asNodeHandler` + `resolveOrigin` for Node `(req, res)` |
161
-
162
- `createMcpApp` is the batteries-included layer. `createMcpHandler` and
163
- `createOAuthRouter` remain exported and unchanged — reach for them when you want
164
- to own the routing or mount the two halves separately.
165
-
166
- ### Default OAuth routes
167
-
168
- With defaults `resourcePath: "/mcp"` and `oauthPath: "/mcp/oauth"`:
169
-
170
- | Path | Purpose |
171
- |------|---------|
172
- | `/.well-known/oauth-protected-resource` (+ `/mcp`) | Protected resource metadata |
173
- | `/.well-known/oauth-authorization-server` (+ `/mcp`) | Authorization server metadata |
174
- | `/mcp/oauth/register` | Dynamic client registration (unmounted when DCR is off) |
175
- | `/mcp/oauth/authorize` | Authorization endpoint |
176
- | `/mcp/oauth/token` | Token endpoint |
177
- | `/mcp/oauth/revoke` | Token revocation (RFC 7009; mounted only when `revokeToken` is set) |
178
-
179
- ## Recipes
180
-
181
- ### Cloudflare Worker, Next.js, Deno, Bun
182
-
183
- `createMcpApp` returns a plain `fetch`, so the same object mounts everywhere:
184
-
185
- ```ts
186
- // Worker
187
- export default { fetch: (req: Request, env: Env) => buildApp(env).fetch(req) };
188
-
189
- // Next.js App Router — app/[[...mcp]]/route.ts
190
- export const GET = (req: Request) => app.fetch(req);
191
- export const POST = (req: Request) => app.fetch(req);
192
- export const OPTIONS = (req: Request) => app.fetch(req);
193
-
194
- // Node / Express / Cloud Run
195
- import { asNodeHandler } from "mcp-trellis/node";
196
- export const mcp = asNodeHandler(app, { origin: process.env.MCP_PUBLIC_ORIGIN! });
197
- ```
198
-
199
- The Node bridge reads the request stream when `req.body` is absent, so raw
200
- `http.createServer` works without a body-parser. Runnable recipe:
201
- [`examples/http-server.ts`](examples/http-server.ts).
202
-
203
- ## Advanced: compose the primitives
204
-
205
- Skip `createMcpApp` when you want to own the routing. Same behavior, more wiring:
206
-
207
- ```ts
208
- import { createMcpHandler, createToolRegistry, parseBearer } from "mcp-trellis";
209
- import { createOAuthRouter } from "mcp-trellis/oauth";
210
-
211
- type Ctx = { userId: string };
212
- type Env = { OAUTH_CODE_SECRET: string };
213
-
214
- const registry = createToolRegistry<Ctx>([
215
- {
216
- name: "ping_db",
217
- description: "Health check",
218
- inputSchema: { type: "object", properties: {} },
219
- scope: "read",
220
- handler: async () => "ok",
221
- },
222
- ]);
223
-
224
- const handler = createMcpHandler<Ctx>({
225
- registry,
226
- serverInfo: { name: "my-app", version: "1.0.0" },
227
- instructions: "Tools available to authenticated clients.",
228
- wwwAuthenticate: (req) => ({
229
- realm: "my-app",
230
- resourceMetadataUrl: `${new URL(req.url).origin}/.well-known/oauth-protected-resource/mcp`,
231
- }),
232
- ports: {
233
- authenticate: async (req) => {
234
- const token = parseBearer(req.headers.get("authorization"));
235
- if (!token) return null;
236
- // Reject tokens whose aud ≠ this server's canonical resource.
237
- return { id: "user-1", scopes: ["read"] };
238
- },
239
- context: async (_req, principal) => ({
240
- userId: principal?.id ?? "",
241
- }),
242
- },
243
- });
244
-
245
- export default {
246
- async fetch(request: Request, env: Env): Promise<Response> {
247
- const oauth = createOAuthRouter({
248
- ports: {
249
- codeSecret: env.OAUTH_CODE_SECRET,
250
- resolveUser: async () => ({ id: "user-1" }),
251
- loginUrl: (req, next) =>
252
- `${new URL(req.url).origin}/login?next=${encodeURIComponent(next)}`,
253
- mintAccessToken: async ({ userId, resource }) => ({
254
- // Embed `resource` as the token audience (RFC 8707).
255
- accessToken: await issueUserToken(userId, { aud: resource }),
256
- expiresIn: 3600,
257
- scope: "mcp",
258
- }),
259
- },
260
- });
261
-
262
- const oauthResponse = await oauth.tryHandle(request);
263
- if (oauthResponse) return oauthResponse;
264
- if (new URL(request.url).pathname === "/mcp") return handler.fetch(request);
265
- return new Response("Not found", { status: 404 });
266
- },
267
- };
268
- ```
269
-
270
- ### Next.js App Router
271
-
272
- Delegate Web `Request` straight through — same handler as above:
129
+ Host recipes, ports, tools, and multi-tenant: [docs/guide.md](docs/guide.md).
273
130
 
274
- ```ts
275
- // app/mcp/route.ts
276
- import { handler, oauth } from "@/lib/mcp"; // your shared setup
277
-
278
- export async function GET(req: Request) {
279
- const oauthResponse = await oauth.tryHandle(req);
280
- if (oauthResponse) return oauthResponse;
281
- return handler.fetch(req);
282
- }
283
-
284
- export async function POST(req: Request) {
285
- const oauthResponse = await oauth.tryHandle(req);
286
- if (oauthResponse) return oauthResponse;
287
- return handler.fetch(req);
288
- }
289
-
290
- export async function OPTIONS() {
291
- return handler.fetch(new Request("https://local/mcp", { method: "OPTIONS" }));
292
- }
293
- ```
294
-
295
- Also mount `.well-known/*` and `/mcp/oauth/*` the same way (or route everything through one catch-all that calls `oauth.tryHandle` first).
296
-
297
- ### Node HTTP `(req, res)`
298
-
299
- Express, Cloud Functions, Cloud Run, `http.createServer` — bridge with
300
- `asNodeHandler`. Runnable recipe: [`examples/http-server.ts`](examples/http-server.ts)
301
- (`npx tsx examples/http-server.ts`).
302
-
303
- ## Ports — what you implement
304
-
305
- The library stays protocol-shaped. Your app plugs in the seams:
306
-
307
- ![Ports: library calls into your authenticate, context, resolveUser, mintAccessToken](docs/diagrams/ports.svg)
308
-
309
- ### App (`createMcpApp`)
310
-
311
- | Port | Role |
312
- |------|------|
313
- | `verifyToken(token, req)` | Decode a bearer and return `{ userId, scopes, audience, claims? }`, or `null`. **The library compares `audience` against this server's canonical resource** — you cannot forget the check. Optional `claims` are passed to `context` on `principal` |
314
- | `resolveUser`, `loginUrl`, `mintAccessToken`, `codeSecret` | Same as the OAuth ports below |
315
- | `clientStore?` | Pre-registered clients; required for confidential clients like Gemini |
316
- | `refreshAccessToken?` | If set, metadata advertises `refresh_token` |
317
- | `revokeToken?` | RFC 7009; presence mounts `/revoke` and advertises `revocation_endpoint` |
318
- | `context?`, `audit?` | Same as the MCP ports below; `context` defaults to an empty object. Receives `principal.claims` when `verifyToken` set them |
319
-
320
- ### MCP (`createMcpHandler`)
321
-
322
- The lower-level handler. Here the audience check is **yours** — prefer
323
- `createMcpApp` unless you need the control.
324
-
325
- | Port | Role |
326
- |------|------|
327
- | `authenticate(req, method, tool?)` | Return `{ id, scopes, claims? }`, or `null` → 401 + `WWW-Authenticate`. **Must reject tokens whose audience is not this server’s canonical resource** (see Threat model) |
328
- | `context(req, principal)` | Build per-request ctx for tools (DB, env, …). Use `principal?.claims` for tenant / plan / role without re-decoding the bearer |
329
- | `audit?(entry)` | Opt-in **metrics hook**: pass any function to receive tool results **and** every denial (bad token, missing scope, query-string token). `entry.method` is `""` for transport-level denials made before parsing. Throwing from this port never fails the request. Omit it and the library stays silent |
330
-
331
- Pass any function to get per-request metrics — do what you want with them
332
- (DB, APM, admin UI). The library never writes to stdout or a store on its
333
- own. `consoleAudit` is only a convenience for local / small deploys;
334
- [`examples/audit-store.ts`](examples/audit-store.ts) shows a ring buffer and
335
- a DB-shaped sketch:
336
-
337
- ```ts
338
- import { createMcpApp, consoleAudit } from "mcp-trellis";
339
-
340
- createMcpApp({ /* … */, audit: consoleAudit });
341
-
342
- // Or your own sink:
343
- createMcpApp({
344
- /* … */,
345
- audit: (entry) => {
346
- // entry: { method, tool?, principalId?, ok, error?, durationMs }
347
- void metrics.record(entry);
348
- },
349
- });
350
- ```
351
-
352
- Failures (denials and tool exceptions) go to `console.error` when using
353
- `consoleAudit`; everything else to `console.log`. Entries never carry raw
354
- exceptions or stack traces, so console output is safe. For anything beyond
355
- stdout, write your own `audit` function — that is the intended production path.
131
+ ## Docs
356
132
 
357
- `wwwAuthenticate` can be a static object or `(req) => …` when the PRM URL depends on origin.
133
+ | Doc | Contents |
134
+ |-----|----------|
135
+ | [docs/guide.md](docs/guide.md) | Architecture, clients, recipes, ports, tools, multi-tenant |
136
+ | [docs/reference.md](docs/reference.md) | Routes, methods, status codes, options, exports |
137
+ | [docs/security.md](docs/security.md) | Protocol promises, threat model, not in scope |
138
+ | [docs/ROADMAP.md](docs/ROADMAP.md) | What's next |
358
139
 
359
- By default `initialize`, `ping`, and notifications are public. Override `publicMethods` if every method must require Bearer.
140
+ Examples: [`examples/`](examples/) — HTTP server, Worker, multi-tenant, stores, audit.
360
141
 
361
- ### OAuth (`createOAuthRouter`)
362
-
363
- | Port | Role |
364
- |------|------|
365
- | `codeSecret` | HMAC secret for signed auth codes (string or `(req) => string`) |
366
- | `resolveUser` | Consenting user, or `null` → redirect to `loginUrl` |
367
- | `loginUrl` | Where unauthenticated authorize requests go |
368
- | `mintAccessToken` | Issue a **user-bound**, **audience-bound** access token (`resource` is the RFC 8707 URI) — never a shared god token |
369
- | `refreshAccessToken?` | If set, metadata advertises `refresh_token`; also receives `resource` |
370
- | `revokeToken?` | If set, mounts `/revoke` and advertises `revocation_endpoint`. Well-formed authenticated revoke → 200 even if the token is unknown; `invalid_request` 400 and `invalid_client` 401 still apply |
371
- | `codeStore?` | Shared single-use jti store for multi-instance (`consume(jti, expMs)`); pruning in-memory default otherwise |
372
- | `clientStore?` | Pre-registered clients: `get(clientId)` returns registered redirect URIs and auth method; `verifySecret(clientId, presented)` authenticates confidential clients. Credentials never enter the library |
373
-
374
- Auth codes carry `userId`, `resource`, and the granted `scope`. Clients **must** send `resource` on authorize and token (MCP MUST) — omitting it is a **breaking** requirement vs earlier 0.1.x drafts that ignored the parameter. Advertised grants come only from the handlers you configure. AS metadata sets `resource_parameter_supported: true`.
375
-
376
- **Scope is negotiated:** `authorize` validates the requested `scope` against `scopes` (default `["mcp"]`) and rejects anything outside it with `invalid_scope`; an omitted `scope` grants the full advertised set. The auth code carries the grant, and `mintAccessToken` receives it — so `ToolDef.scope` and `principal.scopes` sit on a chain that actually reaches the OAuth layer.
377
-
378
- **Client authentication:** unknown `client_id`s are public (PKCE only) unless `allowUnregisteredClients` is `false` — then DCR is unmounted, dropped from metadata, and unrecognized ids are rejected (`unauthorized_client` at authorize, `invalid_client` at token and revoke). `createMcpApp` derives the flag from `clients` via `hasDynamicClient`.
379
-
380
- ## Tool registry
381
-
382
- Schema and handler live in one place — no parallel “defs” and “handlers” lists to keep in sync:
383
-
384
- ```ts
385
- createToolRegistry(tools, { validateArgs: false }) // default: off, easy adoption
386
- // onToolError?: (exc) => string — default redacts to "Tool execution failed"
387
- ```
388
-
389
- - Handler may return a `string` or `{ content, isError? }`
390
- - Unknown tools and thrown errors become `isError: true` (not transport errors)
391
- - Thrown exceptions are **redacted** by default (`"Tool execution failed"`); pass `onToolError` to map them. An `onToolError` that itself throws or returns a non-string falls back to the redacted default
392
- - Duplicate tool names throw at registry construction
393
- - Turn `validateArgs: true` once clients send schema-valid args. The evaluated
394
- subset is `type`, `enum`, `required`, `properties`, `items`, `minimum`,
395
- `maximum`. Pure metadata (`description`, `title`, `$schema`, `$id`,
396
- `$comment`, `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`,
397
- `format`) is allowed and never evaluated. Any other keyword is not
398
- evaluated — and with `validateArgs: true` `createToolRegistry` throws at
399
- construction so a schema like `pattern` or `additionalProperties` cannot
400
- silently look enforced
401
-
402
- ### Wrapping an API as a tool
403
-
404
- `ToolDef` stays the primitive. `defineTool` and `apiTool` are optional sugar for
405
- the common case — turning "I have an API" into "I have an MCP tool" without
406
- hand-rolling arg validation or fetch/error plumbing each time:
407
-
408
- ```ts
409
- import { apiTool } from "mcp-trellis";
410
-
411
- const getWeather = apiTool({
412
- name: "get_weather",
413
- description: "Current weather for a city",
414
- inputSchema: {
415
- type: "object",
416
- properties: { city: { type: "string" } },
417
- required: ["city"],
418
- },
419
- // any Standard Schema v1 validator (https://standardschema.dev)
420
- input: citySchema,
421
- request: (_ctx, { city }) =>
422
- `https://api.example.com/weather?city=${encodeURIComponent(city)}`,
423
- respond: async (res) => {
424
- const data = await res.json();
425
- return `${data.tempC}°C, ${data.condition}`;
426
- },
427
- });
428
- ```
142
+ ## Contributing
429
143
 
430
- - **`inputSchema` stays required** — it's what `tools/list` actually advertises to
431
- clients, and there's no library-agnostic way to derive JSON Schema from an
432
- arbitrary Standard Schema validator. Keep the two in sync.
433
- - **`input`** (optional) parses and types `args` before your handler runs — any
434
- [Standard Schema](https://standardschema.dev)-compliant validator works,
435
- nothing library-specific. A validation failure returns `isError: true` with
436
- the issues and never calls your handler.
437
- - **`apiTool`** builds on `defineTool`: give it `request` (build the outgoing
438
- call from typed args) and optionally `respond` (shape a 2xx response;
439
- default is the body as text). Non-2xx becomes `isError: true` automatically.
440
- `fetch` is overridable — testing, request signing, a custom agent.
441
- - `defineTool` alone (without `request`/`respond`) is just the typed-args
442
- layer over a regular `ToolDef` — use it for anything, not only REST calls.
443
-
444
- ## Multi-tenant (SaaS connectors)
445
-
446
- Resource-per-tenant already works with **zero library change**.
447
- `canonicalResource(origin, resourcePath)` is derived per request, and the
448
- audience check in `createMcpApp` rejects a token minted for another origin
449
- before any tool runs. Deploy `acme.mcp.example.com` and
450
- `globex.mcp.example.com` as separate Hosts and cross-tenant token reuse is a
451
- protocol failure, not an application bug.
452
-
453
- For the in-tool half, derive the tenant from the request Host (or from the
454
- verified audience after the library check) in your `context` port — that is
455
- the isolation key. Optional `claims` from `verifyToken` (plan, role, …) are
456
- handy metadata on `principal`; they are **not** Host→tenant authorization.
457
- A forged `claims.tenant_id` with a matching audience must not change which
458
- tenant's data a tool can see.
459
-
460
- **Host caveat (API requirement).** On Node, multi-tenant needs a Host-derived
461
- origin (`trustProxy: true` without a fixed `origin`). That path **requires**
462
- a non-empty `allowedOrigins` at `asNodeHandler` construction — omit it and
463
- construction throws. Use exact origins and/or `*.example.com` wildcards for
464
- **https** subdomains; the **resolved** origin must match or the request
465
- returns **400** before any OAuth or MCP handling. Loud opt-out:
466
- `allowedOrigins: ["*"]` (admits any Host — only behind a proxy you trust to
467
- strip client `X-Forwarded-*`). A fixed `origin` absent from a non-empty list
468
- fails at construction; with a fixed `origin` the allowlist is optional
469
- defense-in-depth. Absolute-form request targets are rebased onto that
470
- validated origin, so a spoofed URL line cannot override it. A fixed `origin`
471
- is otherwise safe but collapses every tenant onto one resource, so it cannot
472
- do subdomain-per-tenant.
473
-
474
- On Cloudflare Workers (or any wildcard route), apply the equivalent
475
- allowlist check on `new URL(req.url).origin` before calling `app.fetch`
476
- (`isAllowedOrigin` from `mcp-trellis/node`, or the same module the Worker
477
- example imports).
478
-
479
- Typechecked sketch: [`examples/multi-tenant.ts`](examples/multi-tenant.ts)
480
- (needs a Host matching `*.mcp.example.com` — loopback alone is rejected).
481
- Shared `codeStore` / revocation shapes: [`examples/stores.ts`](examples/stores.ts)
482
- (atomic `setIfAbsent` for auth codes; Workers KV alone is not enough).
483
- `audit` metrics sink (ring + DB sketch): [`examples/audit-store.ts`](examples/audit-store.ts).
484
- Worker mount: [`examples/cloudflare-worker.ts`](examples/cloudflare-worker.ts)
485
- (typecheck-only in CI — no wrangler).
486
-
487
- ## Reference
488
-
489
- ### Supported MCP methods
490
-
491
- | Method | Auth | Notes |
492
- |--------|------|-------|
493
- | `initialize` | public | Negotiates protocol version; returns `capabilities.tools` |
494
- | `ping` | public | Empty result |
495
- | `tools/list` | Bearer | Lists registry entries |
496
- | `tools/call` | Bearer + scope | Runs the tool; missing scope → 401 |
497
- | `notifications/*` | public | HTTP 202 empty body |
498
-
499
- Anything else → JSON-RPC `-32601`. Capabilities advertise **tools only**.
500
-
501
- ### HTTP and JSON-RPC status codes
502
-
503
- | HTTP | When |
504
- |------|------|
505
- | **202** | Notification (empty body) |
506
- | **400** | Parse error, batch array, invalid request |
507
- | **401** | Missing/invalid Bearer; includes `WWW-Authenticate` |
508
- | **405** | Wrong HTTP verb on MCP |
509
-
510
- | JSON-RPC code | Constant |
511
- |---------------|----------|
512
- | `-32700` | `JSONRPC_PARSE_ERROR` |
513
- | `-32600` | `JSONRPC_INVALID_REQUEST` (batches) |
514
- | `-32601` | `JSONRPC_METHOD_NOT_FOUND` |
515
- | `-32602` | `JSONRPC_INVALID_PARAMS` |
516
- | `-32603` | `JSONRPC_INTERNAL_ERROR` |
517
- | `-32001` | `JSONRPC_UNAUTHORIZED` |
518
-
519
- ### `createMcpHandler` options
520
-
521
- | Option | Required | Description |
522
- |--------|----------|-------------|
523
- | `registry` | yes | From `createToolRegistry` |
524
- | `ports` | yes | `authenticate`, `context`, optional `audit` |
525
- | `serverInfo` | yes | `{ name, version }` |
526
- | `wwwAuthenticate` | yes | Static or `(req) => …` for RFC 9728 PRM URL |
527
- | `instructions` | no | Returned on `initialize` |
528
- | `publicMethods` | no | Default: `initialize`, `ping`, notifications |
529
-
530
- ### `createOAuthRouter` options
531
-
532
- | Option | Default | Description |
533
- |--------|---------|-------------|
534
- | `ports` | — | Required OAuth ports (see above) |
535
- | `resourcePath` | `"/mcp"` | MCP resource path in PRM |
536
- | `oauthPath` | `"/mcp/oauth"` | Prefix for authorize / token / register / revoke |
537
- | `realm` | — | Optional realm string |
538
- | `scopes` | `["mcp"]` | Advertised in metadata, and the ceiling `authorize` validates against |
539
- | `tokenEndpointAuthMethods` | `["none"]` | Advertised client auth methods |
540
- | `allowUnregisteredClients` | `true` | `false` requires `clientStore`, unmounts DCR, and rejects unknown `client_id`s (`unauthorized_client` / `invalid_client`) |
541
- | `redirect` | see below | Redirect URI allowlist |
542
-
543
- **`redirect` (`RedirectAllowlistOptions`):**
544
-
545
- | Field | Default | Description |
546
- |-------|---------|-------------|
547
- | `extra` | `[]` | Exact-match redirect URIs |
548
- | `allowLoopback` | `true` | Allow `http(s)` loopback — `127.0.0.1`, `[::1]`, `localhost` |
549
- | `allowClaude` | `true` | Allow the Claude.ai Custom Connector callback |
550
-
551
- Pre-registered clients bypass this allowlist entirely — they are validated
552
- against their own `redirectUris` from `clientStore`.
553
-
554
- ### `createMcpApp` options
555
-
556
- | Option | Default | Description |
557
- |--------|---------|-------------|
558
- | `serverInfo`, `tools`, `auth` | — | Required |
559
- | `clients` | `["claude"]` | Connector profiles to serve |
560
- | `resourcePath` | `"/mcp"` | MCP endpoint path |
561
- | `oauthPath` | `` `${resourcePath}/oauth` `` | OAuth prefix |
562
- | `scopes` | `["mcp"]` | Scopes this server grants |
563
- | `realm` | `serverInfo.name` | `WWW-Authenticate` realm |
564
- | `extraRedirectUris` | `[]` | Callbacks beyond the client profiles |
565
- | `allowLoopback` | `true` | Allow loopback redirects |
566
- | `instructions`, `validateArgs`, `onToolError`, `context`, `audit` | — | Passed through |
567
-
568
- ### Exports
569
-
570
- <details>
571
- <summary><code>mcp-trellis</code></summary>
572
-
573
- - `createMcpApp`, `createMcpHandler`, `createToolRegistry`
574
- - `consoleAudit` — convenience `audit` sink that logs to the console (pass any function for metrics / DB / APM)
575
- - `defineTool`, `apiTool` — typed, validated tool authoring on top of `ToolDef`
576
- - `CLIENT_PROFILES`, `DEFAULT_CLIENTS`, `authMethodsFor`, `redirectUrisFor`, `preRegisteredClients`, `hasDynamicClient`
577
- - `parseBearer`, `timingSafeEqual`, `matchesAny`, `wwwAuthenticateHeader`, `rejectQueryToken`
578
- - `validateAgainstSchema`, `JSON_SCHEMA_TYPES`, `SUPPORTED_SCHEMA_KEYWORDS`, `IGNORED_SCHEMA_KEYWORDS`, `unsupportedKeywords`
579
- - `rpcResult`, `rpcError`, JSON-RPC error constants
580
- - `pickProtocolVersion`, `PROTOCOL_VERSIONS`, `DEFAULT_PROTOCOL_VERSION`
581
- - `jsonResponse`, `emptyResponse`, `optionsResponse`, `corsHeaders`, `methodNotAllowed`
582
- - Types: `McpApp`, `McpAppOptions`, `McpAppAuth`, `VerifiedToken`, `ClientName`, `ClientProfile`, `McpHandler`, `McpHandlerOptions`, `McpPorts`, `Principal`, `AuditEntry`, `ServerInfo`, `ToolDef`, `ToolHandler`, `ToolResult`, `ToolRegistry`, `JsonSchema`, `StandardSchemaV1`, `DefineToolOptions`, `ApiToolOptions`, `ApiRequest`, …
583
-
584
- </details>
585
-
586
- <details>
587
- <summary><code>mcp-trellis/oauth</code></summary>
588
-
589
- - `createOAuthRouter`
590
- - `authorizationServerMetadata`, `protectedResourceMetadata`, `mcpWwwAuthenticate`
591
- - `canonicalResource`, `resourcesEqual`, `firstResourceError`, `DEFAULT_RESOURCE_PATH`
592
- - `isAllowedRedirectUri`, `CLAUDE_CALLBACK`
593
- - `issueAuthCode`, `consumeAuthCode`, `newClientId`
594
- - `verifyPkceS256`, `sha256Base64Url`, `randomBase64Url`
595
- - `readClientAuth`, `firstClientAuthError`, `unregisteredClientsAllowed`
596
- - `parseScope`, `formatScope`, `requestedScopes`, `firstScopeError`
597
- - `GRANT_TYPES`, `OAUTH_ERRORS`, `DEFAULT_SCOPE`, `TOKEN_ENDPOINT_AUTH_METHODS`
598
- - Types: `OAuthUser`, `MintedToken`, `OAuthPorts`, `OAuthRouterOptions`, `AuthCodeRecord`, `CodeStore`, `ClientStore`, `RegisteredClient`, `ClientAuth`, `TokenEndpointAuthMethod`, `MintAccessTokenInput`, `RefreshAccessTokenInput`, `RevokeTokenInput`
599
-
600
- </details>
601
-
602
- <details>
603
- <summary><code>mcp-trellis/node</code></summary>
604
-
605
- - `asNodeHandler`, `resolveOrigin`, `isAllowedOrigin`, `toWebRequest`, `sendWebResponse`, `readNodeBody`
606
- - Types: `NodeRequestLike`, `NodeResponseLike`, `AsNodeHandlerOptions`
607
-
608
- </details>
609
-
610
- ## Protocol promises
611
-
612
- - Notifications → HTTP **202** with an empty body
613
- - Batch arrays → `-32600` (MCP 2025-06-18)
614
- - Protocol version negotiated from client `initialize` params
615
- - Query-string tokens rejected (`token` and RFC 6750 `access_token`)
616
- - `tools/call` checks `principal.scopes` (`*` = all), and the OAuth grant that produced them is validated at `authorize`
617
- - With `createMcpApp`, tokens whose audience is not this server's canonical resource are rejected by the library
618
- - **`fetch` never rejects.** If a host port you supply (`authenticate`, `context`, `resolveUser`, `mintAccessToken`, `clientStore`, …) throws, `createMcpHandler`, `createOAuthRouter`, and `createMcpApp` all catch it and return a real `500` (`-32603` for MCP, `server_error` for OAuth) instead of an unhandled rejection — whose shape and whether it even reaches the client varies by runtime. `asNodeHandler` catches independently too, since on raw `http.createServer` a rejection there leaves the socket open with no response ever sent.
619
-
620
- ## Threat model (library)
621
-
622
- This is a **library** threat model, not a third-party audit badge.
623
-
624
- | Risk | Mitigation |
625
- |------|------------|
626
- | Confused deputy (token usable at the wrong MCP) | RFC 8707: `resource` required on authorize + token; bound into the auth code; must equal this AS’s canonical resource (`canonicalResource(origin, resourcePath)`). Passed into `mintAccessToken` / `refreshAccessToken` so you can embed `aud`. |
627
- | Refresh token replayed at another resource on the same AS | Library cannot inspect opaque refresh tokens — **`refreshAccessToken` MUST reject tokens not originally issued for the given `resource`** |
628
- | Audience not enforced at the RS | **`createMcpApp` enforces this** — `verifyToken` returns the token's `audience` and the library rejects any mismatch against `canonicalResource(origin, resourcePath)`. With bare `createMcpHandler`, the check is yours. |
629
- | Open redirect after login | Your login page must validate `next` is same-origin before redirecting |
630
- | Auth-code replay across instances | Pass a shared `codeStore`; in-memory jti map is process-local only |
631
- | Origin spoofing on Node | Pass explicit `origin`, or `trustProxy: true` only behind a proxy that strips client `X-Forwarded-*`. Host-derived origin **requires** non-empty `allowedOrigins` on `asNodeHandler` (`["*"]` to opt out loudly) |
632
- | Scope escalation at authorize | Requested `scope` is validated against the advertised `scopes` and rejected with `invalid_scope`; the grant is bound into the auth code and handed to `mintAccessToken` |
633
- | Stolen access or refresh token | Host `revokeToken` (denylist or equivalent) that `verifyToken` and `refreshAccessToken` consult. Unknown / wrong-client tokens MUST no-op, not throw. Well-formed authenticated revoke is 200 even if the token is unknown |
634
- | Stolen confidential-client secret | `clientStore.verifySecret` owns comparison — store hashes, not plaintext. The library never sees or persists credentials |
635
- | Registration-free DCR | `/register` returns a random `client_id` that is **never stored**; any unknown `client_id` works with an allowlisted `redirect_uri`. Defensible for public clients with PKCE — the model dynamic connectors (Claude, Codex) actually need. Pre-registered clients bypass this entirely, bound to their own `redirectUris` and auth method. CIMD replaces this model |
636
- | Naming only confidential clients doesn't actually block public ones | `clients: ["gemini"]` (or `allowUnregisteredClients: false`) is **enforced**: DCR unmounted, dropped from AS metadata, unresolved `client_id` rejected with `unauthorized_client` at `authorize` and `invalid_client` at `token` and `revoke` |
637
- | DCR / token-endpoint abuse | Edge or reverse-proxy rate limiting on `/register` and `/token`. The library does not rate-limit |
638
-
639
- **Also designed in:** PKCE S256 (timing-safe, length-capped); HMAC auth codes bind `userId`, `clientId`, `redirectUri`, challenge, and `resource`; redirect allowlist + DCR filter; no query-string tokens (`token` and `access_token`); honest `grant_types_supported`; `/token` and `/revoke` omit CORS `*`; audit fires on auth/scope denial as well as tool results.
640
-
641
- **Known limits:** in-memory jti map is process-local (multi-instance needs `codeStore`); loopback redirect allowlist permits any path/port on `localhost` / `127.0.0.1` (expected for native clients); DCR does not bind credentials to a stored client record until CIMD.
642
-
643
- ## Not in scope
644
-
645
- Explicitly **out** of this package (do not expect parity with full MCP hosts or enterprise AS products):
646
-
647
- - **Tools only** — no `resources/*`, no `prompts/*`, and capabilities advertise tools alone
648
- - **No server-initiated messages** — Streamable HTTP is served as a single JSON response per POST (which the spec permits, and which Gemini Enterprise requires). No SSE response streams, so no `notifications/progress` and no long-running tool keepalive
649
- - **No sessions** — removed from the protocol in `2026-07-28` anyway
650
- - **No enterprise-managed auth** — no ID-JAG, no IdP product integration; you wire login + minting
651
- - **No embedded product surface** — no token store, sessions, or login UI
652
- - **No stdio** transport; no batch JSON-RPC arrays
653
- - **No DPoP, PAR, or `client_credentials`** — this AS is authorization_code (+ optional refresh and revoke)
654
- - **No rate limiting** — put quotas on `/register` and `/token` at the edge or reverse proxy
655
- - **No paid external security audit** claimed here — see Threat model above
656
-
657
- See [docs/ROADMAP.md](docs/ROADMAP.md) for what is next — CIMD and the
658
- `2026-07-28` stateless core are the two open items.
659
-
660
- ## Scripts
144
+ PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
661
145
 
662
146
  ```bash
663
147
  npm test
@@ -665,10 +149,6 @@ npm run build
665
149
  npm run typecheck
666
150
  ```
667
151
 
668
- ## Contributing
669
-
670
- PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
671
-
672
152
  ## License
673
153
 
674
154
  MIT