mcp-trellis 2.2.0 → 3.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 (173) hide show
  1. package/README.md +61 -52
  2. package/dist/adapters/node-request.d.ts +5 -0
  3. package/dist/adapters/node-request.d.ts.map +1 -1
  4. package/dist/adapters/node-request.js +17 -1
  5. package/dist/adapters/node-request.js.map +1 -1
  6. package/dist/adapters/node.d.ts +15 -0
  7. package/dist/adapters/node.d.ts.map +1 -1
  8. package/dist/adapters/node.js +38 -0
  9. package/dist/adapters/node.js.map +1 -1
  10. package/dist/app.d.ts.map +1 -1
  11. package/dist/app.js +1 -0
  12. package/dist/app.js.map +1 -1
  13. package/dist/audit.d.ts.map +1 -1
  14. package/dist/audit.js +63 -6
  15. package/dist/audit.js.map +1 -1
  16. package/dist/auth/signed/payload.d.ts +31 -0
  17. package/dist/auth/signed/payload.d.ts.map +1 -0
  18. package/dist/auth/signed/payload.js +85 -0
  19. package/dist/auth/signed/payload.js.map +1 -0
  20. package/dist/auth/signed/refresh.d.ts +34 -0
  21. package/dist/auth/signed/refresh.d.ts.map +1 -0
  22. package/dist/auth/signed/refresh.js +95 -0
  23. package/dist/auth/signed/refresh.js.map +1 -0
  24. package/dist/auth/signed/token.d.ts +41 -0
  25. package/dist/auth/signed/token.d.ts.map +1 -0
  26. package/dist/auth/signed/token.js +103 -0
  27. package/dist/auth/signed/token.js.map +1 -0
  28. package/dist/clients.js +2 -2
  29. package/dist/clients.js.map +1 -1
  30. package/dist/deprecated.d.ts +15 -15
  31. package/dist/deprecated.d.ts.map +1 -1
  32. package/dist/deprecated.js +15 -15
  33. package/dist/deprecated.js.map +1 -1
  34. package/dist/http/body.d.ts +4 -0
  35. package/dist/http/body.d.ts.map +1 -1
  36. package/dist/http/body.js +9 -2
  37. package/dist/http/body.js.map +1 -1
  38. package/dist/http/origins.d.ts.map +1 -1
  39. package/dist/http/origins.js +13 -2
  40. package/dist/http/origins.js.map +1 -1
  41. package/dist/mcp/dispatch.d.ts.map +1 -1
  42. package/dist/mcp/dispatch.js +2 -1
  43. package/dist/mcp/dispatch.js.map +1 -1
  44. package/dist/mcp/methods.d.ts +8 -0
  45. package/dist/mcp/methods.d.ts.map +1 -1
  46. package/dist/mcp/methods.js +14 -1
  47. package/dist/mcp/methods.js.map +1 -1
  48. package/dist/mcp/registry.d.ts.map +1 -1
  49. package/dist/mcp/registry.js +15 -3
  50. package/dist/mcp/registry.js.map +1 -1
  51. package/dist/mcp/rpc-handlers.d.ts.map +1 -1
  52. package/dist/mcp/rpc-handlers.js +60 -13
  53. package/dist/mcp/rpc-handlers.js.map +1 -1
  54. package/dist/mcp/tools.d.ts +14 -0
  55. package/dist/mcp/tools.d.ts.map +1 -1
  56. package/dist/mcp/tools.js +118 -23
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/mcp/validate.d.ts +7 -1
  59. package/dist/mcp/validate.d.ts.map +1 -1
  60. package/dist/mcp/validate.js +58 -24
  61. package/dist/mcp/validate.js.map +1 -1
  62. package/dist/oauth/audit.d.ts +10 -0
  63. package/dist/oauth/audit.d.ts.map +1 -1
  64. package/dist/oauth/audit.js +25 -1
  65. package/dist/oauth/audit.js.map +1 -1
  66. package/dist/oauth/cimd/cimd-parse.d.ts.map +1 -1
  67. package/dist/oauth/cimd/cimd-parse.js +27 -14
  68. package/dist/oauth/cimd/cimd-parse.js.map +1 -1
  69. package/dist/oauth/cimd/cimd-ssrf.d.ts +8 -4
  70. package/dist/oauth/cimd/cimd-ssrf.d.ts.map +1 -1
  71. package/dist/oauth/cimd/cimd-ssrf.js +13 -39
  72. package/dist/oauth/cimd/cimd-ssrf.js.map +1 -1
  73. package/dist/oauth/cimd/cimd.d.ts +14 -18
  74. package/dist/oauth/cimd/cimd.d.ts.map +1 -1
  75. package/dist/oauth/cimd/cimd.js +53 -37
  76. package/dist/oauth/cimd/cimd.js.map +1 -1
  77. package/dist/oauth/cimd/dns.d.ts +4 -0
  78. package/dist/oauth/cimd/dns.d.ts.map +1 -0
  79. package/dist/oauth/cimd/dns.js +45 -0
  80. package/dist/oauth/cimd/dns.js.map +1 -0
  81. package/dist/oauth/cimd/failure.d.ts +19 -0
  82. package/dist/oauth/cimd/failure.d.ts.map +1 -0
  83. package/dist/oauth/cimd/failure.js +31 -0
  84. package/dist/oauth/cimd/failure.js.map +1 -0
  85. package/dist/oauth/cimd/types.d.ts +15 -1
  86. package/dist/oauth/cimd/types.d.ts.map +1 -1
  87. package/dist/oauth/cimd/types.js +1 -1
  88. package/dist/oauth/cimd/types.js.map +1 -1
  89. package/dist/oauth/cimd/url-guard.d.ts +5 -0
  90. package/dist/oauth/cimd/url-guard.d.ts.map +1 -0
  91. package/dist/oauth/cimd/url-guard.js +53 -0
  92. package/dist/oauth/cimd/url-guard.js.map +1 -0
  93. package/dist/oauth/config.d.ts +3 -0
  94. package/dist/oauth/config.d.ts.map +1 -1
  95. package/dist/oauth/config.js +5 -1
  96. package/dist/oauth/config.js.map +1 -1
  97. package/dist/oauth/crypto/clientauth.d.ts.map +1 -1
  98. package/dist/oauth/crypto/clientauth.js +7 -6
  99. package/dist/oauth/crypto/clientauth.js.map +1 -1
  100. package/dist/oauth/deprecated.d.ts +22 -22
  101. package/dist/oauth/deprecated.d.ts.map +1 -1
  102. package/dist/oauth/deprecated.js +22 -22
  103. package/dist/oauth/deprecated.js.map +1 -1
  104. package/dist/oauth/endpoints/authorize.d.ts.map +1 -1
  105. package/dist/oauth/endpoints/authorize.js +25 -7
  106. package/dist/oauth/endpoints/authorize.js.map +1 -1
  107. package/dist/oauth/endpoints/consent/error-page.d.ts +6 -0
  108. package/dist/oauth/endpoints/consent/error-page.d.ts.map +1 -0
  109. package/dist/oauth/endpoints/consent/error-page.js +60 -0
  110. package/dist/oauth/endpoints/consent/error-page.js.map +1 -0
  111. package/dist/oauth/endpoints/{consent.d.ts → consent/handler.d.ts} +2 -2
  112. package/dist/oauth/endpoints/consent/handler.d.ts.map +1 -0
  113. package/dist/oauth/endpoints/{consent.js → consent/handler.js} +20 -13
  114. package/dist/oauth/endpoints/consent/handler.js.map +1 -0
  115. package/dist/oauth/endpoints/{consent-page.d.ts → consent/page.d.ts} +2 -2
  116. package/dist/oauth/endpoints/consent/page.d.ts.map +1 -0
  117. package/dist/oauth/endpoints/consent/page.js +106 -0
  118. package/dist/oauth/endpoints/consent/page.js.map +1 -0
  119. package/dist/oauth/endpoints/metadata.d.ts +0 -1
  120. package/dist/oauth/endpoints/metadata.d.ts.map +1 -1
  121. package/dist/oauth/endpoints/metadata.js.map +1 -1
  122. package/dist/oauth/endpoints/register.js +3 -3
  123. package/dist/oauth/endpoints/register.js.map +1 -1
  124. package/dist/oauth/endpoints/token.d.ts.map +1 -1
  125. package/dist/oauth/endpoints/token.js +9 -6
  126. package/dist/oauth/endpoints/token.js.map +1 -1
  127. package/dist/oauth/endpoints/wellknown.d.ts.map +1 -1
  128. package/dist/oauth/endpoints/wellknown.js +2 -9
  129. package/dist/oauth/endpoints/wellknown.js.map +1 -1
  130. package/dist/oauth/policy/redirect.d.ts +4 -0
  131. package/dist/oauth/policy/redirect.d.ts.map +1 -1
  132. package/dist/oauth/policy/redirect.js +15 -1
  133. package/dist/oauth/policy/redirect.js.map +1 -1
  134. package/dist/oauth/policy/reqbody.d.ts +6 -1
  135. package/dist/oauth/policy/reqbody.d.ts.map +1 -1
  136. package/dist/oauth/policy/reqbody.js +8 -3
  137. package/dist/oauth/policy/reqbody.js.map +1 -1
  138. package/dist/oauth/router.js +1 -1
  139. package/dist/oauth/router.js.map +1 -1
  140. package/dist/oauth/types.d.ts +3 -1
  141. package/dist/oauth/types.d.ts.map +1 -1
  142. package/dist/server.d.ts +1 -1
  143. package/dist/server.js +1 -1
  144. package/dist/util/clip.d.ts +7 -0
  145. package/dist/util/clip.d.ts.map +1 -0
  146. package/dist/util/clip.js +13 -0
  147. package/dist/util/clip.js.map +1 -0
  148. package/dist/util/json.d.ts +5 -0
  149. package/dist/util/json.d.ts.map +1 -1
  150. package/dist/util/json.js +21 -0
  151. package/dist/util/json.js.map +1 -1
  152. package/dist/util/own.d.ts +8 -0
  153. package/dist/util/own.d.ts.map +1 -0
  154. package/dist/util/own.js +8 -0
  155. package/dist/util/own.js.map +1 -0
  156. package/docs/ROADMAP.md +17 -229
  157. package/docs/compatibility.md +15 -3
  158. package/docs/guide.md +15 -4
  159. package/docs/reference.md +5 -3
  160. package/docs/security.md +63 -7
  161. package/docs/troubleshooting.md +4 -0
  162. package/package.json +7 -4
  163. package/dist/auth/signed-token.d.ts +0 -26
  164. package/dist/auth/signed-token.d.ts.map +0 -1
  165. package/dist/auth/signed-token.js +0 -96
  166. package/dist/auth/signed-token.js.map +0 -1
  167. package/dist/oauth/endpoints/consent-page.d.ts.map +0 -1
  168. package/dist/oauth/endpoints/consent-page.js +0 -63
  169. package/dist/oauth/endpoints/consent-page.js.map +0 -1
  170. package/dist/oauth/endpoints/consent.d.ts.map +0 -1
  171. package/dist/oauth/endpoints/consent.js.map +0 -1
  172. package/docs/diagrams/demo-tool-result.png +0 -0
  173. package/docs/diagrams/mcp-trellis-architecture.html +0 -305
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `record[key]` for an attacker-supplied `key`: only an own property counts.
3
+ * A plain object literal also answers `"constructor"`, `"toString"` and
4
+ * `"__proto__"` from `Object.prototype`, which would otherwise be mistaken
5
+ * for a registered handler.
6
+ */
7
+ export const ownValue = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
8
+ //# sourceMappingURL=own.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"own.js","sourceRoot":"","sources":["../../src/util/own.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAI,MAAmC,EAAE,GAAW,EAAiB,EAAE,CAC7F,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC"}
package/docs/ROADMAP.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # mcp-trellis — roadmap
2
2
 
