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/docs/guide.md ADDED
@@ -0,0 +1,417 @@
1
+ # Guide
2
+
3
+ Practical deep dive: architecture, host recipes, ports, tools, and multi-tenant
4
+ connectors. For a working minimal server, start from the [README](../README.md).
5
+ API tables and exports live in [reference.md](reference.md). Security guarantees
6
+ and threat model: [security.md](security.md).
7
+
8
+ [Architecture](#how-it-fits-together) · [Clients](#clients) · [Recipes](#recipes) ·
9
+ [Compose the primitives](#advanced-compose-the-primitives) ·
10
+ [Ports](#ports--what-you-implement) · [Consent](#consent) ·
11
+ [Tool registry](#tool-registry) · [Multi-tenant](#multi-tenant-saas-connectors)
12
+
13
+ ## How it fits together
14
+
15
+ What `createMcpApp` wires for you — two pieces, one deciding which requests go
16
+ to the other:
17
+
18
+ ![Architecture: connector → edge → OAuth or MCP → your ports](diagrams/architecture.svg)
19
+
20
+ Typical first-connection path:
21
+
22
+ ![Sequence: OAuth authorize and token, then MCP tool call](diagrams/first-connection.svg)
23
+
24
+ You only touch this routing directly if you skip `createMcpApp` — see
25
+ [Advanced: compose the primitives](#advanced-compose-the-primitives).
26
+
27
+ ## Clients
28
+
29
+ Name the connector clients you serve; the AS is configured to match.
30
+
31
+ | Client | Registration | Token endpoint auth | Notes |
32
+ |--------|--------------|---------------------|-------|
33
+ | `claude` | Dynamic (DCR), public + PKCE | `none` | Claude Custom Connectors callback is allowlisted for you |
34
+ | `gemini` | **Pre-registered**, confidential | `client_secret_basic`, `client_secret_post` | Gemini Enterprise is registered with your org's IdP — supply `auth.clientStore` |
35
+ | `codex` | OAuth 2.1 per MCP auth spec, public + PKCE | `none` | ChatGPT / Codex share one connector contract |
36
+
37
+ ```ts
38
+ createMcpApp({
39
+ clients: ["claude", "gemini"],
40
+ auth: {
41
+ // …
42
+ clientStore: {
43
+ get: async (clientId) => lookupClient(clientId),
44
+ verifySecret: async (clientId, presented) =>
45
+ checkSecret(clientId, presented),
46
+ },
47
+ },
48
+ });
49
+ ```
50
+
51
+ Naming a pre-registered client without a `clientStore` throws at construction
52
+ rather than failing at the first token exchange. Secret storage and comparison
53
+ stay in your `clientStore` — the library never sees or persists credentials.
54
+
55
+ Naming only pre-registered clients (`clients: ["gemini"]`, no `claude` /
56
+ `codex`) sets `allowUnregisteredClients: false`: `/register` is unmounted
57
+ and dropped from AS metadata, unknown ids are rejected with
58
+ `unauthorized_client` at `authorize` and `invalid_client` at `token` and
59
+ `revoke`. Naming `claude` or `codex` alongside `gemini` reopens dynamic
60
+ registration.
61
+
62
+ ## Recipes
63
+
64
+ ### Cloudflare Worker, Next.js, Deno, Bun
65
+
66
+ `createMcpApp` returns a plain `fetch`, so the same object mounts everywhere:
67
+
68
+ ```ts
69
+ // Worker
70
+ export default { fetch: (req: Request, env: Env) => buildApp(env).fetch(req) };
71
+
72
+ // Next.js App Router — app/[[...mcp]]/route.ts
73
+ export const GET = (req: Request) => app.fetch(req);
74
+ export const POST = (req: Request) => app.fetch(req);
75
+ export const OPTIONS = (req: Request) => app.fetch(req);
76
+
77
+ // Node / Express / Cloud Run
78
+ import { asNodeHandler } from "mcp-trellis/node";
79
+ export const mcp = asNodeHandler(app, { origin: process.env.MCP_PUBLIC_ORIGIN! });
80
+ ```
81
+
82
+ The Node bridge reads the request stream when `req.body` is absent, so raw
83
+ `http.createServer` works without a body-parser. Runnable recipe:
84
+ [`examples/http-server.ts`](../examples/http-server.ts).
85
+
86
+ ## Advanced: compose the primitives
87
+
88
+ Skip `createMcpApp` when you want to own the routing. Same behavior, more wiring:
89
+
90
+ ```ts
91
+ import { createMcpHandler, createToolRegistry, parseBearer } from "mcp-trellis";
92
+ import { createOAuthRouter } from "mcp-trellis/oauth";
93
+
94
+ type Ctx = { userId: string };
95
+ type Env = { OAUTH_CODE_SECRET: string };
96
+
97
+ const registry = createToolRegistry<Ctx>([
98
+ {
99
+ name: "ping_db",
100
+ description: "Health check",
101
+ inputSchema: { type: "object", properties: {} },
102
+ scope: "read",
103
+ handler: async () => "ok",
104
+ },
105
+ ]);
106
+
107
+ const handler = createMcpHandler<Ctx>({
108
+ registry,
109
+ serverInfo: { name: "my-app", version: "1.0.0" },
110
+ instructions: "Tools available to authenticated clients.",
111
+ wwwAuthenticate: (req) => ({
112
+ realm: "my-app",
113
+ resourceMetadataUrl: `${new URL(req.url).origin}/.well-known/oauth-protected-resource/mcp`,
114
+ }),
115
+ ports: {
116
+ authenticate: async (req) => {
117
+ const token = parseBearer(req.headers.get("authorization"));
118
+ if (!token) return null;
119
+ // Reject tokens whose aud ≠ this server's canonical resource.
120
+ return { id: "user-1", scopes: ["read"] };
121
+ },
122
+ context: async (_req, principal) => ({
123
+ userId: principal?.id ?? "",
124
+ }),
125
+ },
126
+ });
127
+
128
+ export default {
129
+ async fetch(request: Request, env: Env): Promise<Response> {
130
+ const oauth = createOAuthRouter({
131
+ ports: {
132
+ codeSecret: env.OAUTH_CODE_SECRET,
133
+ resolveUser: async () => ({ id: "user-1" }),
134
+ loginUrl: (req, next) =>
135
+ `${new URL(req.url).origin}/login?next=${encodeURIComponent(next)}`,
136
+ mintAccessToken: async ({ userId, resource }) => ({
137
+ // Embed `resource` as the token audience (RFC 8707).
138
+ accessToken: await issueUserToken(userId, { aud: resource }),
139
+ expiresIn: 3600,
140
+ scope: "mcp",
141
+ }),
142
+ },
143
+ });
144
+
145
+ const oauthResponse = await oauth.tryHandle(request);
146
+ if (oauthResponse) return oauthResponse;
147
+ if (new URL(request.url).pathname === "/mcp") return handler.fetch(request);
148
+ return new Response("Not found", { status: 404 });
149
+ },
150
+ };
151
+ ```
152
+
153
+ ### Next.js App Router
154
+
155
+ Delegate Web `Request` straight through — same handler as above:
156
+
157
+ ```ts
158
+ // app/mcp/route.ts
159
+ import { handler, oauth } from "@/lib/mcp"; // your shared setup
160
+
161
+ export async function GET(req: Request) {
162
+ const oauthResponse = await oauth.tryHandle(req);
163
+ if (oauthResponse) return oauthResponse;
164
+ return handler.fetch(req);
165
+ }
166
+
167
+ export async function POST(req: Request) {
168
+ const oauthResponse = await oauth.tryHandle(req);
169
+ if (oauthResponse) return oauthResponse;
170
+ return handler.fetch(req);
171
+ }
172
+
173
+ export async function OPTIONS() {
174
+ return handler.fetch(new Request("https://local/mcp", { method: "OPTIONS" }));
175
+ }
176
+ ```
177
+
178
+ Also mount `.well-known/*` and `/mcp/oauth/*` the same way (or route everything through one catch-all that calls `oauth.tryHandle` first).
179
+
180
+ ### Node HTTP `(req, res)`
181
+
182
+ Express, Cloud Functions, Cloud Run, `http.createServer` — bridge with
183
+ `asNodeHandler`. Runnable recipe: [`examples/http-server.ts`](../examples/http-server.ts)
184
+ (`npx tsx examples/http-server.ts`).
185
+
186
+ ## Ports — what you implement
187
+
188
+ The library stays protocol-shaped. Your app plugs in the seams:
189
+
190
+ ![Ports: library calls into your authenticate, context, resolveUser, mintAccessToken](diagrams/ports.svg)
191
+
192
+ ### App (`createMcpApp`)
193
+
194
+ | Port | Role |
195
+ |------|------|
196
+ | `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` |
197
+ | `resolveUser`, `loginUrl`, `mintAccessToken`, `codeSecret` | Same as the OAuth ports below |
198
+ | `clientStore?` | Pre-registered clients; required for confidential clients like Gemini |
199
+ | `refreshAccessToken?` | If set, metadata advertises `refresh_token` |
200
+ | `revokeToken?` | RFC 7009; presence mounts `/revoke` and advertises `revocation_endpoint` |
201
+
202
+ `consent?` (approval-page policy — see [Consent](#consent), below) and
203
+ `defaultScopes?` sit as top-level `McpAppOptions`, not under `auth` — they
204
+ are policy, not credentials, the same reasoning as `allowLoopback`.
205
+ | `context?`, `audit?` | Same as the MCP ports below; `context` defaults to an empty object. Receives `principal.claims` when `verifyToken` set them |
206
+
207
+ ### MCP (`createMcpHandler`)
208
+
209
+ The lower-level handler. Here the audience check is **yours** — prefer
210
+ `createMcpApp` unless you need the control. See [security.md](security.md).
211
+
212
+ | Port | Role |
213
+ |------|------|
214
+ | `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** |
215
+ | `context(req, principal)` | Build per-request ctx for tools (DB, env, …). Use `principal?.claims` for tenant / plan / role without re-decoding the bearer |
216
+ | `audit?(entry)` | Opt-in **metrics hook**: pass any function to receive tool results **and** auth denials (bad token, missing scope, query-string token), plus the 500 path when a host port throws. `entry.method` is `""` for transport-level denials made before parsing. Protocol errors (malformed JSON, bad `jsonrpc`, unsupported protocol version, oversized body, batch) are **not** audited. Throwing from this port never fails the request, and neither does hanging — see `auditTimeoutMs` below. Omit it and the library stays silent |
217
+
218
+ `auditTimeoutMs` (default 1000ms) races `audit` against a timeout, so a
219
+ slow sink can't stall a response past that bound either.
220
+
221
+ Pass any function to get per-request metrics — do what you want with them
222
+ (DB, APM, admin UI). The library never writes to stdout or a store on its
223
+ own. `consoleAudit` is only a convenience for local / small deploys;
224
+ [`examples/audit-store.ts`](../examples/audit-store.ts) shows a ring buffer and
225
+ a DB-shaped sketch:
226
+
227
+ ```ts
228
+ import { createMcpApp, consoleAudit } from "mcp-trellis";
229
+
230
+ createMcpApp({ /* … */, audit: consoleAudit });
231
+
232
+ // Or your own sink:
233
+ createMcpApp({
234
+ /* … */,
235
+ audit: (entry) => {
236
+ // entry: { method, tool?, principalId?, ok, error?, durationMs }
237
+ void metrics.record(entry);
238
+ },
239
+ });
240
+ ```
241
+
242
+ Failures (denials and tool exceptions) go to `console.error` when using
243
+ `consoleAudit`; everything else to `console.log`. Entries never carry raw
244
+ exceptions or stack traces, so console output is safe. For anything beyond
245
+ stdout, write your own `audit` function — that is the intended production path.
246
+
247
+ `wwwAuthenticate` can be a static object or `(req) => …` when the PRM URL depends on origin.
248
+
249
+ By default `initialize`, `ping`, and notifications are public. Override `publicMethods` if every method must require Bearer.
250
+
251
+ ### OAuth (`createOAuthRouter`)
252
+
253
+ | Port | Role |
254
+ |------|------|
255
+ | `codeSecret` | HMAC secret for signed auth codes (string or `(req) => string`) |
256
+ | `resolveUser` | Consenting user, or `null` → redirect to `loginUrl` |
257
+ | `loginUrl` | Where unauthenticated authorize requests go |
258
+ | `mintAccessToken` | Issue a **user-bound**, **audience-bound** access token (`resource` is the RFC 8707 URI) — never a shared god token |
259
+ | `refreshAccessToken?` | If set, metadata advertises `refresh_token`; also receives `resource` |
260
+ | `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 |
261
+ | `codeStore?` | Shared single-use jti store for multi-instance (`consume(jti, expMs)`); pruning in-memory default otherwise. Also backs consent-ticket single-use — see below |
262
+ | `clientStore?` | Pre-registered clients: `get(clientId)` returns registered redirect URIs and auth method. Two ways to authenticate a confidential client's secret — **prefer `secretHash(clientId)`**: return a stored hash from `hashClientSecret`, and the library compares it with the same constant-time primitive it uses everywhere else; `verifySecret(clientId, presented)` is the fallback when you'd rather compare yourself. Credentials never enter the library either way |
263
+ | `audit?` | Opt-in **OAuth-side metrics hook** — mirrors the MCP-side one above. `/token`/`/revoke` collapse every client-auth failure to one generic `invalid_client` (an enumeration-oracle fix — see [security.md](security.md)), and a rejected `codeSecret` surfaces to the caller only as `server_error`. This is where you get the real reason back (`entry.reason`) without handing it to an unauthenticated caller. Throwing never fails the request |
264
+
265
+ ```ts
266
+ import { hashClientSecret } from "mcp-trellis/oauth";
267
+
268
+ // Once, when you provision a confidential client (e.g. Gemini Enterprise):
269
+ const stored = await hashClientSecret(theSecretYouGenerated, codeSecretValue);
270
+ // Save `stored` — never the plaintext secret — and return it from
271
+ // clientStore.secretHash(clientId) at verify time.
272
+ ```
273
+
274
+ 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`.
275
+
276
+ **Scope is negotiated:** `authorize` validates the requested `scope` against `scopes` (default `["mcp"]`) and rejects anything outside it with `invalid_scope`. An omitted `scope` grants `defaultScopes` — which defaults to the full advertised set **only when `scopes` has a single entry**. Once `scopes` names more than one, `createOAuthRouter` throws at construction unless you set `defaultScopes` explicitly: silently granting everything advertised to a client that asked for nothing is exactly the escalation least-privilege scoping exists to prevent. 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.
277
+
278
+ **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`. Separately, `requireRegisteredClients` (default **true** since 1.0) keeps DCR mounted but rejects any `client_id` that isn't `clientStore`-resolved or a sealed id this server itself issued via `/register` — see [security.md](security.md). Set `requireRegisteredClients: false` only when you intentionally accept invented public ids.
279
+
280
+ ### Consent
281
+
282
+ `/authorize` never issues a code directly, even for a resolved session — it
283
+ renders an approval interstitial and only issues the code once the user
284
+ POSTs an explicit approval to `/consent`. This closes a cross-site flow
285
+ where an attacker's page could otherwise walk a logged-in visitor through
286
+ `/authorize` with an attacker-chosen `client_id` and a loopback
287
+ `redirect_uri`, then catch the code on a local listener; PKCE alone does
288
+ not stop this, because it proves possession of the verifier, not the
289
+ identity of a legitimate client.
290
+
291
+ | Option (`consent`) | Default | Role |
292
+ |---|---|---|
293
+ | `render?(input)` | built-in interstitial | Render your own approval page — return any `Response`, or redirect to a route you own. `input.ticket` is the opaque value your form must echo back as `consent_ticket` |
294
+ | `preApprovedClientIds?` | `[]` | Skip the screen for these ids — **only** when `clientStore` actually resolves the id. A DCR or self-invented id is never trusted to pre-approve itself, no matter what's in this list |
295
+
296
+ The built-in interstitial ships `Content-Security-Policy`,
297
+ `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, and HTML-escapes
298
+ every reflected value (`client_id` and `redirect_uri` are attacker-
299
+ controlled input up to this point). If you supply `render`, you own that
300
+ hardening for your own markup.
301
+
302
+ Approval tickets are sealed (AES-GCM, single-use via the same store as
303
+ auth-code replay protection, 5-minute TTL) and re-checked against the
304
+ resolved user at redemption — approving as one user and redeeming as
305
+ another is rejected, and a ticket can never be presented at `/token` as an
306
+ authorization code, or vice versa.
307
+
308
+ ## Tool registry
309
+
310
+ Schema and handler live in one place — no parallel "defs" and "handlers" lists to keep in sync:
311
+
312
+ ```ts
313
+ createToolRegistry(tools, { validateArgs: false }) // default: off, easy adoption
314
+ // onToolError?: (exc) => string — default redacts to "Tool execution failed"
315
+ ```
316
+
317
+ - Handler may return a `string` or `{ content, isError? }`
318
+ - Unknown tools and thrown errors become `isError: true` (not transport errors)
319
+ - 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
320
+ - Duplicate tool names throw at registry construction
321
+ - Turn `validateArgs: true` once clients send schema-valid args. The evaluated
322
+ subset is `type`, `enum`, `required`, `properties`, `items`, `minimum`,
323
+ `maximum`. Pure metadata (`description`, `title`, `$schema`, `$id`,
324
+ `$comment`, `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`,
325
+ `format`) is allowed and never evaluated. Any other keyword is not
326
+ evaluated — and with `validateArgs: true` `createToolRegistry` throws at
327
+ construction so a schema like `pattern` or `additionalProperties` cannot
328
+ silently look enforced
329
+
330
+ ### Wrapping an API as a tool
331
+
332
+ `ToolDef` stays the primitive. `defineTool` and `apiTool` are optional sugar for
333
+ the common case — turning "I have an API" into "I have an MCP tool" without
334
+ hand-rolling arg validation or fetch/error plumbing each time:
335
+
336
+ ```ts
337
+ import { apiTool } from "mcp-trellis";
338
+
339
+ const getWeather = apiTool({
340
+ name: "get_weather",
341
+ description: "Current weather for a city",
342
+ inputSchema: {
343
+ type: "object",
344
+ properties: { city: { type: "string" } },
345
+ required: ["city"],
346
+ },
347
+ // any Standard Schema v1 validator (https://standardschema.dev)
348
+ input: citySchema,
349
+ request: (_ctx, { city }) =>
350
+ `https://api.example.com/weather?city=${encodeURIComponent(city)}`,
351
+ respond: async (res) => {
352
+ const data = await res.json();
353
+ return `${data.tempC}°C, ${data.condition}`;
354
+ },
355
+ });
356
+ ```
357
+
358
+ - **`inputSchema` stays required** — it's what `tools/list` actually advertises to
359
+ clients, and there's no library-agnostic way to derive JSON Schema from an
360
+ arbitrary Standard Schema validator. Keep the two in sync.
361
+ - **`input`** (optional) parses and types `args` before your handler runs — any
362
+ [Standard Schema](https://standardschema.dev)-compliant validator works,
363
+ nothing library-specific. A validation failure returns `isError: true` with
364
+ the issues and never calls your handler.
365
+ - **`apiTool`** builds on `defineTool`: give it `request` (build the outgoing
366
+ call from typed args) and optionally `respond` (shape a 2xx response;
367
+ default is the body as text). Non-2xx becomes `isError: true` automatically
368
+ — **status only by default, the upstream body is never forwarded** (it
369
+ could contain internal detail, tokens, or SQL an upstream error page
370
+ echoes back). Pass `onError(res)` to shape it yourself, e.g. include a
371
+ redacted version of the body. `fetch` is overridable — testing, request
372
+ signing, a custom agent.
373
+ - `defineTool` alone (without `request`/`respond`) is just the typed-args
374
+ layer over a regular `ToolDef` — use it for anything, not only REST calls.
375
+
376
+ ## Multi-tenant (SaaS connectors)
377
+
378
+ Resource-per-tenant already works with **zero library change**.
379
+ `canonicalResource(origin, resourcePath)` is derived per request, and the
380
+ audience check in `createMcpApp` rejects a token minted for another origin
381
+ before any tool runs. Deploy `acme.mcp.example.com` and
382
+ `globex.mcp.example.com` as separate Hosts and cross-tenant token reuse is a
383
+ protocol failure, not an application bug.
384
+
385
+ For the in-tool half, derive the tenant from the request Host (or from the
386
+ verified audience after the library check) in your `context` port — that is
387
+ the isolation key. Optional `claims` from `verifyToken` (plan, role, …) are
388
+ handy metadata on `principal`; they are **not** Host→tenant authorization.
389
+ A forged `claims.tenant_id` with a matching audience must not change which
390
+ tenant's data a tool can see.
391
+
392
+ **Host caveat (API requirement).** On Node, multi-tenant needs a Host-derived
393
+ origin (`trustProxy: true` without a fixed `origin`). That path **requires**
394
+ a non-empty `allowedOrigins` at `asNodeHandler` construction — omit it and
395
+ construction throws. Use exact origins and/or `*.example.com` wildcards for
396
+ **https** subdomains; the **resolved** origin must match or the request
397
+ returns **400** before any OAuth or MCP handling. Loud opt-out:
398
+ `allowedOrigins: ["*"]` (admits any Host — only behind a proxy you trust to
399
+ strip client `X-Forwarded-*`). A fixed `origin` absent from a non-empty list
400
+ fails at construction; with a fixed `origin` the allowlist is optional
401
+ defense-in-depth. Absolute-form request targets are rebased onto that
402
+ validated origin, so a spoofed URL line cannot override it. A fixed `origin`
403
+ is otherwise safe but collapses every tenant onto one resource, so it cannot
404
+ do subdomain-per-tenant.
405
+
406
+ On Cloudflare Workers (or any wildcard route), apply the equivalent
407
+ allowlist check on `new URL(req.url).origin` before calling `app.fetch`
408
+ (`isAllowedOrigin` from `mcp-trellis/node`, or the same module the Worker
409
+ example imports).
410
+
411
+ Typechecked sketch: [`examples/multi-tenant.ts`](../examples/multi-tenant.ts)
412
+ (needs a Host matching `*.mcp.example.com` — loopback alone is rejected).
413
+ Shared `codeStore` / revocation shapes: [`examples/stores.ts`](../examples/stores.ts)
414
+ (atomic `setIfAbsent` for auth codes; Workers KV alone is not enough).
415
+ `audit` metrics sink (ring + DB sketch): [`examples/audit-store.ts`](../examples/audit-store.ts).
416
+ Worker mount: [`examples/cloudflare-worker.ts`](../examples/cloudflare-worker.ts)
417
+ (typecheck-only in CI — no wrangler).
@@ -0,0 +1,178 @@
1
+ # Reference
2
+
3
+ Package surface, routes, MCP methods, status codes, options, and exports.
4
+ Ports and recipes: [guide.md](guide.md). Security: [security.md](security.md).
5
+
6
+ ## Package surface
7
+
8
+ | Import | What you get |
9
+ |--------|----------------|
10
+ | `mcp-trellis` | `createMcpApp`, client profiles, MCP handler, tool registry, bearer helpers, HTTP utils |
11
+ | `mcp-trellis/oauth` | OAuth 2.1 AS router, PKCE, auth codes, client auth, metadata |
12
+ | `mcp-trellis/node` | `asNodeHandler` + `resolveOrigin` for Node `(req, res)` |
13
+
14
+ `createMcpApp` is the batteries-included layer. `createMcpHandler` and
15
+ `createOAuthRouter` remain exported and unchanged — reach for them when you want
16
+ to own the routing or mount the two halves separately. See
17
+ [compose the primitives](guide.md#advanced-compose-the-primitives).
18
+
19
+ ## Default OAuth routes
20
+
21
+ With defaults `resourcePath: "/mcp"` and `oauthPath: "/mcp/oauth"`:
22
+
23
+ | Path | Purpose |
24
+ |------|---------|
25
+ | `/.well-known/oauth-protected-resource` (+ `/mcp`) | Protected resource metadata |
26
+ | `/.well-known/oauth-authorization-server` (+ `/mcp`) | Authorization server metadata |
27
+ | `/mcp/oauth/register` | Dynamic client registration (unmounted when DCR is off) |
28
+ | `/mcp/oauth/authorize` | Authorization endpoint — GET only. Renders a consent interstitial rather than redirecting directly; see `/consent` below and [security.md](security.md). Once `redirect_uri` is validated, every remaining error redirects to it with `?error=…` (RFC 6749 §4.1.2.1) rather than returning JSON; an invalid `redirect_uri` itself, and an oversized `state`, are the two exceptions and stay direct 400s |
29
+ | `/mcp/oauth/consent` | POST only. Approves or denies a consent ticket issued by `/authorize` and, on approval, issues the code and redirects to `redirect_uri` |
30
+ | `/mcp/oauth/token` | Token endpoint. JSON or form body |
31
+ | `/mcp/oauth/revoke` | Token revocation (RFC 7009; mounted only when `revokeToken` is set). JSON or form body |
32
+
33
+ ## Supported MCP methods
34
+
35
+ | Method | Auth | Notes |
36
+ |--------|------|-------|
37
+ | `initialize` | public | Negotiates protocol version; returns `capabilities.tools` |
38
+ | `ping` | public | Empty result |
39
+ | `tools/list` | Bearer | Lists registry entries |
40
+ | `tools/call` | Bearer + scope | Runs the tool; missing scope → 401 |
41
+ | `notifications/*` | public | HTTP 202 empty body |
42
+
43
+ Anything else → JSON-RPC `-32601`. Capabilities advertise **tools only**.
44
+
45
+ ## HTTP and JSON-RPC status codes
46
+
47
+ | HTTP | When |
48
+ |------|------|
49
+ | **202** | Notification (empty body) |
50
+ | **400** | Parse error, batch array, invalid request, `jsonrpc` not `"2.0"`, unsupported `MCP-Protocol-Version` |
51
+ | **401** | Missing/invalid Bearer; includes `WWW-Authenticate`; echoes the real request id, not `null` |
52
+ | **405** | Wrong HTTP verb on MCP |
53
+ | **413** | Request body over the cap — `/mcp` (1 MiB default), `/token`/`/revoke`/`/register` (64 KiB default) |
54
+
55
+ | JSON-RPC code | Constant |
56
+ |---------------|----------|
57
+ | `-32700` | `JSONRPC_PARSE_ERROR` |
58
+ | `-32600` | `JSONRPC_INVALID_REQUEST` (batches) |
59
+ | `-32601` | `JSONRPC_METHOD_NOT_FOUND` |
60
+ | `-32602` | `JSONRPC_INVALID_PARAMS` |
61
+ | `-32603` | `JSONRPC_INTERNAL_ERROR` |
62
+ | `-32001` | `JSONRPC_UNAUTHORIZED` |
63
+
64
+ ## `createMcpHandler` options
65
+
66
+ | Option | Required | Description |
67
+ |--------|----------|-------------|
68
+ | `registry` | yes | From `createToolRegistry` |
69
+ | `ports` | yes | `authenticate`, `context`, optional `audit` — see [Ports](guide.md#ports--what-you-implement) |
70
+ | `serverInfo` | yes | `{ name, version }` |
71
+ | `wwwAuthenticate` | yes | Static or `(req) => …` for RFC 9728 PRM URL |
72
+ | `instructions` | no | Returned on `initialize` |
73
+ | `publicMethods` | no | Default: `initialize`, `ping`, notifications |
74
+ | `auditTimeoutMs` | no | Max time to wait for `ports.audit` before responding anyway. Default 1000ms — see [security.md](security.md) |
75
+
76
+ `ports.audit` fires on tool results and on auth denials (bad token, missing
77
+ scope, query-string token), plus the 500 path when a host port throws.
78
+ Malformed JSON, bad `jsonrpc`, unsupported `MCP-Protocol-Version`, oversized
79
+ bodies, and batch requests return JSON-RPC client errors without an audit
80
+ entry — those are protocol errors, not auth denials.
81
+
82
+ ## `createOAuthRouter` options
83
+
84
+ | Option | Default | Description |
85
+ |--------|---------|-------------|
86
+ | `ports` | — | Required OAuth ports — see [Ports](guide.md#ports--what-you-implement) |
87
+ | `resourcePath` | `"/mcp"` | MCP resource path in PRM. Normalized once at construction (a trailing slash is stripped); construction throws if it starts with `/.well-known` or equals `oauthPath` |
88
+ | `oauthPath` | `"/mcp/oauth"` | Prefix for authorize / token / register / revoke |
89
+ | `realm` | — | Optional realm string |
90
+ | `scopes` | `["mcp"]` | Advertised in metadata, and the ceiling `authorize` validates against |
91
+ | `defaultScopes` | full `scopes` | Scopes granted when a client omits `scope` entirely. **Required at construction** once `scopes` has more than one entry — the router throws rather than silently granting everything advertised. Must be a subset of `scopes` |
92
+ | `tokenEndpointAuthMethods` | `["none"]` | Advertised client auth methods |
93
+ | `allowUnregisteredClients` | `true` | `false` requires `clientStore`, unmounts DCR, and rejects unknown `client_id`s (`unauthorized_client` / `invalid_client`) |
94
+ | `requireRegisteredClients` | `true` | Reject any `client_id` that isn't `clientStore`-resolved or self-sealed via this server's own `/register` (see [security.md](security.md)). DCR stays mounted, unlike `allowUnregisteredClients: false` — a client just can't invent an id out of thin air |
95
+ | `redirect` | see below | Redirect URI allowlist |
96
+ | `consent` | see below | Consent/approval policy for `/authorize` |
97
+
98
+ **`redirect` (`RedirectAllowlistOptions`):**
99
+
100
+ | Field | Default | Description |
101
+ |-------|---------|-------------|
102
+ | `extra` | `[]` | Exact-match redirect URIs |
103
+ | `allowLoopback` | `true` | Allow `http:` loopback — `127.0.0.1`, `[::1]` (any port). `localhost` and `https:` loopback are not accepted — see [security.md](security.md) |
104
+ | `allowClaude` | `true` | Allow the Claude.ai Custom Connector callback |
105
+
106
+ Pre-registered clients, and DCR-issued clients whose id was sealed by this
107
+ server's own `/register`, bypass this allowlist entirely — they are
108
+ validated against their own bound `redirectUris`.
109
+
110
+ **`consent` (`ConsentOptions`):**
111
+
112
+ | Field | Default | Description |
113
+ |-------|---------|-------------|
114
+ | `render` | built-in interstitial | Render the approval page yourself — return any `Response`, including a redirect to your own route. Receives a `ConsentRequest` with the validated `clientId`, `redirectUri`, `scope`, `resource`, `user`, and an opaque `ticket` to embed in your form's `consent_ticket` field |
115
+ | `preApprovedClientIds` | `[]` | Client ids that skip the interstitial and go straight to code issuance. Only honored when `clientStore` actually resolves the id — a DCR or self-invented id is never trusted to skip it |
116
+
117
+ ## `createMcpApp` options
118
+
119
+ | Option | Default | Description |
120
+ |--------|---------|-------------|
121
+ | `serverInfo`, `tools`, `auth` | — | Required |
122
+ | `clients` | `["claude"]` | Connector profiles to serve |
123
+ | `resourcePath` | `"/mcp"` | MCP endpoint path |
124
+ | `oauthPath` | `` `${resourcePath}/oauth` `` | OAuth prefix |
125
+ | `scopes` | `["mcp"]` | Scopes this server grants |
126
+ | `defaultScopes` | full `scopes` | Same as `OAuthRouterOptions.defaultScopes` — required once `scopes` has more than one entry |
127
+ | `realm` | `serverInfo.name` | `WWW-Authenticate` realm |
128
+ | `extraRedirectUris` | `[]` | Callbacks beyond the client profiles |
129
+ | `allowLoopback` | `true` | Allow loopback redirects — see the narrowed `http:`-only, no-`localhost` behavior in [security.md](security.md) |
130
+ | `consent` | built-in interstitial | Same as `OAuthRouterOptions.consent` — override the approval page or pre-approve specific client ids |
131
+ | `requireRegisteredClients` | `true` | Same as `OAuthRouterOptions.requireRegisteredClients` — inventing a public `client_id` is rejected unless it comes from `clientStore` or this server's own `/register` |
132
+ | `instructions`, `validateArgs`, `onToolError`, `context`, `audit`, `auditTimeoutMs` | — | Passed through. `audit`/`auditTimeoutMs` are the MCP-side (tool-call) hook — the OAuth side has its own, separate `auth.audit` (see [Ports](guide.md#ports--what-you-implement)) |
133
+
134
+ ## Exports
135
+
136
+ <details>
137
+ <summary><code>mcp-trellis</code></summary>
138
+
139
+ - `createMcpApp`, `createMcpHandler`, `createToolRegistry`
140
+ - `consoleAudit` — convenience `audit` sink that logs to the console (pass any function for metrics / DB / APM)
141
+ - `defineTool`, `apiTool` — typed, validated tool authoring on top of `ToolDef`
142
+ - `CLIENT_PROFILES`, `DEFAULT_CLIENTS`, `authMethodsFor`, `redirectUrisFor`, `preRegisteredClients`, `hasDynamicClient`
143
+ - `parseBearer`, `timingSafeEqual`, `matchesAny`, `wwwAuthenticateHeader`, `rejectQueryToken`
144
+ - `validateAgainstSchema`, `JSON_SCHEMA_TYPES`, `SUPPORTED_SCHEMA_KEYWORDS`, `IGNORED_SCHEMA_KEYWORDS`, `unsupportedKeywords`, `missingObjectType`
145
+ - `rpcResult`, `rpcError`, JSON-RPC error constants
146
+ - `pickProtocolVersion`, `PROTOCOL_VERSIONS`, `DEFAULT_PROTOCOL_VERSION`, `ASSUMED_HEADER_PROTOCOL_VERSION`
147
+ - `jsonResponse`, `emptyResponse`, `optionsResponse`, `corsHeaders`, `methodNotAllowed`
148
+ - Types: `McpApp`, `McpAppOptions`, `McpAppAuth`, `VerifiedToken`, `ClientName`, `ClientProfile`, `McpHandler`, `McpHandlerOptions`, `McpPorts`, `Principal`, `AuditEntry`, `ServerInfo`, `ToolDef`, `ToolHandler`, `ToolResult`, `ToolRegistry`, `JsonSchema`, `StandardSchemaV1`, `DefineToolOptions`, `ApiToolOptions`, `ApiRequest`, `ClientStore`, `RegisteredClient`, `CodeStore`, `MintAccessTokenInput`, `RefreshAccessTokenInput`, `RevokeTokenInput`, `MintedToken`, `OAuthUser`, …
149
+
150
+ </details>
151
+
152
+ <details>
153
+ <summary><code>mcp-trellis/oauth</code></summary>
154
+
155
+ - `createOAuthRouter`
156
+ - `authorizationServerMetadata`, `protectedResourceMetadata`, `mcpWwwAuthenticate`
157
+ - `canonicalResource`, `resourcesEqual`, `firstResourceError`, `resourceErrorInfo`, `DEFAULT_RESOURCE_PATH`
158
+ - `isAllowedRedirectUri`, `CLAUDE_CALLBACK`
159
+ - `issueAuthCode`, `consumeAuthCode`, `newClientId`
160
+ - `verifyPkceS256`, `sha256Base64Url`, `randomBase64Url`
161
+ - `readClientAuth`, `firstClientAuthError`, `unregisteredClientsAllowed`
162
+ - `parseScope`, `formatScope`, `requestedScopes`, `firstScopeError`, `scopeErrorInfo`
163
+ - `defaultScopes`, `registeredClientsRequired` — resolve the effective option value, same pattern as `unregisteredClientsAllowed`
164
+ - `hashClientSecret`, `verifyClientSecret` — for `ClientStore.secretHash` (hash once at registration time; the library verifies)
165
+ - `buildErrorRedirectUrl` — the RFC 6749 §4.1.2.1 helper `/authorize` uses internally, exported for hosts composing their own authorize flow via [compose the primitives](guide.md#advanced-compose-the-primitives)
166
+ - `normalizeConfiguredPath` — strips a trailing slash from a configured path (`resourcePath`, `oauthPath`); `createOAuthRouter` applies this to `resourcePath` internally, exported for hosts composing their own routing
167
+ - `GRANT_TYPES`, `OAUTH_ERRORS`, `DEFAULT_SCOPE`, `TOKEN_ENDPOINT_AUTH_METHODS`
168
+ - Types: `OAuthUser`, `MintedToken`, `OAuthPorts`, `OAuthAuditEntry`, `OAuthRouterOptions`, `OAuthErrorInfo`, `AuthCodeRecord`, `CodeStore`, `ClientStore`, `RegisteredClient`, `ClientAssertion`, `ClientAuth`, `TokenEndpointAuthMethod`, `MintAccessTokenInput`, `RefreshAccessTokenInput`, `RevokeTokenInput`, `ConsentOptions`, `ConsentRequest`
169
+
170
+ </details>
171
+
172
+ <details>
173
+ <summary><code>mcp-trellis/node</code></summary>
174
+
175
+ - `asNodeHandler`, `resolveOrigin`, `isAllowedOrigin`, `toWebRequest`, `sendWebResponse`, `readNodeBody`
176
+ - Types: `NodeRequestLike`, `NodeResponseLike`, `ToWebRequestOptions`, `ResolveOriginOptions`, `AsNodeHandlerOptions`, `OriginAllowlistOptions`
177
+
178
+ </details>