mcp-trellis 0.2.1 → 2.1.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 (293) hide show
  1. package/README.md +70 -620
  2. package/dist/adapters/node-request.d.ts +53 -0
  3. package/dist/adapters/node-request.d.ts.map +1 -0
  4. package/dist/adapters/node-request.js +165 -0
  5. package/dist/adapters/node-request.js.map +1 -0
  6. package/dist/adapters/node.d.ts +16 -24
  7. package/dist/adapters/node.d.ts.map +1 -1
  8. package/dist/adapters/node.js +69 -88
  9. package/dist/adapters/node.js.map +1 -1
  10. package/dist/advanced.d.ts +18 -0
  11. package/dist/advanced.d.ts.map +1 -0
  12. package/dist/advanced.js +18 -0
  13. package/dist/advanced.js.map +1 -0
  14. package/dist/app-options.d.ts +132 -0
  15. package/dist/app-options.d.ts.map +1 -0
  16. package/dist/app-options.js +2 -0
  17. package/dist/app-options.js.map +1 -0
  18. package/dist/app.d.ts +2 -66
  19. package/dist/app.d.ts.map +1 -1
  20. package/dist/app.js +150 -57
  21. package/dist/app.js.map +1 -1
  22. package/dist/audit.d.ts +5 -5
  23. package/dist/audit.d.ts.map +1 -1
  24. package/dist/audit.js +19 -8
  25. package/dist/audit.js.map +1 -1
  26. package/dist/auth/bearer.d.ts +4 -1
  27. package/dist/auth/bearer.d.ts.map +1 -1
  28. package/dist/auth/bearer.js +25 -5
  29. package/dist/auth/bearer.js.map +1 -1
  30. package/dist/clients.d.ts.map +1 -1
  31. package/dist/clients.js +3 -6
  32. package/dist/clients.js.map +1 -1
  33. package/dist/deprecated.d.ts +42 -0
  34. package/dist/deprecated.d.ts.map +1 -0
  35. package/dist/deprecated.js +35 -0
  36. package/dist/deprecated.js.map +1 -0
  37. package/dist/http/body.d.ts +69 -0
  38. package/dist/http/body.d.ts.map +1 -0
  39. package/dist/http/body.js +165 -0
  40. package/dist/http/body.js.map +1 -0
  41. package/dist/http/http.d.ts +42 -0
  42. package/dist/http/http.d.ts.map +1 -0
  43. package/dist/http/http.js +100 -0
  44. package/dist/http/http.js.map +1 -0
  45. package/dist/http/origins.d.ts +37 -0
  46. package/dist/http/origins.d.ts.map +1 -0
  47. package/dist/http/origins.js +87 -0
  48. package/dist/http/origins.js.map +1 -0
  49. package/dist/mcp/dispatch.d.ts +7 -0
  50. package/dist/mcp/dispatch.d.ts.map +1 -0
  51. package/dist/mcp/dispatch.js +99 -0
  52. package/dist/mcp/dispatch.js.map +1 -0
  53. package/dist/{jsonrpc.d.ts → mcp/jsonrpc.d.ts} +10 -0
  54. package/dist/mcp/jsonrpc.d.ts.map +1 -0
  55. package/dist/mcp/jsonrpc.js +88 -0
  56. package/dist/mcp/jsonrpc.js.map +1 -0
  57. package/dist/mcp/methods.d.ts +133 -0
  58. package/dist/mcp/methods.d.ts.map +1 -0
  59. package/dist/mcp/methods.js +84 -0
  60. package/dist/mcp/methods.js.map +1 -0
  61. package/dist/mcp/protocol.d.ts +16 -0
  62. package/dist/mcp/protocol.d.ts.map +1 -0
  63. package/dist/mcp/protocol.js +22 -0
  64. package/dist/mcp/protocol.js.map +1 -0
  65. package/dist/mcp/registry.d.ts +58 -0
  66. package/dist/mcp/registry.d.ts.map +1 -0
  67. package/dist/mcp/registry.js +81 -0
  68. package/dist/mcp/registry.js.map +1 -0
  69. package/dist/mcp/rpc-handlers.d.ts +11 -0
  70. package/dist/mcp/rpc-handlers.d.ts.map +1 -0
  71. package/dist/mcp/rpc-handlers.js +133 -0
  72. package/dist/mcp/rpc-handlers.js.map +1 -0
  73. package/dist/{tools.d.ts → mcp/tools.d.ts} +30 -2
  74. package/dist/mcp/tools.d.ts.map +1 -0
  75. package/dist/mcp/tools.js +166 -0
  76. package/dist/mcp/tools.js.map +1 -0
  77. package/dist/{validate.d.ts → mcp/validate.d.ts} +24 -12
  78. package/dist/mcp/validate.d.ts.map +1 -0
  79. package/dist/mcp/validate.js +186 -0
  80. package/dist/mcp/validate.js.map +1 -0
  81. package/dist/oauth/audit.d.ts +20 -0
  82. package/dist/oauth/audit.d.ts.map +1 -0
  83. package/dist/oauth/audit.js +29 -0
  84. package/dist/oauth/audit.js.map +1 -0
  85. package/dist/oauth/cimd/cimd-parse.d.ts +4 -0
  86. package/dist/oauth/cimd/cimd-parse.d.ts.map +1 -0
  87. package/dist/oauth/cimd/cimd-parse.js +70 -0
  88. package/dist/oauth/cimd/cimd-parse.js.map +1 -0
  89. package/dist/oauth/cimd/cimd-ssrf.d.ts +8 -0
  90. package/dist/oauth/cimd/cimd-ssrf.d.ts.map +1 -0
  91. package/dist/oauth/cimd/cimd-ssrf.js +193 -0
  92. package/dist/oauth/cimd/cimd-ssrf.js.map +1 -0
  93. package/dist/oauth/cimd/cimd.d.ts +36 -0
  94. package/dist/oauth/cimd/cimd.d.ts.map +1 -0
  95. package/dist/oauth/cimd/cimd.js +132 -0
  96. package/dist/oauth/cimd/cimd.js.map +1 -0
  97. package/dist/oauth/cimd/types.d.ts +12 -0
  98. package/dist/oauth/cimd/types.d.ts.map +1 -0
  99. package/dist/oauth/cimd/types.js +3 -0
  100. package/dist/oauth/cimd/types.js.map +1 -0
  101. package/dist/oauth/config.d.ts +51 -0
  102. package/dist/oauth/config.d.ts.map +1 -0
  103. package/dist/oauth/config.js +108 -0
  104. package/dist/oauth/config.js.map +1 -0
  105. package/dist/oauth/constants.d.ts +15 -0
  106. package/dist/oauth/constants.d.ts.map +1 -1
  107. package/dist/oauth/constants.js +15 -0
  108. package/dist/oauth/constants.js.map +1 -1
  109. package/dist/oauth/{base64url.d.ts → crypto/base64url.d.ts} +1 -2
  110. package/dist/oauth/crypto/base64url.d.ts.map +1 -0
  111. package/dist/oauth/{base64url.js → crypto/base64url.js} +1 -5
  112. package/dist/oauth/crypto/base64url.js.map +1 -0
  113. package/dist/oauth/crypto/clientauth.d.ts +13 -0
  114. package/dist/oauth/crypto/clientauth.d.ts.map +1 -0
  115. package/dist/oauth/crypto/clientauth.js +195 -0
  116. package/dist/oauth/crypto/clientauth.js.map +1 -0
  117. package/dist/oauth/crypto/codes.d.ts +65 -0
  118. package/dist/oauth/crypto/codes.d.ts.map +1 -0
  119. package/dist/oauth/crypto/codes.js +121 -0
  120. package/dist/oauth/crypto/codes.js.map +1 -0
  121. package/dist/oauth/crypto/hkdf.d.ts +13 -0
  122. package/dist/oauth/crypto/hkdf.d.ts.map +1 -0
  123. package/dist/oauth/crypto/hkdf.js +30 -0
  124. package/dist/oauth/crypto/hkdf.js.map +1 -0
  125. package/dist/oauth/crypto/keycache.d.ts +19 -0
  126. package/dist/oauth/crypto/keycache.d.ts.map +1 -0
  127. package/dist/oauth/crypto/keycache.js +44 -0
  128. package/dist/oauth/crypto/keycache.js.map +1 -0
  129. package/dist/oauth/crypto/pkce.d.ts.map +1 -0
  130. package/dist/oauth/{pkce.js → crypto/pkce.js} +1 -1
  131. package/dist/oauth/crypto/pkce.js.map +1 -0
  132. package/dist/oauth/crypto/sealed.d.ts +26 -0
  133. package/dist/oauth/crypto/sealed.d.ts.map +1 -0
  134. package/dist/oauth/crypto/sealed.js +65 -0
  135. package/dist/oauth/crypto/sealed.js.map +1 -0
  136. package/dist/oauth/crypto/secrethash.d.ts +58 -0
  137. package/dist/oauth/crypto/secrethash.d.ts.map +1 -0
  138. package/dist/oauth/crypto/secrethash.js +82 -0
  139. package/dist/oauth/crypto/secrethash.js.map +1 -0
  140. package/dist/oauth/deprecated.d.ts +52 -0
  141. package/dist/oauth/deprecated.d.ts.map +1 -0
  142. package/dist/oauth/deprecated.js +49 -0
  143. package/dist/oauth/deprecated.js.map +1 -0
  144. package/dist/oauth/endpoints/authorize.d.ts +3 -0
  145. package/dist/oauth/endpoints/authorize.d.ts.map +1 -0
  146. package/dist/oauth/endpoints/authorize.js +209 -0
  147. package/dist/oauth/endpoints/authorize.js.map +1 -0
  148. package/dist/oauth/endpoints/callback.d.ts +20 -0
  149. package/dist/oauth/endpoints/callback.d.ts.map +1 -0
  150. package/dist/oauth/endpoints/callback.js +31 -0
  151. package/dist/oauth/endpoints/callback.js.map +1 -0
  152. package/dist/oauth/endpoints/consent-page.d.ts +5 -0
  153. package/dist/oauth/endpoints/consent-page.d.ts.map +1 -0
  154. package/dist/oauth/endpoints/consent-page.js +63 -0
  155. package/dist/oauth/endpoints/consent-page.js.map +1 -0
  156. package/dist/oauth/endpoints/consent.d.ts +29 -0
  157. package/dist/oauth/endpoints/consent.d.ts.map +1 -0
  158. package/dist/oauth/endpoints/consent.js +98 -0
  159. package/dist/oauth/endpoints/consent.js.map +1 -0
  160. package/dist/oauth/{metadata.d.ts → endpoints/metadata.d.ts} +14 -8
  161. package/dist/oauth/endpoints/metadata.d.ts.map +1 -0
  162. package/dist/oauth/{metadata.js → endpoints/metadata.js} +10 -4
  163. package/dist/oauth/endpoints/metadata.js.map +1 -0
  164. package/dist/oauth/endpoints/register.d.ts +10 -0
  165. package/dist/oauth/endpoints/register.d.ts.map +1 -0
  166. package/dist/oauth/endpoints/register.js +96 -0
  167. package/dist/oauth/endpoints/register.js.map +1 -0
  168. package/dist/oauth/{revoke.d.ts → endpoints/revoke.d.ts} +1 -1
  169. package/dist/oauth/endpoints/revoke.d.ts.map +1 -0
  170. package/dist/oauth/{revoke.js → endpoints/revoke.js} +11 -9
  171. package/dist/oauth/endpoints/revoke.js.map +1 -0
  172. package/dist/oauth/endpoints/token.d.ts +3 -0
  173. package/dist/oauth/endpoints/token.d.ts.map +1 -0
  174. package/dist/oauth/{token.js → endpoints/token.js} +33 -47
  175. package/dist/oauth/endpoints/token.js.map +1 -0
  176. package/dist/oauth/{wellknown.d.ts → endpoints/wellknown.d.ts} +1 -1
  177. package/dist/oauth/endpoints/wellknown.d.ts.map +1 -0
  178. package/dist/oauth/{wellknown.js → endpoints/wellknown.js} +7 -7
  179. package/dist/oauth/endpoints/wellknown.js.map +1 -0
  180. package/dist/oauth/{redirect.d.ts → policy/redirect.d.ts} +6 -1
  181. package/dist/oauth/policy/redirect.d.ts.map +1 -0
  182. package/dist/oauth/policy/redirect.js +39 -0
  183. package/dist/oauth/policy/redirect.js.map +1 -0
  184. package/dist/oauth/policy/reqbody.d.ts +18 -0
  185. package/dist/oauth/policy/reqbody.d.ts.map +1 -0
  186. package/dist/oauth/policy/reqbody.js +68 -0
  187. package/dist/oauth/policy/reqbody.js.map +1 -0
  188. package/dist/oauth/policy/resource.d.ts +37 -0
  189. package/dist/oauth/policy/resource.d.ts.map +1 -0
  190. package/dist/oauth/policy/resource.js +74 -0
  191. package/dist/oauth/policy/resource.js.map +1 -0
  192. package/dist/oauth/policy/scope.d.ts +20 -0
  193. package/dist/oauth/policy/scope.d.ts.map +1 -0
  194. package/dist/oauth/policy/scope.js +35 -0
  195. package/dist/oauth/policy/scope.js.map +1 -0
  196. package/dist/oauth/router.d.ts +12 -12
  197. package/dist/oauth/router.d.ts.map +1 -1
  198. package/dist/oauth/router.js +105 -87
  199. package/dist/oauth/router.js.map +1 -1
  200. package/dist/oauth/types.d.ts +177 -26
  201. package/dist/oauth/types.d.ts.map +1 -1
  202. package/dist/oauth/types.js +1 -27
  203. package/dist/oauth/types.js.map +1 -1
  204. package/dist/server.d.ts +14 -13
  205. package/dist/server.d.ts.map +1 -1
  206. package/dist/server.js +10 -9
  207. package/dist/server.js.map +1 -1
  208. package/dist/util/defined.d.ts +11 -0
  209. package/dist/util/defined.d.ts.map +1 -0
  210. package/dist/util/defined.js +15 -0
  211. package/dist/util/defined.js.map +1 -0
  212. package/dist/util/json.d.ts +3 -0
  213. package/dist/util/json.d.ts.map +1 -0
  214. package/dist/util/json.js +3 -0
  215. package/dist/util/json.js.map +1 -0
  216. package/docs/ROADMAP.md +283 -0
  217. package/docs/compatibility.md +84 -0
  218. package/docs/diagrams/mcp-trellis-architecture.html +305 -0
  219. package/docs/guide.md +441 -0
  220. package/docs/reference.md +210 -0
  221. package/docs/security.md +112 -0
  222. package/docs/troubleshooting.md +19 -0
  223. package/package.json +24 -8
  224. package/dist/adapters/origins.d.ts +0 -19
  225. package/dist/adapters/origins.d.ts.map +0 -1
  226. package/dist/adapters/origins.js +0 -55
  227. package/dist/adapters/origins.js.map +0 -1
  228. package/dist/dispatch.d.ts +0 -7
  229. package/dist/dispatch.d.ts.map +0 -1
  230. package/dist/dispatch.js +0 -79
  231. package/dist/dispatch.js.map +0 -1
  232. package/dist/http.d.ts +0 -17
  233. package/dist/http.d.ts.map +0 -1
  234. package/dist/http.js +0 -38
  235. package/dist/http.js.map +0 -1
  236. package/dist/jsonrpc.d.ts.map +0 -1
  237. package/dist/jsonrpc.js +0 -17
  238. package/dist/jsonrpc.js.map +0 -1
  239. package/dist/methods.d.ts +0 -61
  240. package/dist/methods.d.ts.map +0 -1
  241. package/dist/methods.js +0 -114
  242. package/dist/methods.js.map +0 -1
  243. package/dist/oauth/authorize.d.ts +0 -3
  244. package/dist/oauth/authorize.d.ts.map +0 -1
  245. package/dist/oauth/authorize.js +0 -109
  246. package/dist/oauth/authorize.js.map +0 -1
  247. package/dist/oauth/base64url.d.ts.map +0 -1
  248. package/dist/oauth/base64url.js.map +0 -1
  249. package/dist/oauth/clientauth.d.ts +0 -19
  250. package/dist/oauth/clientauth.d.ts.map +0 -1
  251. package/dist/oauth/clientauth.js +0 -76
  252. package/dist/oauth/clientauth.js.map +0 -1
  253. package/dist/oauth/codes.d.ts +0 -30
  254. package/dist/oauth/codes.d.ts.map +0 -1
  255. package/dist/oauth/codes.js +0 -93
  256. package/dist/oauth/codes.js.map +0 -1
  257. package/dist/oauth/metadata.d.ts.map +0 -1
  258. package/dist/oauth/metadata.js.map +0 -1
  259. package/dist/oauth/pkce.d.ts.map +0 -1
  260. package/dist/oauth/pkce.js.map +0 -1
  261. package/dist/oauth/redirect.d.ts.map +0 -1
  262. package/dist/oauth/redirect.js +0 -26
  263. package/dist/oauth/redirect.js.map +0 -1
  264. package/dist/oauth/resource.d.ts +0 -7
  265. package/dist/oauth/resource.d.ts.map +0 -1
  266. package/dist/oauth/resource.js +0 -44
  267. package/dist/oauth/resource.js.map +0 -1
  268. package/dist/oauth/revoke.d.ts.map +0 -1
  269. package/dist/oauth/revoke.js.map +0 -1
  270. package/dist/oauth/scope.d.ts +0 -11
  271. package/dist/oauth/scope.d.ts.map +0 -1
  272. package/dist/oauth/scope.js +0 -24
  273. package/dist/oauth/scope.js.map +0 -1
  274. package/dist/oauth/token.d.ts +0 -3
  275. package/dist/oauth/token.d.ts.map +0 -1
  276. package/dist/oauth/token.js.map +0 -1
  277. package/dist/oauth/wellknown.d.ts.map +0 -1
  278. package/dist/oauth/wellknown.js.map +0 -1
  279. package/dist/protocol.d.ts +0 -4
  280. package/dist/protocol.d.ts.map +0 -1
  281. package/dist/protocol.js +0 -14
  282. package/dist/protocol.js.map +0 -1
  283. package/dist/registry.d.ts +0 -37
  284. package/dist/registry.d.ts.map +0 -1
  285. package/dist/registry.js +0 -69
  286. package/dist/registry.js.map +0 -1
  287. package/dist/tools.d.ts.map +0 -1
  288. package/dist/tools.js +0 -97
  289. package/dist/tools.js.map +0 -1
  290. package/dist/validate.d.ts.map +0 -1
  291. package/dist/validate.js +0 -130
  292. package/dist/validate.js.map +0 -1
  293. /package/dist/oauth/{pkce.d.ts → crypto/pkce.d.ts} +0 -0