3
- Status: 2026-09-14. Written against MCP spec revision `2026-07-28`.
3
+ Status: 2026-11-07. Written against MCP spec revision `2026-07-28`. What shipped,
4
+ version by version, is in [CHANGELOG.md](../CHANGELOG.md).
4
5
 
5
6
  ## What this library is
6
7
 
@@ -9,29 +10,15 @@ Status: 2026-09-14. Written against MCP spec revision `2026-07-28`.
9
10
  > vendor.
10
11
 
11
12
  The differentiating axis is **clients**, not runtimes. Runtime portability is
12
- table stakes; the official SDK has it too. What nobody in TypeScript ships is
13
- both halves — the MCP handler *and* a self-hosted OAuth authorization server —
14
- in one package you can drop into a Next.js route or a Deno server.
13
+ table stakes; the official SDK has it too. mcp-trellis puts both halves — the MCP
14
+ handler *and* a self-hosted OAuth authorization server — in one zero-dependency
15
+ package that reuses the session your app already has, so you can drop it into a
16
+ Next.js route or a Deno server.
15
17
 
16
- Contrast composition stacks that split the MCP runtime from the identity
17
- product (e.g. xmcp + Scalekit): those are 2–3 products to wire; mcp-trellis is
18
- one package with BYO-auth ports.
19
-
20
- | | Portable runtime | Self-hosted AS | One npm install | Zero-dep |
21
- |---|---|---|---|---|
22
- | Official SDK v2 | yes | **no** | yes (handler only) | no |
23
- | `workers-oauth-provider` | Workers only | yes | yes | — |
24
- | Auth0 / Authlete / Clerk | yes | **no** (SaaS) | no (IdP + MCP) | — |
25
- | Better Auth | yes | yes | yes, but full stack + DB | no |
26
- | **xmcp** | yes | **no** (compose) | no — pair with IdP | — |
27
- | **Scalekit** | yes | managed connector IdP | no — pair with MCP runtime | — |
28
- | **WorkOS Standalone Connect** | yes | managed | no — IdP + your MCP | — |
29
- | **Descope** | yes | managed | no — IdP + your MCP | — |
30
- | **mcp-trellis** | yes | yes | **yes** (handler + AS) | yes |
31
-
32
- The official SDK explicitly does not provide an authorization server — it offers
33
- SDK-level opt-ins and expects you to bring the infrastructure. That gap is the
34
- product.
18
+ The official TypeScript SDK v2 ships resource-server helpers only and never
19
+ issues tokens. How mcp-trellis compares with Better Auth, `workers-oauth-provider`,
20
+ Scalekit and WorkOS is in the
21
+ [README](../README.md#compared-with-the-alternatives).
35
22
 
36
23
  ### The rule that decides design questions
37
24
 
@@ -41,202 +28,9 @@ goes in the code, not the README.** Audience validation moved into
41
28
 
42
29
  ### The acceptance test
43
30
 
44
- From `npm install` to a connected client: **under 15 lines and one secret.**
31
+ From `npm install` to a connected client: **at most 15 lines and one secret.**
45
32
  Anything that forces the user to route requests, read an RFC, or install a
46
- second package fails.
47
-
48
- ---
49
-
50
- ## Shipped
51
-
52
- **Phase 0 — trust.** Query-string tokens now cover RFC 6750's `access_token`;
53
- audit fires on every denial including transport-level ones, and a throwing audit
54
- port can no longer fail a request; tool exceptions are redacted by default;
55
- a relative `loginUrl` resolves instead of throwing; explicit `id: null` returns
56
- a proper JSON-RPC error.
57
-
58
- **Phase 1 — the app layer and the Gemini unblock.**
59
-
60
- - `createMcpApp` — one call mounts tools, the AS, both discovery documents, and
61
- the routing between them. `createMcpHandler` / `createOAuthRouter` stay
62
- exported for anyone who wants to compose the pieces.
63
- - **Audience validation is enforced by the library.** `verifyToken` returns the
64
- token's audience; `createMcpApp` compares it against `canonicalResource` and
65
- rejects mismatches before any tool runs.
66
- - **Client profiles** (`claude` / `gemini` / `codex`) compose the redirect
67
- allowlist and the advertised token-endpoint auth methods. Client-specific
68
- knowledge is the asset, so it lives in the library.
69
- - **Confidential clients and pre-registration** via a `clientStore` port —
70
- `client_secret_basic` and `client_secret_post`. This is what Gemini Enterprise
71
- needs, and it was previously impossible: the AS advertised `["none"]` only.
72
- Secret storage and comparison stay in the port; credentials never enter the
73
- library.
74
- - **Naming only confidential clients is enforced, not just advertised.**
75
- The first cut of this let `clients: ["gemini"]` configure the AS's metadata
76
- while leaving DCR and the loopback allowlist wide open — a self-registered
77
- public client could still complete a normal PKCE flow and get a token,
78
- quietly defeating the "confidential clients only" intent. Fixed:
79
- `allowUnregisteredClients: false` (derived automatically by `createMcpApp`
80
- when no configured client registers dynamically) unmounts `/register`, drops
81
- it from AS metadata, and rejects any `client_id` the `clientStore` doesn't
82
- resolve — `unauthorized_client` at `authorize`, `invalid_client` at `token`.
83
- Public type break: `AuthorizationServerMetadata.registration_endpoint` is
84
- now optional (`string | undefined`), omitted when DCR is off.
85
- - **Scope threaded end-to-end.** `authorize` validates the requested scope
86
- against the advertised set, the auth code carries the grant, and
87
- `mintAccessToken` receives it. `ToolDef.scope` finally sits on a chain that
88
- reaches the OAuth layer.
89
- - Node adapter drains the request stream when `req.body` is absent, so raw
90
- `http.createServer` works without a body-parser.
91
- - `[::1]` added to the loopback redirect allowlist.
92
- - **`defineTool` / `apiTool`.** The library was easy to run but not easy to
93
- *author tools for* — every tool hand-wrote its own JSON Schema disconnected
94
- from its handler's types, and every API-backed tool re-invented fetch/error
95
- plumbing. `defineTool` adds optional Standard Schema v1 parsing in front of
96
- parsing in front of a handler — typed args, validation failures short-circuit
97
- before the handler runs. `apiTool` builds on it: give it `request` (+
98
- optional `respond`) and get a working REST-call tool, non-2xx handled as
99
- `isError` automatically. `inputSchema` (JSON Schema, for `tools/list`) stays
100
- required and separate — no library-agnostic way exists to derive it from an
101
- arbitrary Standard Schema validator, so the two must be kept in sync by hand.
102
- - **No host port can crash `fetch`.** Nothing guarded `authenticate`,
103
- `context`, `resolveUser`, `mintAccessToken`, `clientStore`, etc. — any of
104
- them throwing turned `.fetch()` into a rejected promise instead of a
105
- `Response`, with the fallout depending on the runtime (a generic error page
106
- on Workers, a hung socket with no response ever sent on raw
107
- `http.createServer`). `createMcpHandler`, `createOAuthRouter`,
108
- `createMcpApp`, and `asNodeHandler` now each catch at their own boundary and
109
- return a real `500` instead.
110
-
111
- - **RFC 7009 token revocation.** The library never stores access or refresh
112
- tokens, so revocation is a host port — `revokeToken` — advertised and mounted
113
- only when implemented (same honest-advertisement pattern as refresh).
114
- Well-formed authenticated `POST /revoke` returns 200 even if the token is
115
- unknown (`invalid_request` 400 and `invalid_client` 401 still apply).
116
- Stolen-token invalidation lives in the host store that `verifyToken` and
117
- `refreshAccessToken` consult.
118
-
119
- - **Claims on `Principal`.** Optional `claims` from `verifyToken` /
120
- `VerifiedToken` flow into `context` (plan/role metadata — not the
121
- Host→tenant isolation key). Host-origin allowlisting (`allowedOrigins` on
122
- `asNodeHandler`) is **required** when origin is Host-derived. Separately,
123
- browser `Origin` on MCP is gated by `allowedRequestOrigins` (fail-closed
124
- when the header is present; native clients omit it).
125
-
126
- - **`validateArgs` fail-fast.** With `validateArgs: true`, unsupported JSON
127
- Schema keywords throw at `createToolRegistry` construction instead of
128
- silently looking enforced. Default (off) is unchanged.
129
-
130
- **Phase 2 — the authorize endpoint (0.2.2 / 0.3.0).**
131
-
132
- - **Consent step at `/authorize`.** A resolved session no longer issues a
133
- code and redirects on its own; it renders an approval interstitial (built
134
- in, or your own via `consent.render`) and only issues a code after an
135
- explicit `POST ${oauthPath}/consent` approval. Closes a cross-site flow
136
- where an attacker-controlled page could otherwise walk a logged-in user
137
- through `/authorize` with an attacker-chosen `client_id`/`code_challenge`
138
- and a loopback `redirect_uri`, catching the code with a local listener —
139
- PKCE alone does not defend against this, it proves possession of the
140
- verifier, not the identity of a legitimate client. Approval tickets are
141
- AES-GCM sealed (`sealed.ts`), single-use, and re-checked against the
142
- resolved user at redemption.
143
- - **DCR `client_id` is bound to its own `redirect_uris`**, zero storage — the
144
- id issued by `/register` is a sealed, self-verifying assertion of the list
145
- it registered. Stops a substituted `redirect_uri` on a legitimate
146
- connector's id (not impersonation via self-registration — the consent step
147
- above is what stops that). `requireRegisteredClients` (default `true`
148
- since 1.0) is the stricter default, and a direct stepping stone to CIMD below.
149
- - **`codeSecret` is validated** — minimum 32 characters, and the literal
150
- values this package's own docs/examples publish are denylisted by name.
151
- It is the single key that can forge an auth code, consent ticket, or DCR
152
- client assertion for any userId/scope/resource, and had no validation at
153
- all before this.
154
- - **Loopback redirect allowlist narrowed** — `localhost` and `https:`
155
- loopback are no longer accepted (RFC 8252 §8.3); a `redirect_uri` carrying
156
- a fragment or embedded credentials is rejected for every predicate, not
157
- only loopback.
158
- - **`defaultScopes` required for multi-scope servers.** `createOAuthRouter`
159
- now throws at construction when `scopes` has more than one entry and
160
- `defaultScopes` is unset, instead of silently granting the full advertised
161
- set to a client that asked for nothing.
162
- - **`timingSafeEqual("", "")` now returns `false`**, not `true` — a public
163
- export, so a host comparing a presented secret against an unset stored one
164
- no longer authenticates.
165
- - **Request body size limits** on `/mcp`, `/token`, `/revoke`, and
166
- `/register` — previously unbounded on every one of them.
167
-
168
- **Phase 3 — credentials and correctness (0.4.0).**
169
-
170
- - **Auth codes sealed (AES-GCM), not signed-only.** v1 (HMAC) codes were
171
- readable by anyone who saw one; v2 codes are encrypted too. Soft
172
- migration in 0.4.0 / 0.5.0 (read both, write v2); **1.0.0 drops v1 reading**.
173
- - **`ClientStore.secretHash`**, preferred over `verifySecret`: return a
174
- stored hash from the new `hashClientSecret` and the library does the
175
- comparison with the same constant-time primitive it uses everywhere
176
- else. Keyed by `codeSecret` — no second secret to manage.
177
- - **Client-auth enumeration oracle closed.** `/token` and `/revoke`
178
- collapsed five distinguishable error messages into one; a locked-down
179
- server also pays a fixed-cost dummy comparison for an unknown
180
- `client_id`, so response timing can't distinguish "unknown" from
181
- "known, wrong secret" either. Unaffected: public-client traffic, and
182
- `/authorize`'s deliberately-still-descriptive `unauthorized_client`.
183
- - **`/revoke` accepts JSON**, matching `/token` — previously form-only,
184
- so a JSON revoke silently failed while the caller believed it worked.
185
- - **`apiTool` no longer forwards the upstream error body by default** —
186
- status only; opt in via the new `onError`.
187
- - **`missingObjectType`** — a schema using `properties`/`required`
188
- without `type: "object"` now throws at construction under
189
- `validateArgs: true`, and is enforced at runtime by dispatching on the
190
- value's actual shape rather than trusting a schema author remembered to
191
- declare `type`.
192
- - **Union `type` arrays** (`type: ["string", "null"]`) now evaluate
193
- correctly — previously always failed.
194
- - **`/authorize` errors redirect to the client's callback** (RFC 6749
195
- §4.1.2.1) once `redirect_uri` is validated, instead of a bare JSON body
196
- the connector never parses — this was silently connector-breaking.
197
-
198
- **Phase 4 — protocol correctness and observability (0.5.0).**
199
-
200
- - **`jsonrpc` validated on every `/mcp` request** — `-32600` on anything
201
- but exactly `"2.0"`, including a missing field. Previously accepted
202
- silently.
203
- - **`MCP-Protocol-Version` header read and validated**, per the
204
- 2025-06-18 spec — unsupported → 400, missing → assumed `2025-03-26`
205
- (the spec's own compatibility default), not checked on `initialize`
206
- (which is what negotiates the version). Previously only appeared in the
207
- CORS allow-list, never actually read.
208
- - **`initialize` defaults to the newest supported protocol version**
209
- (`2025-06-18`) for a missing/unrecognized request, not the oldest.
210
- - **401 responses echo the real request id** instead of hardcoding `null`.
211
- - **`/mcp/` routes identically to `/mcp`.** `canonicalResource` normalizes
212
- its `resourcePath` argument internally, so route matching, `/authorize`,
213
- and `/token` all agree — previously only some call sites normalized a
214
- trailing slash and others read a raw, unnormalized value.
215
- - **`createOAuthRouter` refuses to construct** if `oauthPath` would equal
216
- `resourcePath` (they'd shadow each other) or if `resourcePath` starts
217
- with `/.well-known` (reserved for discovery documents).
218
- - **`auditTimeoutMs`** (default 1000ms) — a hanging `ports.audit` can no
219
- longer stall a response indefinitely; it already couldn't fail one.
220
- - **`OAuthPorts.audit` / `McpAppAuth.audit`** — the OAuth-side twin of the
221
- MCP audit hook. Sees the real reason behind a collapsed `invalid_client`
222
- or a rejected `codeSecret`, which the caller never does.
223
-
224
- **2.0 — CIMD, unified audit, recipes.**
225
-
226
- - **CIMD (Client ID Metadata Documents)** — opt-in (`cimd: true`). HTTPS URL
227
- `client_id`s are fetched and validated with SSRF hardening (public hosts
228
- only, size / redirect / timeout caps). Advertises
229
- `client_id_metadata_document_supported`. Optional `cimdCache` port.
230
- Off by default until connect-pinned fetch closes the DNS-rebinding ceiling.
231
- - **Unified `audit` sink** on `createMcpApp` — one callback for MCP and
232
- OAuth events (`source: "mcp" | "oauth"`); `auth.audit` remains an
233
- OAuth-only override.
234
- - **Framework recipes** — `examples/nextjs-saas/`, `examples/express.ts`.
235
- - **Reference MCP Registry server** — `examples/project-desk/`
236
- (`io.github.amir1824/project-desk`, remotes only; not the SDK package).
237
- - Fail-closed defaults: `allowInMemoryCodeStore` / `validateArgs`.
238
- - **RFC 9207 `iss`** on authorize / error redirects; AS metadata advertises
239
- `authorization_response_iss_parameter_supported`.
33
+ second package fails. `npm run check:quickstart` enforces the line count.
240
34
 
241
35
  ---
242
36
 
@@ -244,10 +38,11 @@ a proper JSON-RPC error.
244
38
 
245
39
  ### 1. Protocol currency — `2026-07-28`
246
40
 
247
- Deferred deliberately: Claude, Gemini, and Codex all work against current
248
- revisions today, and the official SDK already covers dual-era for anyone who
249
- needs it now. But a batteries-included server that speaks an outdated revision
250
- is worse than useless, so this becomes mandatory once real clients move.
41
+ Deferred deliberately: the revisions implemented today (`2024-11-05`,
42
+ `2025-03-26`, `2025-06-18`) are what clients negotiate with this library, see
43
+ [compatibility.md](compatibility.md). But a batteries-included server that
44
+ speaks an outdated revision is worse than useless, so this becomes mandatory once
45
+ real clients move.
251
46
 
252
47
  Work: remove `initialize` / `ping`, add `server/discover` (MUST), carry protocol
253
48
  version and client capabilities in `_meta` per request, `resultType` on every
@@ -276,10 +71,3 @@ Backed by the spec, not just by our own scoping:
276
71
 
277
72
  Deferred until asked: MRTR (`input_required`), `subscriptions/listen`,
278
73
  `io.modelcontextprotocol/tasks`.
279
-
280
- ## Known gaps
281
-
282
- Tracked, not scheduled: `pruneMemory` scans the whole jti map on every code
283
- consume; `mcpWwwAuthenticate` and `wwwAuthenticateHeader` build the same
284
- header twice. (Unsupported JSON Schema keywords under `validateArgs: true`
285
- now throw at `createToolRegistry` construction — no longer a silent gap.)
@@ -1,6 +1,6 @@
1
1
  # Compatibility evidence
2
2
 
3
- Updated 2026-09-14. Repository implementation and automated coverage are in good
3
+ Updated 2026-11-07. Repository implementation and automated coverage are in good
4
4
  shape after the pre-prod security pass. **Vendor-client UI sessions are still
5
5
  Pending** — do not convert product rows to ✅ until a live Claude/ChatGPT/Codex/Gemini
6
6
  check below is filled in.
@@ -8,7 +8,7 @@ check below is filled in.
8
8
  | Product | Server profile | Repository evidence | Live evidence |
9
9
  |---|---|---|---|
10
10
  | Claude custom connector | `claude`: DCR / CIMD, public client, S256 PKCE; built-in callback | `test/oauth/policy/clients.test.ts`, `test/oauth/endpoints/dcr.test.ts`, `test/oauth/cimd/cimd.test.ts`, `test/oauth/crypto/pkce.test.ts`; saas-demo e2e + `scripts/prep-claude-oauth.mjs` | Pending (UI) |
11
- | ChatGPT | `codex`: public-client profile; exact hosted callback must be configured | Profile tests only; no ChatGPT UI test | Pending |
11
+ | ChatGPT | `codex`: public-client profile; ChatGPT's stable callback and its per-connector `https://chatgpt.com/connector/oauth/{id}` callback are allowed | Profile tests only; no ChatGPT UI test | Pending |
12
12
  | Codex | `codex`: public client, PKCE, loopback callbacks allowed | Profile/redirect tests; no actual Codex login run | Pending |
13
13
  | Gemini Enterprise | `gemini`: `clientStore`, pre-registration, `client_secret_basic` / `client_secret_post` | Client authentication tests; no Enterprise environment | Pending |
14
14
 
@@ -53,7 +53,19 @@ release; it does **not** replace the live client row.
53
53
 
54
54
  | Date | Commit / package | Command | Result |
55
55
  |---|---|---|---|
56
- | 2026-09-12 | unreleased working tree (`1.1.0` pack) | `npm run smoke:pack` | Pass — `createMcpApp` + `asNodeHandler` import from tarball |
56
+ | 2026-11-07 | `3.1.0` release-branch working tree | `npm run smoke:pack` | Pass — `createMcpApp` + `asNodeHandler` import from tarball |
57
+
58
+ ## Production use (maintainer-reported)
59
+
60
+ The maintainer reports that two of their own production MCP servers run on
61
+ mcp-trellis and are connected to Claude. On 2026-11-07 a Claude session called a
62
+ read tool on each of them over its connector and got a normal result (an empty
63
+ list from one, 14 note paths from the other).
64
+
65
+ This is **not** a logged live pass. The Claude build and plan, the mcp-trellis
66
+ version those servers run, and the discovery, authorize and token exchange were
67
+ not recorded, and it says nothing about ChatGPT, Codex or Gemini. The "Live
68
+ evidence" column above stays Pending until a pass is recorded in the table below.
57
69
 
58
70
  ## Evidence required for a live pass
59
71
 
package/docs/guide.md CHANGED
@@ -248,8 +248,15 @@ createMcpApp({
248
248
 
249
249
  Failures (MCP denials and tool exceptions) go to `console.error` when using
250
250
  `consoleAudit`; MCP successes and OAuth events go to `console.log`. Entries never carry raw
251
- exceptions or stack traces, so console output is safe. For anything beyond
252
- stdout, write your own `audit` function — that is the intended production path.
251
+ exceptions or stack traces from your tools, so console output is safe: each
252
+ caller-chosen value (`client_id`, tool name, error text) is cut to 200
253
+ characters, control and invisible characters are escaped, and a value
254
+ containing a space, `=` or a quote is printed as a quoted string, so it cannot
255
+ forge a log line or a `key=value` field (`error="Tool execution failed"`,
256
+ `reason="secretHash mismatch"`). Host-level failures that reach `audit` carry
257
+ the exception's message. For anything beyond stdout, write your own `audit`
258
+ function — that is the intended production path; the entries it receives
259
+ are not escaped, so escape or parameterize them for your sink.
253
260
 
254
261
  `wwwAuthenticate` can be a static object or `(req) => …` when the PRM URL depends on origin.
255
262
 
@@ -323,7 +330,7 @@ authorization code, or vice versa.
323
330
  Schema and handler live in one place — no parallel "defs" and "handlers" lists to keep in sync:
324
331
 
325
332
  ```ts
326
- createToolRegistry(tools, { validateArgs: false }) // default: off, easy adoption
333
+ createToolRegistry(tools) // validateArgs defaults to true; pass { validateArgs: false } to skip it
327
334
  // onToolError?: (exc) => string — default redacts to "Tool execution failed"
328
335
  ```
329
336
 
@@ -389,7 +396,11 @@ const getWeather = apiTool({
389
396
  — **status only by default, the upstream body is never forwarded** (it
390
397
  could contain internal detail, tokens, or SQL an upstream error page
391
398
  echoes back). Pass `onError(res)` to shape it yourself, e.g. include a
392
- redacted version of the body. `fetch` is overridable — testing, request
399
+ redacted version of the body. Redirects are **not followed** by default
400
+ (`redirect: "manual"`; `"error"` and `"follow"` are available): `fetch`
401
+ forwards custom headers such as `X-Api-Key`, and a POST body on a 307/308,
402
+ to whatever `Location` the upstream names, and would let an open redirect
403
+ reach internal addresses. `fetch` is overridable — testing, request
393
404
  signing, a custom agent.
394
405
  - **`timeoutMs`** (default `30000`) aborts the upstream call past that many
395
406
  ms — an upstream that never responds otherwise hangs the tool call (and
package/docs/reference.md CHANGED
@@ -107,6 +107,7 @@ entry — those are protocol errors, not auth denials.
107
107
  | `extra` | `[]` | Exact-match redirect URIs |
108
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
109
  | `allowClaude` | `true` | Allow the Claude.ai Custom Connector callback |
110
+ | `allowChatgptConnector` | `false` | Allow ChatGPT's per-connector callback `https://chatgpt.com/connector/oauth/{id}` (one `[A-Za-z0-9_-]{1,128}` id, exact string match). `createMcpApp` turns it on for `clients: ["codex"]` |
110
111
 
111
112
  Pre-registered clients, and DCR-issued clients whose id was sealed by this
112
113
  server's own `/register`, bypass this allowlist entirely — they are
@@ -154,10 +155,10 @@ A single-scope server has no such ambiguity and is unaffected.
154
155
  - `createMcpApp`, `createMcpHandler`, `createToolRegistry`
155
156
  - `consoleAudit` — convenience unified `audit` sink that logs to the console (pass any function for metrics / DB / APM)
156
157
  - `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
+ - `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. `redirect` (`"manual"` by default, `"error"`, or `"follow"`) controls upstream redirects — see the guide before enabling `"follow"`. An abort on the `Request` your `request` callback returns surfaces as `Request was cancelled`
158
159
  - `CLIENT_PROFILES`, `DEFAULT_CLIENTS`, `authMethodsFor`, `redirectUrisFor`, `preRegisteredClients`, `hasDynamicClient`
159
160
  - `parseBearer`, `wwwAuthenticateHeader`, `rejectQueryToken`
160
- - `signedTokenAuth` — one-secret convenience `McpAppAuth` (HMAC access tokens via HKDF; no refresh/revoke). Type `SignedTokenAuthOptions`
161
+ - `signedTokenAuth` — one-secret convenience `McpAppAuth` (HMAC access tokens via HKDF). `secret` may be a list for rotation (first signs, all verify). Opt-in: `isUserActive(userId)` runs on every verified token and every refresh; `refreshTtlSeconds` adds single-use rotating refresh tokens and `/revoke` for them (needs `codeStore` and `isUserActive`). Access tokens are not revocable before `ttlSeconds`. Type `SignedTokenAuthOptions`
161
162
  - `rpcResult`, `rpcError`, JSON-RPC error constants
162
163
  - `PROTOCOL_VERSIONS`, `DEFAULT_PROTOCOL_VERSION`
163
164
  - `jsonResponse({ data, status?, headers?, cors? })` — type `JsonResponseInput` exported
@@ -197,7 +198,7 @@ Building blocks for hosts assembling their own transport or OAuth flow. Less sta
197
198
  - `buildErrorRedirectUrl` — the RFC 6749 §4.1.2.1 error-redirect builder `/authorize` uses
198
199
  - Types: `AuthCodeRecord`, `CodeStore`, `ClientAuth`
199
200
 
200
- **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.
201
+ **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 the next major release.
201
202
 
202
203
  </details>
203
204
 
@@ -205,6 +206,7 @@ Building blocks for hosts assembling their own transport or OAuth flow. Less sta
205
206
  <summary><code>mcp-trellis/node</code></summary>
206
207
 
207
208
  - `asNodeHandler`, `resolveOrigin`, `isAllowedOrigin`, `toWebRequest`, `sendWebResponse`, `readNodeBody`
209
+ - `asNodeHandler` options: `origin`, `trustProxy`, `allowedOrigins`, and `allowedHosts` — the exact `Host` values to answer to (**400** otherwise). On by default for a loopback `origin` (`127.0.0.1` / `localhost` / `[::1]` on its port), to stop DNS rebinding; set it yourself for any other fixed origin or to add a LAN address
208
210
  - Types: `NodeRequestLike`, `NodeResponseLike`, `ToWebRequestOptions`, `ResolveOriginOptions`, `AsNodeHandlerOptions`, `OriginAllowlistOptions`
209
211
  - `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
210
212
 
package/docs/security.md CHANGED
@@ -21,28 +21,31 @@ This is a **library** threat model, not a third-party audit badge.
21
21
 
22
22
  | Risk | Mitigation |
23
23
  |------|------------|
24
- | Attacker-driven cross-site authorization (a logged-in user visits an attacker page and is silently walked through `/authorize`, catching the code on an attacker-controlled `redirect_uri`) | **`/authorize` never issues a code directly.** A resolved session renders a consent interstitial (built in, hardened — CSP, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, escaped reflection — or your own via `consent.render`) and only issues a code after an explicit `POST ${oauthPath}/consent` approval. The approval ticket is sealed (AES-GCM, HKDF-derived per-type key, type bound in as AEAD data — see `sealed.ts`), single-use, 5-minute TTL, and re-checked against the resolved user at redemption; it can never be redeemed as an auth code or vice versa. PKCE alone does **not** defend against this — it proves possession of the verifier, not identity of a legitimate client — so this is a distinct control, not a restatement of PKCE. `preApprovedClientIds` skips the screen only for ids `clientStore` actually resolves; a DCR or self-invented id is never trusted to skip it. The built-in interstitial's CSP scopes `form-action` to `'self'` plus the validated `redirect_uri`'s own origin — Chromium enforces `form-action` across the redirect the approval POST produces, not just its immediate target, so a custom `consent.render` that sets its own CSP must do the same or the callback to the client's own origin gets silently blocked. |
24
+ | Attacker-driven cross-site authorization (a logged-in user visits an attacker page and is silently walked through `/authorize`, catching the code on an attacker-controlled `redirect_uri`) | **`/authorize` never issues a code directly.** A resolved session renders a consent interstitial (built in, hardened — CSP, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer`, escaped reflection — or your own via `consent.render`) and only issues a code after an explicit `POST ${oauthPath}/consent` approval. The approval ticket is sealed (AES-GCM, HKDF-derived per-type key, type bound in as AEAD data — see `sealed.ts`), single-use, 5-minute TTL, and re-checked against the resolved user at redemption; it can never be redeemed as an auth code or vice versa. PKCE alone does **not** defend against this — it proves possession of the verifier, not identity of a legitimate client — so this is a distinct control, not a restatement of PKCE. `preApprovedClientIds` skips the screen only for ids `clientStore` actually resolves; a DCR or self-invented id is never trusted to skip it. The built-in interstitial's CSP scopes `form-action` to `'self'` plus the validated `redirect_uri`'s own origin (its scheme, e.g. `myapp:`, for a custom-scheme URI whose origin is opaque) — Chromium enforces `form-action` across the redirect the approval POST produces, not just its immediate target, so a custom `consent.render` that sets its own CSP must do the same or the callback to the client's own origin gets silently blocked. |
25
25
  | Confused deputy (token usable at the wrong MCP) | RFC 8707: `resource` required on authorize + token; bound into the auth code; must equal this AS's canonical resource (`canonicalResource(origin, resourcePath)`). Passed into `mintAccessToken` / `refreshAccessToken` so you can embed `aud`. |
26
26
  | Refresh token replayed at another resource on the same AS | Library cannot inspect opaque refresh tokens — **`refreshAccessToken` MUST reject tokens not originally issued for the given `resource`** |
27
27
  | Audience not enforced at the RS | **`createMcpApp` enforces this** — `verifyToken` returns the token's `audience` and the library rejects any mismatch against `canonicalResource(origin, resourcePath)`. With bare `createMcpHandler`, the check is yours. |
28
28
  | Open redirect after login | Your login page must validate `next` is same-origin before redirecting |
29
29
  | Auth-code replay across instances | Pass a shared `codeStore`; in-memory `jti` (the auth code's single-use id) map is process-local only |
30
- | Origin spoofing on Node | Pass explicit `origin`, or `trustProxy: true` only behind a proxy that strips client `X-Forwarded-*`. Host-derived origin **requires** non-empty `allowedOrigins` on `asNodeHandler` (`["*"]` to opt out loudly) |
30
+ | Origin spoofing on Node | Pass explicit `origin`, or `trustProxy: true` only behind a proxy that strips client `X-Forwarded-*`. Host-derived origin **requires** non-empty `allowedOrigins` on `asNodeHandler` (`["*"]` to opt out loudly). A fixed `origin` ignores the `Host` header, so a DNS-rebinding page could otherwise drive the whole OAuth flow from a victim's browser: for a loopback `origin` `asNodeHandler` rejects any other `Host` by default, and `allowedHosts` does the same for any origin |
31
31
  | A Host/`X-Forwarded-*` header carrying a path, query, fragment, or embedded credentials (`evil.test/../trusted.test`, `user:pass@evil.test`) building an origin string that merely looks well-formed | `resolveOrigin` always normalizes and validates through `URL`, rejecting any of those (plus a non-`http(s)` `X-Forwarded-Proto`) with `InvalidOriginError` — `asNodeHandler` answers it **400**, not the generic 500 an actual unexpected failure gets |
32
32
  | Browser `Origin` on MCP (DNS rebinding / CSRF) | `allowedRequestOrigins` on `createMcpHandler` / `createMcpApp`. No `Origin` header → allowed (native clients). Omitted allowlist → **reject** any present `Origin`. `"*"` opts out. Distinct from Node Host `allowedOrigins` |
33
33
  | Scope escalation at authorize | Requested `scope` is validated against the advertised `scopes` and rejected with `invalid_scope`; the grant is bound into the auth code and handed to `mintAccessToken` |
34
34
  | An unscoped tool silently callable by any authenticated principal, including one holding none of the advertised scopes — the gap `defaultScopes: []` alone doesn't close, since that only governs the token grant, not per-tool enforcement | `createMcpApp` refuses to construct once `scopes` has more than one entry if any tool omits `ToolDef.scope` — pass `scope: null` to state the omission on purpose. A single-scope server has no such ambiguity and is unaffected. `tools/call` itself always enforces whatever `scope` a tool does declare, independent of this guard (403 `insufficient_scope`) |
35
35
  | Scope reduction lost on refresh | Optional `scope` on the refresh token request is validated against advertised scopes and forwarded on `RefreshAccessTokenInput.scope`. **`refreshAccessToken` MUST enforce ⊆ originally granted scopes** (opaque RT — same pattern as `resource`). The token response omits `scope` when neither the port nor the grant supplies one (RFC 6749 §5.1) — clients must not assume the field is always present |
36
+ | A removed or disabled user keeps a working connector | `signedTokenAuth` tokens are stateless, so they outlive the login session. `isUserActive` closes that; see [Sessions and expiry](#sessions-and-expiry) |
36
37
  | Stolen access or refresh token | Host `revokeToken` (denylist or equivalent) that `verifyToken` and `refreshAccessToken` consult. Unknown / wrong-client tokens MUST no-op, not throw. Well-formed authenticated revoke is 200 even if the token is unknown |
37
38
  | Stolen confidential-client secret | `clientStore.verifySecret` owns comparison — store hashes, not plaintext. The library never sees or persists credentials. **Prefer `clientStore.secretHash`** instead: return a stored hash from `hashClientSecret` and the library does the comparison with the same constant-time primitive it uses everywhere else, rather than a hand-rolled `stored === presented` in your own `verifySecret` being a timing oracle with no warning |
38
39
  | An unauthenticated caller enumerating which `client_id`s a locked-down server knows, via distinguishable `/token`/`/revoke` error text or response timing | Every client-auth failure at `/token` and `/revoke` collapses to one `invalid_client` / "client authentication failed" — previously up to five distinguishable messages named exactly why (wrong method, missing secret, misconfigured store, unknown id). Every rejection path in `firstClientAuthError` for a client_id that *is* known — wrong auth method, no secret presented, wrong secret, no `secretHash`/`verifySecret` on file — runs the same fixed-cost dummy or real comparison before returning, so response timing alone can't tell "known client, wrong method" apart from "known client, wrong secret." An unknown `client_id` on a locked-down server (`allowUnregisteredClients: false`) pays the identical fixed cost, so the crypto-verification cost itself can't distinguish "unknown" from "known, wrong secret" either. `/authorize`'s `unauthorized_client` is unchanged and stays descriptive — a human debugging a browser flow, not a bare API response, and a locked-down server already announces the same fact by omitting `registration_endpoint` |
39
40
  | Auth codes readable by anyone who sees one — browser history, `Referer`, proxy logs | Auth codes are sealed (AES-GCM, `sealed.ts`), not signed-only. Legacy v1 (HMAC-signed, not encrypted) codes are **rejected** since 1.0 — complete any rolling deploy before upgrading past 0.4.x |
40
41
  | An upstream API's error body leaking into the model or the audit log via `apiTool` | Default is status-only — the upstream response body is never forwarded on a non-2xx result, since it's a return value `onToolError` redaction never sees. Use `onError` for (a redacted version of) the body |
42
+ | `apiTool` as a credential-forwarding / SSRF primitive via upstream redirects | Redirects are not followed by default (`redirect: "manual"`): `fetch` would forward custom auth headers (`X-Api-Key`) and a 307 body to the `Location` and could fetch an internal address, returning it to the model. `"follow"` is an explicit opt-in for a fully trusted upstream |
43
+ | A tool call that amplifies a small request into a huge validation error | Validation stops after 20 violations and the message is capped at 2000 characters; audit copies of tool errors are capped at 1000 |
41
44
  | `/authorize` errors delivered as a bare JSON body a connector can't act on | RFC 6749 §4.1.2.1: once `redirect_uri` is validated, every remaining error (`unsupported_response_type`, PKCE, `invalid_scope`, `invalid_target`, …) redirects to the client's own callback with `?error=…&state=…` instead. `redirect_uri` validity itself, and an oversized `state`, stay direct JSON — the URI is what's in question in the first case, and reflecting an oversized `state` into its own error redirect would recreate the DoS the length cap exists to prevent |
42
- | DCR-issued `client_id` reused with a substituted `redirect_uri` | `/register` no longer returns a bare random id — the `client_id` **is** a sealed, self-verifying assertion of the exact `redirect_uris` it registered (`sealed.ts`, type `"client"`), with zero server-side storage. `/authorize` unseals it and binds the request to that list, the same protection a stored registration would give. This does not require the *identity* of the client to be verified — an attacker can still call `/register` and mint their own sealed id — so it stops URI substitution on a legitimate connector's id, not impersonation; the consent step above is what stops impersonation. `requireRegisteredClients` defaults to **true** since 1.0 (reject any `client_id` that isn't `clientStore`-resolved or self-sealed); set `false` only to accept invented public ids. Enforced at `/authorize`, and — since 1.2.0 — at `/token`'s `refresh_token` grant and at `/revoke` too, closing a gap where an invented `client_id` that never went through `/authorize` could still authenticate on either (the `authorization_code` grant was never actually exposed by this: a redeemable code's `client_id` is bound into its sealed payload at `/authorize` time, where the check already ran). Opt into CIMD with `cimd: true` for URL-shaped client_ids (off by default) |
43
- | CIMD fetch as SSRF proxy (attacker supplies `https://…` `client_id`) | **Off by default** (`cimd: true` to enable). When on: HTTPS-only fetch, private/loopback/link-local/NAT64/`::ffff:` blocked (incl. bracketed IPv6 **and full-form mapped encodings DNS may return**, e.g. `0:0:0:0:0:ffff:7f00:1`), DNS lookup required before fetch and re-checked after the body, unparseable IPv6 lookup results fail closed, 5 KB stream-capped body / 10 s per hop covering the body read / 3 same-origin redirects (cross-origin redirects rejected), public clients only, `http:` redirect_uris loopback-only (`127.0.0.1` / `[::1]`, never `localhost`). Also blocked: `192.0.0/24`, `198.18/15`, multicast/reserved `224/4`–`240/4`, IPv6 multicast `ff00::/8`, 6to4 `2002::/16`, IPv4-compatible `::a.b.c.d` (and full-form private embeds). `/token` and `/revoke` **re-resolve** CIMD — never accept URL-shaped ids by shape alone; pass `cimdCache` (strongly recommended) or every refresh/revoke re-fetches the document, and a failed re-resolve is audited as `cimd_resolve_failed`. **Ceiling:** TCP connect is not pinned to the looked-up addresses (zero-dep Web `fetch`); classic DNS rebinding between lookup and connect remains possible until a custom dispatcher pins the IP — do not treat CIMD as production-complete SSRF defense until then |
45
+ | DCR-issued `client_id` reused with a substituted `redirect_uri` | `/register` no longer returns a bare random id — the `client_id` **is** a sealed, self-verifying assertion of the exact `redirect_uris` it registered (`sealed.ts`, type `"client"`), with zero server-side storage. `/authorize` unseals it and binds the request to that list, the same protection a stored registration would give. This does not require the *identity* of the client to be verified — an attacker can still call `/register` and mint their own sealed id — so it stops URI substitution on a legitimate connector's id, not impersonation; the consent step above is what stops impersonation. `requireRegisteredClients` defaults to **true** since 1.0 (reject any `client_id` that isn't `clientStore`-resolved or self-sealed); set `false` only to accept invented public ids. Enforced at `/authorize`, and — since 2.0.0 — at `/token`'s `refresh_token` grant and at `/revoke` too, closing a gap where an invented `client_id` that never went through `/authorize` could still authenticate on either (the `authorization_code` grant was never actually exposed by this: a redeemable code's `client_id` is bound into its sealed payload at `/authorize` time, where the check already ran). Opt into CIMD with `cimd: true` for URL-shaped client_ids (off by default) |
46
+ | CIMD fetch as SSRF proxy (attacker supplies `https://…` `client_id`) | **Off by default** (`cimd: true` to enable). When on: HTTPS-only fetch, private/loopback/link-local/NAT64/`::ffff:` blocked (incl. bracketed IPv6 **and full-form mapped encodings DNS may return**, e.g. `0:0:0:0:0:ffff:7f00:1`), DNS lookup required before fetch and re-checked after the body, unparseable IPv6 lookup results fail closed, 5 KB stream-capped body / 10 s per hop covering the body read / 3 same-origin redirects (cross-origin redirects rejected), public clients only, `http:` redirect_uris loopback-only (`127.0.0.1` / `[::1]`, never `localhost`). Also blocked: `192.0.0/24`, `198.18/15`, multicast/reserved `224/4`–`240/4`, IPv6 multicast `ff00::/8`, 6to4 `2002::/16`, IPv4-compatible `::a.b.c.d` (and full-form private embeds). `/token` and `/revoke` **re-resolve** CIMD — never accept URL-shaped ids by shape alone; pass `cimdCache` (strongly recommended) or every refresh/revoke re-fetches the document, and a failed resolve is audited as `cimd_resolve_failed` at `/authorize`, `/token` and `/revoke` with a reason code (`http_status: status 403 (authorize)`; never an address, header, body or redirect target) while the connector gets one generic `unauthorized_client`. **Ceiling:** TCP connect is not pinned to the looked-up addresses (zero-dep Web `fetch`); classic DNS rebinding between lookup and connect remains possible until a custom dispatcher pins the IP — do not treat CIMD as production-complete SSRF defense until then |
44
47
  | Naming only confidential clients doesn't actually block public ones | `clients: ["gemini"]` (or `allowUnregisteredClients: false`) is **enforced**: DCR unmounted, dropped from AS metadata, unresolved `client_id` rejected with `unauthorized_client` at `authorize` and `invalid_client` at `token` and `revoke` |
45
- | DCR / token-endpoint abuse | Edge or reverse-proxy rate limiting on `/register` and `/token`, **plus** a library-enforced request body cap (see "Also designed in") — the library does not rate-limit by request count, but an unbounded body was previously a memory-exhaustion vector on its own regardless of rate |
48
+ | DCR / token-endpoint abuse | Edge or reverse-proxy rate limiting on `/register` and `/token` — and on `/authorize` when `cimd: true`, where each unresolvable `client_id` costs an outbound fetch and one `cimd_resolve_failed` audit entry — **plus** a library-enforced request body cap (see "Also designed in") — the library does not rate-limit by request count, but an unbounded body was previously a memory-exhaustion vector on its own regardless of rate |
46
49
  | A weak or copy-pasted `codeSecret` (it can forge an auth code — or, since 0.3.0, a consent ticket or DCR client assertion — for any userId/scope/resource) | Validated at the point of use: a string or array form throws at `createOAuthRouter` construction (every array entry checked, and the array itself must be non-empty), a function form on every call. Minimum 32 characters per entry, and the exact literals this package's own docs and examples have published are denylisted by name, as is any secret containing the examples' `do-not-reuse` marker — copy-paste is the realistic failure mode. The examples themselves read `MCP_SECRET` (or `OAUTH_CODE_SECRET` for the advanced multi-secret recipes) from the environment and ship no usable default, and a test fails if any `codeSecret` literal in `examples/`, `README.md` or `docs/` is one the library would accept. This only catches the obvious cases; a leaked or brute-forceable 32+ character secret is still a full bypass, same as before |
47
50
  | An operator unable to diagnose *why* a client was rejected, once `/token`/`/revoke` collapsed every failure to one generic `invalid_client` (see the enumeration-oracle row above) | `OAuthPorts.audit` (and `McpAppAuth.audit` on `createMcpApp`) — opt-in, sees the real reason (`"client requires client_secret_basic, got none"`, a rejected `codeSecret`'s actual message, …) that never reaches the caller. Same "never fails the request" guarantee as the MCP-side `audit` port |
48
51
  | A hanging `ports.audit` sink stalling every audited response indefinitely | `auditTimeoutMs` (default 1000ms) on both `McpHandlerOptions` and `OAuthRouterOptions` races the audit call against a timeout; "can't fail a request" already held, this adds "can't delay one" past a bound. A slow sink keeps running in the background and its own internal `catch` means it can never surface as an unhandled rejection later |
@@ -51,15 +54,68 @@ This is a **library** threat model, not a third-party audit badge.
51
54
 
52
55
  **Known limits:** in-memory jti map is process-local (multi-instance needs `codeStore`); the loopback redirect allowlist permits any path or port on `127.0.0.1`/`[::1]` over `http:` only — `localhost` and `https:` loopback are deliberately not accepted (RFC 8252 §8.3: `localhost` is DNS-resolvable, not a loopback guarantee, and native-app loopback redirects are `http:` only); DCR binds a client to its own `redirect_uris` via a sealed assertion but does not verify the *registrant's identity* (opt into CIMD with `cimd: true`); CIMD DNS checks do not pin the TCP connect (see CIMD SSRF row).
53
56
 
57
+ ## Sessions and expiry
58
+
59
+ The connector holds a token, never the app's login cookie. What each event does
60
+ to it, with `signedTokenAuth`:
61
+
62
+ | Event | What the connector sees |
63
+ |-------|-------------------------|
64
+ | The app session ends (cookie expires, logout) | Nothing. The next `/authorize` redirects to `loginUrl`; a token already issued keeps working |
65
+ | The access token reaches its `ttlSeconds` (default 1 h) | `401` with `WWW-Authenticate: Bearer …, error="invalid_token"` (one code for a bad signature, expiry, wrong audience or an inactive user). With a refresh token the client renews silently; without one the user goes through the browser again |
66
+ | The user is removed, disabled or signed out everywhere | With `isUserActive`: `401` on the next MCP request, `invalid_grant` on the next refresh. Without it: the access token works until it expires. `isUserActive` runs on every request, only `true` passes, a throw is a `500`; keep it cheap or cached, a call that hangs stalls the request |
67
+ | The user's role or scopes change | The token keeps the scopes granted at issue until it expires. Enforce current permissions in the data layer |
68
+ | The user sits on the consent page over 5 minutes, the session ends before Approve, or Approve is posted twice | A short page in the browser (JSON for a caller that does not ask for `text/html`), status `400`. `consent.render` customizes the consent screen only |
69
+ | `secret` becomes a list `[new, old]` | Old tokens and DCR `client_id`s keep working, new ones are signed with `new`. Remove `old` once its tokens have expired; until then it can still forge |
70
+ | `initialize` and `ping` | Succeed with no token or a bad one, so a "connected" status proves nothing. A `GET /mcp` with an `Authorization` header is answered `405` without checking it. Only `tools/list` and `tools/call` check the token |
71
+
72
+ Access tokens are bearer tokens: not bound to a client and not revocable before
73
+ they expire, so keep `ttlSeconds` short.
74
+
75
+ **Refresh (`refreshTtlSeconds`).** Off unless set. Refresh tokens are signed, not
76
+ stored; the only state is a single-use mark per token in `codeStore`.
77
+
78
+ - **Lifetime.** Absolute: fixed when the authorization code is redeemed and never extended by refreshing, so
79
+ the user approves again after `refreshTtlSeconds`. The last access token issued
80
+ can still run up to `ttlSeconds` past that.
81
+ - **Single use.** Each refresh token works once and is replaced. `codeStore.consume`
82
+ must be atomic (Redis `SET NX EX`, a unique-key insert) with a TTL no shorter than
83
+ the token. The process-local store does not hold across instances and Workers KV
84
+ is not atomic, so neither gives single use. Pass the same `codeStore` instance to
85
+ `signedTokenAuth` as the router uses; it refuses to start without one. One mark
86
+ per refresh stays until the grant's expiry, so size the store and rate-limit
87
+ `/token` per `client_id`.
88
+ - **Binding.** To the user, client, resource and scopes of the consent. A refresh
89
+ may narrow scope, never widen it. Narrowing applies to that one access token: the
90
+ successor refresh token keeps the original grant's scopes, so the next refresh can
91
+ ask for them again (RFC 6749 §6). Every failure is one `invalid_grant`, and every
92
+ check that depends on the caller's input runs before the token is burned, so a
93
+ wrong client cannot spend the rightful holder's token.
94
+ - **Size.** A refresh token is capped at 4096 characters. Issuing a longer one (a
95
+ user id of roughly 2.8 KB of ASCII, or a very long scope list) throws: at the
96
+ first issuance that is a `500` at `/token` after the authorization code was
97
+ spent, on every login for that user. Keep user ids short.
98
+ - **No grace window.** A client that loses a refresh response and retries is logged
99
+ out and re-authorizes, as it is if minting fails after the old token was burned.
100
+ - **No token family.** Replaying a used token is refused, but the successor a thief
101
+ obtained first stays valid until its expiry or until `isUserActive` turns false.
102
+ For family revocation use an opaque server-side store
103
+ (`examples/project-desk/src/tokens.ts`).
104
+ - **`/revoke`** burns a refresh token only and answers `200` for anything else,
105
+ including an access token. A store failure on a valid refresh token is a `500`, so
106
+ revoking the current refresh token never reports success while it still works.
107
+ Revoking one that was already replaced by a refresh is a no-op: it answers `200`
108
+ and the successor keeps working (see "No token family").
109
+
54
110
  ## Rotating codeSecret
55
111
 
56
112
  `codeSecret` is the one secret behind auth codes, consent tickets, DCR
57
113
  client assertions, and (when `clientStore.secretHash` is used)
58
- confidential-client secret hashes. Before 1.2.0 it was a single string —
114
+ confidential-client secret hashes. Before 2.0.0 it was a single string —
59
115
  rotating it invalidated every DCR-issued `client_id` still in a connector's
60
116
  hands and every stored `secretHash`, with no way to phase the change in.
61
117
 
62
- Since 1.2.0, `codeSecret` also accepts an array:
118
+ Since 2.0.0, `codeSecret` also accepts an array:
63
119
 
64
120
  ```ts
65
121
  codeSecret: [newSecret, oldSecret],
@@ -14,6 +14,10 @@
14
14
  | Wrong user's data | Data query ignores authenticated identity | Filter by verified principal and tenant; never trust a tool argument for ownership. |
15
15
  | Switching login does not switch connector user | Connector retains its previously issued token | Disconnect and reauthorize with the intended account. |
16
16
  | Initialization works but tools fail | Initialize is public | Test authenticated `tools/call`; initialize alone proves no auth flow. |
17
+ | `unauthorized_client: CIMD client_id could not be resolved` | The connector is told one generic error on purpose; the cause is in your audit sink | Read the `cimd_resolve_failed` entry: `reason` is `<code>[: detail] (authorize\|token/revoke)`. `http_status: status 403` = the document host refused the fetch; `auth_method_unsupported` = its document excludes `none`; `dns_failed (ENOTFOUND)` / `dns_unavailable` = no DNS from this runtime, pass `cimdLookup`; `blocked_address` = it resolves to a non-public address; `timeout` = the host did not answer, or stalled mid-body, within 10 s. Pass `cimdCache` so refresh does not re-fetch. |
18
+ | Connector asks to reconnect about every hour | The access token expired and there is no refresh token (`signedTokenAuth` default) | Set `refreshTtlSeconds` with `codeStore` and `isUserActive`, or raise `ttlSeconds`. See [Sessions and expiry](security.md#sessions-and-expiry) |
19
+ | `401` with `error="invalid_token"` | A bearer was sent and rejected, whatever the reason ([Sessions and expiry](security.md#sessions-and-expiry)) | Reconnect. Check expiry and audience, then your `isUserActive` |
20
+ | Approve shows "This approval request expired" / "Your sign-in changed or expired" / "already submitted" | Ticket older than 5 minutes, the app session ended before Approve, or the form was posted twice | Start the connection again from the connector |
17
21
  | Client requests an unsupported protocol | Package supports the revisions in `src/mcp/protocol.ts` | Record negotiation; do not claim a live pass from a successful HTTP status alone. |
18
22
 
19
23
  See [security responsibilities](security.md) before replacing production token verification with example code. Share sanitized errors in an issue, never access tokens or session cookies.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mcp-trellis",
3
- "version": "2.2.0",
4
- "description": "Add authenticated remote MCP connectors to existing TypeScript SaaS apps using your existing users and auth.",
3
+ "version": "3.1.0",
4
+ "description": "Plug your existing SaaS's users, auth and permissions into MCP with OAuth 2.1, so Claude, ChatGPT and other AI clients can act on your app as that user.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "sideEffects": false,
@@ -31,7 +31,10 @@
31
31
  "saas",
32
32
  "remote-mcp",
33
33
  "byo-auth",
34
- "ai-connector"
34
+ "ai-connector",
35
+ "mcp-auth",
36
+ "nextjs",
37
+ "express"
35
38
  ],
36
39
  "types": "./dist/server.d.ts",
37
40
  "exports": {
@@ -81,7 +84,7 @@
81
84
  "node": ">=20"
82
85
  },
83
86
  "devDependencies": {
84
- "@biomejs/biome": "2.5.11",
87
+ "@biomejs/biome": "2.6.3",
85
88
  "@types/node": "^26.4.1",
86
89
  "tsx": "^4.19.3",
87
90
  "typescript": "^5.8.2"
@@ -1,26 +0,0 @@
1
- /**
2
- * Convenience auth: one secret seals OAuth codes (via `codeSecret`) and
3
- * signs short-lived access tokens. Domain separation is HKDF, not luck —
4
- * access-token HMAC uses `mcp-trellis:signed-access-token:v1`, distinct from
5
- * the sealed-code and client-secret-hash labels.
6
- *
7
- * ponytail: stateless tokens cannot be revoked before `exp`. No
8
- * `refreshAccessToken` / `revokeToken` — `/revoke` stays unmounted and
9
- * refresh unadvertised. Upgrade: supply those ports plus a store (see
10
- * `examples/project-desk/src/tokens.ts`).
11
- */
12
- import type { McpAppAuth } from "../app-options.js";
13
- import type { CodeStore } from "../oauth/crypto/codes.js";
14
- import type { OAuthUser } from "../oauth/types.js";
15
- export type SignedTokenAuthOptions = {
16
- secret: string;
17
- resolveUser: (request: Request) => Promise<OAuthUser | null>;
18
- loginUrl: (request: Request, nextPath: string) => string;
19
- /** Access-token lifetime in seconds. Default 3600. Must be finite and > 0. */
20
- ttlSeconds?: number;
21
- /** Passthrough — production still needs a shared store across instances. */
22
- codeStore?: CodeStore;
23
- };
24
- /** Builds a complete `McpAppAuth` from one secret plus your session resolver. */
25
- export declare const signedTokenAuth: (options: SignedTokenAuthOptions) => McpAppAuth;
26
- //# sourceMappingURL=signed-token.d.ts.map