package/docs/guide.md ADDED
@@ -0,0 +1,441 @@
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
+ (set `OAUTH_CODE_SECRET` and `ACCESS_TOKEN_SECRET`, e.g. from `openssl rand -base64 32`,
185
+ then `npx tsx examples/http-server.ts`).
186
+
187
+ ## Ports — what you implement
188
+
189
+ The library stays protocol-shaped. Your app plugs in the seams:
190
+
191
+ ![Ports: library calls into your authenticate, context, resolveUser, mintAccessToken](diagrams/ports.svg)
192
+
193
+ ### App (`createMcpApp`)
194
+
195
+ | Port | Role |
196
+ |------|------|
197
+ | `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` |
198
+ | `resolveUser`, `loginUrl`, `mintAccessToken`, `codeSecret` | Same as the OAuth ports below |
199
+ | `clientStore?` | Pre-registered clients; required for confidential clients like Gemini |
200
+ | `refreshAccessToken?` | If set, metadata advertises `refresh_token`. Receives `resource` and optional reduced `scope` — **MUST reject tokens not originally issued for that resource, and any `scope` outside the originally granted set** (see [security.md](security.md)) |
201
+ | `revokeToken?` | RFC 7009; presence mounts `/revoke` and advertises `revocation_endpoint` |
202
+
203
+ `consent?` (approval-page policy — see [Consent](#consent), below) and
204
+ `defaultScopes?` sit as top-level `McpAppOptions`, not under `auth` — they
205
+ are policy, not credentials, the same reasoning as `allowLoopback`.
206
+ | `context?`, `audit?` | `context` defaults to `{}`. Top-level `audit` is a **unified** sink for MCP and OAuth (`entry.source` is `"mcp"` or `"oauth"`). `auth.audit` overrides OAuth delivery when both are set |
207
+
208
+ ### MCP (`createMcpHandler`)
209
+
210
+ The lower-level handler. Here the audience check is **yours** — prefer
211
+ `createMcpApp` unless you need the control. See [security.md](security.md).
212
+
213
+ | Port | Role |
214
+ |------|------|
215
+ | `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** |
216
+ | `context(req, principal)` | Build per-request ctx for tools (DB, env, …). Use `principal?.claims` for tenant / plan / role without re-decoding the bearer |
217
+ | `audit?(entry)` | Opt-in **metrics hook**: pass any function to receive tool results **and** auth denials (bad token, missing scope, query-string token, rejected browser Origin), plus the 500 path when a host port throws. `entry.method` is `""` for transport-level denials made before parsing (`query_string_token`, `unauthorized`, `origin_not_allowed`). 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 |
218
+
219
+ `auditTimeoutMs` (default 1000ms) races `audit` against a timeout, so a
220
+ slow sink can't stall a response past that bound either.
221
+
222
+ Pass any function to get per-request metrics — do what you want with them
223
+ (DB, APM, admin UI). The library never writes to stdout or a store on its
224
+ own. `consoleAudit` is only a convenience for local / small deploys;
225
+ [`examples/audit-store.ts`](../examples/audit-store.ts) shows a ring buffer and
226
+ a DB-shaped sketch:
227
+
228
+ ```ts
229
+ import { createMcpApp, consoleAudit } from "mcp-trellis";
230
+
231
+ createMcpApp({ /* … */, audit: consoleAudit });
232
+
233
+ // Or your own sink:
234
+ createMcpApp({
235
+ /* … */,
236
+ audit: (entry) => {
237
+ // entry.source === "mcp" | "oauth"
238
+ void metrics.record(entry);
239
+ },
240
+ });
241
+ ```
242
+
243
+ Failures (MCP denials and tool exceptions) go to `console.error` when using
244
+ `consoleAudit`; MCP successes and OAuth events go to `console.log`. Entries never carry raw
245
+ exceptions or stack traces, so console output is safe. For anything beyond
246
+ stdout, write your own `audit` function — that is the intended production path.
247
+
248
+ `wwwAuthenticate` can be a static object or `(req) => …` when the PRM URL depends on origin.
249
+
250
+ By default `initialize`, `ping`, and notifications are public. Override `publicMethods` if every method must require Bearer.
251
+
252
+ **Browser `Origin`.** MCP Streamable HTTP expects servers to validate the
253
+ request `Origin` header. Pass `allowedRequestOrigins` on `createMcpHandler` /
254
+ `createMcpApp`. Native connectors omit the header and are admitted. An omitted
255
+ allowlist **rejects** any present `Origin` (use `["*"]` only if you mean it).
256
+ This is separate from Node Host `allowedOrigins` on `asNodeHandler`.
257
+
258
+ ### OAuth (`createOAuthRouter`)
259
+
260
+ | Port | Role |
261
+ |------|------|
262
+ | `codeSecret` | Secret(s) sealing auth codes, consent tickets, and DCR client assertions, and keying `secretHash` (string, `string[]`, or `(req) => string \| string[]`). A single string works as before; pass an array to rotate keys — see [security.md](security.md#rotating-codesecret) |
263
+ | `resolveUser` | Consenting user, or `null` → redirect to `loginUrl` |
264
+ | `loginUrl` | Where unauthenticated authorize requests go |
265
+ | `mintAccessToken` | Issue a **user-bound**, **audience-bound** access token (`resource` is the RFC 8707 URI) — never a shared god token |
266
+ | `refreshAccessToken?` | If set, metadata advertises `refresh_token`; receives `resource` and optional client `scope` (advertised-scope check is library-side). **MUST reject refresh tokens not originally issued for that `resource`, and any `scope` outside the originally granted set** — opaque RT, same pattern as audience |
267
+ | `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 |
268
+ | `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 |
269
+ | `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 |
270
+ | `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 |
271
+
272
+ ```ts
273
+ import { hashClientSecret } from "mcp-trellis/oauth";
274
+
275
+ // Once, when you provision a confidential client (e.g. Gemini Enterprise):
276
+ const stored = await hashClientSecret(theSecretYouGenerated, codeSecretValue);
277
+ // Save `stored` — never the plaintext secret — and return it from
278
+ // clientStore.secretHash(clientId) at verify time.
279
+ ```
280
+
281
+ 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`.
282
+
283
+ **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.
284
+
285
+ **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.
286
+
287
+ ### Consent
288
+
289
+ `/authorize` never issues a code directly, even for a resolved session — it
290
+ renders an approval interstitial and only issues the code once the user
291
+ POSTs an explicit approval to `/consent`. This closes a cross-site flow
292
+ where an attacker's page could otherwise walk a logged-in visitor through
293
+ `/authorize` with an attacker-chosen `client_id` and a loopback
294
+ `redirect_uri`, then catch the code on a local listener; PKCE alone does
295
+ not stop this, because it proves possession of the verifier, not the
296
+ identity of a legitimate client.
297
+
298
+ | Option (`consent`) | Default | Role |
299
+ |---|---|---|
300
+ | `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` |
301
+ | `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 |
302
+
303
+ The built-in interstitial ships `Content-Security-Policy`,
304
+ `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, and HTML-escapes
305
+ every reflected value (`client_id` and `redirect_uri` are attacker-
306
+ controlled input up to this point). If you supply `render`, you own that
307
+ hardening for your own markup.
308
+
309
+ Approval tickets are sealed (AES-GCM, single-use via the same store as
310
+ auth-code replay protection, 5-minute TTL) and re-checked against the
311
+ resolved user at redemption — approving as one user and redeeming as
312
+ another is rejected, and a ticket can never be presented at `/token` as an
313
+ authorization code, or vice versa.
314
+
315
+ ## Tool registry
316
+
317
+ Schema and handler live in one place — no parallel "defs" and "handlers" lists to keep in sync:
318
+
319
+ ```ts
320
+ createToolRegistry(tools, { validateArgs: false }) // default: off, easy adoption
321
+ // onToolError?: (exc) => string — default redacts to "Tool execution failed"
322
+ ```
323
+
324
+ - Handler may return a `string` or `{ content, isError? }`
325
+ - A thrown handler exception becomes `isError: true` (not a transport error)
326
+ - `registry.call(name, ...)` itself returns `isError: true` for an unknown
327
+ `name` too — but the JSON-RPC `tools/call` dispatch (`createMcpHandler` /
328
+ `createMcpApp`) checks the name against the registry *before* calling it,
329
+ and answers an unrecognized one with a protocol-level `-32602` error
330
+ instead: MCP distinguishes "this request doesn't refer to a real tool"
331
+ from a tool's own execution outcome. The registry's `isError: true`
332
+ fallback only surfaces to a caller that invokes `registry.call` directly,
333
+ outside that dispatch.
334
+ - 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
335
+ - Duplicate tool names throw at registry construction
336
+ - Turn `validateArgs: true` once clients send schema-valid args. The evaluated
337
+ subset is `type`, `enum`, `required`, `properties`, `items`, `minimum`,
338
+ `maximum`, and boolean `additionalProperties`. Pure metadata (`description`, `title`, `$schema`, `$id`,
339
+ `$comment`, `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`,
340
+ `format`) is allowed and never evaluated. Any other keyword is not
341
+ evaluated — and with `validateArgs: true` `createToolRegistry` throws at
342
+ construction so a schema like `pattern` or a schema-valued
343
+ `additionalProperties` cannot silently look enforced
344
+
345
+ ### Wrapping an API as a tool
346
+
347
+ `ToolDef` stays the primitive. `defineTool` and `apiTool` are optional sugar for
348
+ the common case — turning "I have an API" into "I have an MCP tool" without
349
+ hand-rolling arg validation or fetch/error plumbing each time:
350
+
351
+ ```ts
352
+ import { apiTool } from "mcp-trellis";
353
+
354
+ const getWeather = apiTool({
355
+ name: "get_weather",
356
+ description: "Current weather for a city",
357
+ inputSchema: {
358
+ type: "object",
359
+ properties: { city: { type: "string" } },
360
+ required: ["city"],
361
+ },
362
+ // any Standard Schema v1 validator (https://standardschema.dev)
363
+ input: citySchema,
364
+ request: (_ctx, { city }) =>
365
+ `https://api.example.com/weather?city=${encodeURIComponent(city)}`,
366
+ respond: async (res) => {
367
+ const data = await res.json();
368
+ return `${data.tempC}°C, ${data.condition}`;
369
+ },
370
+ });
371
+ ```
372
+
373
+ - **`inputSchema` stays required** — it's what `tools/list` actually advertises to
374
+ clients, and there's no library-agnostic way to derive JSON Schema from an
375
+ arbitrary Standard Schema validator. Keep the two in sync.
376
+ - **`input`** (optional) parses and types `args` before your handler runs — any
377
+ [Standard Schema](https://standardschema.dev)-compliant validator works,
378
+ nothing library-specific. A validation failure returns `isError: true` with
379
+ the issues and never calls your handler.
380
+ - **`apiTool`** builds on `defineTool`: give it `request` (build the outgoing
381
+ call from typed args) and optionally `respond` (shape a 2xx response;
382
+ default is the body as text). Non-2xx becomes `isError: true` automatically
383
+ — **status only by default, the upstream body is never forwarded** (it
384
+ could contain internal detail, tokens, or SQL an upstream error page
385
+ echoes back). Pass `onError(res)` to shape it yourself, e.g. include a
386
+ redacted version of the body. `fetch` is overridable — testing, request
387
+ signing, a custom agent.
388
+ - **`timeoutMs`** (default `30000`) aborts the upstream call past that many
389
+ ms — an upstream that never responds otherwise hangs the tool call (and
390
+ the MCP request behind it) indefinitely. Pass `false` to disable; a
391
+ timeout firing surfaces as `isError: true` with a clear message, not a
392
+ thrown exception `onToolError` would redact.
393
+ - **`maxResponseBytes`** (default 1 MiB) caps the upstream response body
394
+ before `respond`/`onError` ever see it — the same memory-exhaustion shape
395
+ an unbounded *incoming* request body is. Exceeding it also surfaces as
396
+ `isError: true`, not a thrown exception.
397
+ - `defineTool` alone (without `request`/`respond`) is just the typed-args
398
+ layer over a regular `ToolDef` — use it for anything, not only REST calls.
399
+
400
+ ## Multi-tenant (SaaS connectors)
401
+
402
+ Resource-per-tenant already works with **zero library change**.
403
+ `canonicalResource(origin, resourcePath)` is derived per request, and the
404
+ audience check in `createMcpApp` rejects a token minted for another origin
405
+ before any tool runs. Deploy `acme.mcp.example.com` and
406
+ `globex.mcp.example.com` as separate Hosts and cross-tenant token reuse is a
407
+ protocol failure, not an application bug.
408
+
409
+ For the in-tool half, derive the tenant from the request Host (or from the
410
+ verified audience after the library check) in your `context` port — that is
411
+ the isolation key. Optional `claims` from `verifyToken` (plan, role, …) are
412
+ handy metadata on `principal`; they are **not** Host→tenant authorization.
413
+ A forged `claims.tenant_id` with a matching audience must not change which
414
+ tenant's data a tool can see.
415
+
416
+ **Host caveat (API requirement).** On Node, multi-tenant needs a Host-derived
417
+ origin (`trustProxy: true` without a fixed `origin`). That path **requires**
418
+ a non-empty `allowedOrigins` at `asNodeHandler` construction — omit it and
419
+ construction throws. Use exact origins and/or `*.example.com` wildcards for
420
+ **https** subdomains; the **resolved** origin must match or the request
421
+ returns **400** before any OAuth or MCP handling. Loud opt-out:
422
+ `allowedOrigins: ["*"]` (admits any Host — only behind a proxy you trust to
423
+ strip client `X-Forwarded-*`). A fixed `origin` absent from a non-empty list
424
+ fails at construction; with a fixed `origin` the allowlist is optional
425
+ defense-in-depth. Absolute-form request targets are rebased onto that
426
+ validated origin, so a spoofed URL line cannot override it. A fixed `origin`
427
+ is otherwise safe but collapses every tenant onto one resource, so it cannot
428
+ do subdomain-per-tenant.
429
+
430
+ On Cloudflare Workers (or any wildcard route), apply the equivalent
431
+ allowlist check on `new URL(req.url).origin` before calling `app.fetch`
432
+ (`isAllowedOrigin` from `mcp-trellis/node`, or the same module the Worker
433
+ example imports).
434
+
435
+ Typechecked sketch: [`examples/multi-tenant.ts`](../examples/multi-tenant.ts)
436
+ (needs a Host matching `*.mcp.example.com` — loopback alone is rejected).
437
+ Shared `codeStore` / revocation shapes: [`examples/stores.ts`](../examples/stores.ts)
438
+ (atomic `setIfAbsent` for auth codes; Workers KV alone is not enough).
439
+ `audit` metrics sink (ring + DB sketch): [`examples/audit-store.ts`](../examples/audit-store.ts).
440
+ Worker mount: [`examples/cloudflare-worker.ts`](../examples/cloudflare-worker.ts)
441
+ (typecheck-only in CI — no wrangler).
@@ -0,0 +1,210 @@
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, JSON-RPC helpers |
11
+ | `mcp-trellis/oauth` | OAuth 2.1 AS router, discovery metadata, resource and redirect policy, client-secret hashing |
12
+ | `mcp-trellis/node` | `asNodeHandler` + `resolveOrigin` for Node `(req, res)` |
13
+ | `mcp-trellis/advanced` | Low-level primitives: PKCE, auth codes, client auth, scope/resource parsing, schema validation, HTTP utils. Less stable — may change in a minor release |
14
+
15
+ `createMcpApp` is the batteries-included layer. `createMcpHandler` and
16
+ `createOAuthRouter` remain exported and unchanged — reach for them when you want
17
+ to own the routing or mount the two halves separately. See
18
+ [compose the primitives](guide.md#advanced-compose-the-primitives).
19
+
20
+ ## Default OAuth routes
21
+
22
+ With defaults `resourcePath: "/mcp"` and `oauthPath: "/mcp/oauth"`:
23
+
24
+ | Path | Purpose |
25
+ |------|---------|
26
+ | `/.well-known/oauth-protected-resource` (+ `/mcp`) | Protected resource metadata |
27
+ | `/.well-known/oauth-authorization-server` (+ `/mcp`) | Authorization server metadata |
28
+ | `/mcp/oauth/register` | Dynamic client registration (unmounted when DCR is off). `redirect_uris`, if supplied, must be an array of at most 10 entries — a malformed body, a non-object body, a non-array `redirect_uris`, or too many entries all return **400** `invalid_client_metadata` (RFC 7591 §3.2.2) rather than silently falling back to the default callback. `redirect_uris` omitted entirely still falls back as documented below |
29
+ | `/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 |
30
+ | `/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` |
31
+ | `/mcp/oauth/token` | Token endpoint. JSON or form body |
32
+ | `/mcp/oauth/revoke` | Token revocation (RFC 7009; mounted only when `revokeToken` is set). JSON or form body |
33
+
34
+ ## Supported MCP methods
35
+
36
+ | Method | Auth | Notes |
37
+ |--------|------|-------|
38
+ | `initialize` | public | Negotiates protocol version; returns `capabilities.tools` |
39
+ | `ping` | public | Empty result |
40
+ | `tools/list` | Bearer | Lists registry entries |
41
+ | `tools/call` | Bearer + scope | Runs the tool; no Bearer at all → 401, authenticated but missing scope → 403, `params.name` naming no registered tool → `-32602` (protocol error, not a tool result — see `docs/guide.md`) |
42
+ | `notifications/*` | public | HTTP 202 empty body |
43
+
44
+ Anything else → JSON-RPC `-32601`. Capabilities advertise **tools only**.
45
+
46
+ ## HTTP and JSON-RPC status codes
47
+
48
+ | HTTP | When |
49
+ |------|------|
50
+ | **202** | Notification (empty body) |
51
+ | **400** | Parse error, batch array, invalid request, `jsonrpc` not `"2.0"`, unsupported `MCP-Protocol-Version` |
52
+ | **401** | Missing/invalid Bearer; includes `WWW-Authenticate`; echoes the real request id, not `null` |
53
+ | **403** | Authenticated, but the token's scopes don't include the tool's `scope`; `WWW-Authenticate` carries `error="insufficient_scope"` and `scope="<required>"` (RFC 6750 §3.1) |
54
+ | **405** | Wrong HTTP verb on MCP |
55
+ | **413** | Request body over the cap — `/mcp` (1 MiB default), `/token`/`/revoke`/`/register` (64 KiB default) |
56
+
57
+ | JSON-RPC code | Constant |
58
+ |---------------|----------|
59
+ | `-32700` | `JSONRPC_PARSE_ERROR` |
60
+ | `-32600` | `JSONRPC_INVALID_REQUEST` (batches) |
61
+ | `-32601` | `JSONRPC_METHOD_NOT_FOUND` |
62
+ | `-32602` | `JSONRPC_INVALID_PARAMS` (unrecognized `tools/call` `params.name`) |
63
+ | `-32603` | `JSONRPC_INTERNAL_ERROR` |
64
+ | `-32001` | `JSONRPC_UNAUTHORIZED` |
65
+
66
+ ## `createMcpHandler` options
67
+
68
+ | Option | Required | Description |
69
+ |--------|----------|-------------|
70
+ | `registry` | yes | From `createToolRegistry` |
71
+ | `ports` | yes | `authenticate`, `context`, optional `audit` — see [Ports](guide.md#ports--what-you-implement) |
72
+ | `serverInfo` | yes | `{ name, version }` |
73
+ | `wwwAuthenticate` | yes | Static or `(req) => …` for RFC 9728 PRM URL |
74
+ | `instructions` | no | Returned on `initialize` |
75
+ | `publicMethods` | no | Default: `initialize`, `ping`, notifications |
76
+ | `auditTimeoutMs` | no | Max time to wait for `ports.audit` before responding anyway. Default 1000ms — see [security.md](security.md) |
77
+ | `allowedRequestOrigins` | no | Browser `Origin` allowlist. Omitted → reject any present `Origin`; no header → allow; `"*"` opts out. Distinct from Node Host `allowedOrigins` |
78
+
79
+ `ports.audit` fires on tool results and on auth denials (bad token, missing
80
+ scope, query-string token, rejected browser Origin as `origin_not_allowed`),
81
+ plus the 500 path when a host port throws.
82
+ Malformed JSON, bad `jsonrpc`, unsupported `MCP-Protocol-Version`, oversized
83
+ bodies, and batch requests return JSON-RPC client errors without an audit
84
+ entry — those are protocol errors, not auth denials.
85
+
86
+ ## `createOAuthRouter` options
87
+
88
+ | Option | Default | Description |
89
+ |--------|---------|-------------|
90
+ | `ports` | — | Required OAuth ports — see [Ports](guide.md#ports--what-you-implement) |
91
+ | `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` |
92
+ | `oauthPath` | `"/mcp/oauth"` | Prefix for authorize / token / register / revoke |
93
+ | `realm` | — | Optional realm string |
94
+ | `scopes` | `["mcp"]` | Advertised in metadata, and the ceiling `authorize` validates against |
95
+ | `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` |
96
+ | `tokenEndpointAuthMethods` | `["none"]` | Advertised client auth methods |
97
+ | `allowUnregisteredClients` | `true` | `false` requires `clientStore`, unmounts DCR, and rejects unknown `client_id`s (`unauthorized_client` / `invalid_client`) |
98
+ | `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. Enforced at `/authorize`, and at `/token`'s `refresh_token` grant and `/revoke` (the `authorization_code` grant's `client_id` is already bound into the sealed code at `/authorize` time, so it's covered indirectly there) |
99
+ | `redirect` | see below | Redirect URI allowlist |
100
+ | `consent` | see below | Consent/approval policy for `/authorize` |
101
+ | `auditTimeoutMs` | `1000` | Max time to wait for `ports.audit` before responding anyway — same as MCP |
102
+
103
+ **`redirect` (`RedirectAllowlistOptions`):**
104
+
105
+ | Field | Default | Description |
106
+ |-------|---------|-------------|
107
+ | `extra` | `[]` | Exact-match redirect URIs |
108
+ | `allowLoopback` | `true` | Allow `http:` loopback — `127.0.0.1`, `[::1]` (any port). `localhost` and `https:` loopback are not accepted — see [security.md](security.md) |
109
+ | `allowClaude` | `true` | Allow the Claude.ai Custom Connector callback |
110
+
111
+ Pre-registered clients, and DCR-issued clients whose id was sealed by this
112
+ server's own `/register`, bypass this allowlist entirely — they are
113
+ validated against their own bound `redirectUris`.
114
+
115
+ **`consent` (`ConsentOptions`):**
116
+
117
+ | Field | Default | Description |
118
+ |-------|---------|-------------|
119
+ | `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 |
120
+ | `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 |
121
+
122
+ ## `createMcpApp` options
123
+
124
+ | Option | Default | Description |
125
+ |--------|---------|-------------|
126
+ | `serverInfo`, `tools`, `auth` | — | Required |
127
+ | `clients` | `["claude"]` | Connector profiles to serve |
128
+ | `resourcePath` | `"/mcp"` | MCP endpoint path |
129
+ | `oauthPath` | `` `${resourcePath}/oauth` `` | OAuth prefix |
130
+ | `scopes` | `["mcp"]` | Scopes this server grants |
131
+ | `defaultScopes` | full `scopes` | Same as `OAuthRouterOptions.defaultScopes` — required once `scopes` has more than one entry |
132
+ | `realm` | `serverInfo.name` | `WWW-Authenticate` realm |
133
+ | `extraRedirectUris` | `[]` | Callbacks beyond the client profiles |
134
+ | `allowLoopback` | `true` | Allow loopback redirects — see the narrowed `http:`-only, no-`localhost` behavior in [security.md](security.md) |
135
+ | `consent` | built-in interstitial | Same as `OAuthRouterOptions.consent` — override the approval page or pre-approve specific client ids |
136
+ | `requireRegisteredClients` | `true` | Same as `OAuthRouterOptions.requireRegisteredClients` — inventing a public `client_id` is rejected unless it comes from `clientStore` or this server's own `/register` |
137
+ | `instructions`, `validateArgs`, `onToolError`, `context`, `audit`, `auditTimeoutMs`, `allowedRequestOrigins` | — | Passed through to the MCP handler. Top-level `audit` is a unified MCP+OAuth sink (`source`); `auth.audit` overrides OAuth only. `auditTimeoutMs` applies to both |
138
+ | `hideToolsOutsideScope` | `false` | Filter `tools/list` to tools the calling principal's scopes actually satisfy (an unscoped tool, or one with `scope: null`, is always listed). `tools/call` already enforces scope on its own (403 `insufficient_scope`) regardless of this setting — turning it on only changes what's discoverable via `tools/list` |
139
+
140
+ **A tool's `scope` (`ToolDef.scope`):** required scope, `null` for "any authenticated
141
+ principal, deliberately" or omitted for the same at runtime. On a server
142
+ advertising more than one `scopes` entry, `createMcpApp` **refuses to
143
+ construct** if any tool omits `scope` — an omission there means "any
144
+ authenticated principal, including one holding none of the advertised
145
+ scopes," which is rarely the intent once there's more than one scope to
146
+ have gotten wrong. Pass `scope: null` to state the omission on purpose.
147
+ A single-scope server has no such ambiguity and is unaffected.
148
+
149
+ ## Exports
150
+
151
+ <details>
152
+ <summary><code>mcp-trellis</code></summary>
153
+
154
+ - `createMcpApp`, `createMcpHandler`, `createToolRegistry`
155
+ - `consoleAudit` — convenience unified `audit` sink that logs to the console (pass any function for metrics / DB / APM)
156
+ - `UnifiedAuditEntry` — `{ source: "mcp" } & AuditEntry` \| `{ source: "oauth" } & OAuthAuditEntry`
157
+ - `defineTool`, `apiTool` — typed, validated tool authoring on top of `ToolDef`. `apiTool`'s `timeoutMs` (default 30000, or `false` to disable) and `maxResponseBytes` (default 1 MiB) bound the upstream call; either surfaces as `isError: true`, not a thrown exception
158
+ - `CLIENT_PROFILES`, `DEFAULT_CLIENTS`, `authMethodsFor`, `redirectUrisFor`, `preRegisteredClients`, `hasDynamicClient`
159
+ - `parseBearer`, `wwwAuthenticateHeader`, `rejectQueryToken`
160
+ - `rpcResult`, `rpcError`, JSON-RPC error constants
161
+ - `PROTOCOL_VERSIONS`, `DEFAULT_PROTOCOL_VERSION`
162
+ - `jsonResponse({ data, status?, headers?, cors? })` — type `JsonResponseInput` exported
163
+ - 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`, …
164
+
165
+ </details>
166
+
167
+ <details>
168
+ <summary><code>mcp-trellis/oauth</code></summary>
169
+
170
+ - `createOAuthRouter`
171
+ - `authorizationServerMetadata`, `protectedResourceMetadata`, `mcpWwwAuthenticate`
172
+ - `canonicalResource`, `resourcesEqual`, `DEFAULT_RESOURCE_PATH`
173
+ - `isAllowedRedirectUri`, `CLAUDE_CALLBACK`
174
+ - `isCimdClientId`, `resolveCimdClient`
175
+ - `hashClientSecret`, `verifyClientSecret` — for `ClientStore.secretHash` (hash once at registration time; the library verifies)
176
+ - `GRANT_TYPES`, `OAUTH_ERRORS`, `DEFAULT_SCOPE`, `TOKEN_ENDPOINT_AUTH_METHODS`
177
+ - Types: `OAuthUser`, `MintedToken`, `OAuthPorts`, `OAuthAuditEntry`, `OAuthRouterOptions`, `OAuthErrorInfo`, `AuthCodeRecord`, `CodeStore`, `ClientStore`, `RegisteredClient`, `ClientAssertion`, `ClientAuth`, `TokenEndpointAuthMethod`, `MintAccessTokenInput`, `RefreshAccessTokenInput`, `RevokeTokenInput`, `ConsentOptions`, `ConsentRequest`
178
+
179
+ </details>
180
+
181
+ <details>
182
+ <summary><code>mcp-trellis/advanced</code></summary>
183
+
184
+ Building blocks for hosts assembling their own transport or OAuth flow. Less stable than the entries above.
185
+
186
+ - `timingSafeEqual`, `matchesAny`
187
+ - `validateAgainstSchema`, `JSON_SCHEMA_TYPES`, `SUPPORTED_SCHEMA_KEYWORDS`, `IGNORED_SCHEMA_KEYWORDS`, `unsupportedKeywords`, `missingObjectType`
188
+ - `pickProtocolVersion`, `ASSUMED_HEADER_PROTOCOL_VERSION`
189
+ - `emptyResponse`, `optionsResponse`, `corsHeaders`, `methodNotAllowed`
190
+ - `issueAuthCode`, `consumeAuthCode` (pass `codeStore`; omitting it is deprecated), `newClientId`
191
+ - `verifyPkceS256`, `sha256Base64Url`, `randomBase64Url`
192
+ - `readClientAuth`, `firstClientAuthError`
193
+ - `firstResourceError`, `resourceErrorInfo`, `normalizeConfiguredPath`
194
+ - `parseScope`, `formatScope`, `requestedScopes`, `firstScopeError`, `scopeErrorInfo`
195
+ - `advertisedScopes`, `defaultScopes`, `registeredClientsRequired`, `unregisteredClientsAllowed` — resolve the effective option value
196
+ - `buildErrorRedirectUrl` — the RFC 6749 §4.1.2.1 error-redirect builder `/authorize` uses
197
+ - Types: `AuthCodeRecord`, `CodeStore`, `ClientAuth`
198
+
199
+ **Deprecated in 2.1:** every symbol above is still importable from `mcp-trellis` or `mcp-trellis/oauth`, where it used to live, as a `@deprecated` alias. Those aliases are removed in 3.0.
200
+
201
+ </details>
202
+
203
+ <details>
204
+ <summary><code>mcp-trellis/node</code></summary>
205
+
206
+ - `asNodeHandler`, `resolveOrigin`, `isAllowedOrigin`, `toWebRequest`, `sendWebResponse`, `readNodeBody`
207
+ - Types: `NodeRequestLike`, `NodeResponseLike`, `ToWebRequestOptions`, `ResolveOriginOptions`, `AsNodeHandlerOptions`, `OriginAllowlistOptions`
208
+ - `InvalidOriginError` — thrown by `resolveOrigin` for a Host/`X-Forwarded-*` header that can't safely become an origin (missing, carrying a path/query/fragment/credentials, malformed, or a non-`http(s)` scheme). `asNodeHandler` answers it with **400**, not the generic 500 it gives an actual unexpected failure
209
+
210
+ </details>