mcp-trellis 0.2.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -561
- package/dist/adapters/node.d.ts +15 -6
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +56 -29
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/origins.d.ts.map +1 -1
- package/dist/adapters/origins.js +1 -4
- package/dist/adapters/origins.js.map +1 -1
- package/dist/app.d.ts +27 -4
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +31 -18
- package/dist/app.js.map +1 -1
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +6 -1
- package/dist/auth/bearer.js.map +1 -1
- package/dist/body.d.ts +48 -0
- package/dist/body.d.ts.map +1 -0
- package/dist/body.js +145 -0
- package/dist/body.js.map +1 -0
- package/dist/clients.d.ts.map +1 -1
- package/dist/clients.js +2 -5
- package/dist/clients.js.map +1 -1
- package/dist/dispatch.d.ts +1 -1
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +58 -18
- package/dist/dispatch.js.map +1 -1
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js.map +1 -1
- package/dist/jsonrpc.d.ts +1 -0
- package/dist/jsonrpc.d.ts.map +1 -1
- package/dist/jsonrpc.js +1 -0
- package/dist/jsonrpc.js.map +1 -1
- package/dist/methods.d.ts +38 -15
- package/dist/methods.d.ts.map +1 -1
- package/dist/methods.js +38 -22
- package/dist/methods.js.map +1 -1
- package/dist/oauth/authorize.d.ts.map +1 -1
- package/dist/oauth/authorize.js +100 -68
- package/dist/oauth/authorize.js.map +1 -1
- package/dist/oauth/base64url.d.ts +1 -1
- package/dist/oauth/base64url.d.ts.map +1 -1
- package/dist/oauth/base64url.js +1 -4
- package/dist/oauth/base64url.js.map +1 -1
- package/dist/oauth/clientauth.d.ts +7 -2
- package/dist/oauth/clientauth.d.ts.map +1 -1
- package/dist/oauth/clientauth.js +59 -16
- package/dist/oauth/clientauth.js.map +1 -1
- package/dist/oauth/codes.d.ts +4 -4
- package/dist/oauth/codes.d.ts.map +1 -1
- package/dist/oauth/codes.js +43 -51
- package/dist/oauth/codes.js.map +1 -1
- package/dist/oauth/consent.d.ts +65 -0
- package/dist/oauth/consent.d.ts.map +1 -0
- package/dist/oauth/consent.js +173 -0
- package/dist/oauth/consent.js.map +1 -0
- package/dist/oauth/constants.d.ts +4 -0
- package/dist/oauth/constants.d.ts.map +1 -1
- package/dist/oauth/constants.js +4 -0
- package/dist/oauth/constants.js.map +1 -1
- package/dist/oauth/metadata.d.ts +6 -6
- package/dist/oauth/metadata.d.ts.map +1 -1
- package/dist/oauth/metadata.js.map +1 -1
- package/dist/oauth/pkce.d.ts.map +1 -1
- package/dist/oauth/pkce.js +1 -1
- package/dist/oauth/pkce.js.map +1 -1
- package/dist/oauth/redirect.d.ts +6 -1
- package/dist/oauth/redirect.d.ts.map +1 -1
- package/dist/oauth/redirect.js +23 -10
- package/dist/oauth/redirect.js.map +1 -1
- package/dist/oauth/reqbody.d.ts +10 -0
- package/dist/oauth/reqbody.d.ts.map +1 -0
- package/dist/oauth/reqbody.js +29 -0
- package/dist/oauth/reqbody.js.map +1 -0
- package/dist/oauth/resource.d.ts +30 -0
- package/dist/oauth/resource.d.ts.map +1 -1
- package/dist/oauth/resource.js +38 -8
- package/dist/oauth/resource.js.map +1 -1
- package/dist/oauth/revoke.d.ts.map +1 -1
- package/dist/oauth/revoke.js +15 -3
- package/dist/oauth/revoke.js.map +1 -1
- package/dist/oauth/router.d.ts +14 -11
- package/dist/oauth/router.d.ts.map +1 -1
- package/dist/oauth/router.js +62 -22
- package/dist/oauth/router.js.map +1 -1
- package/dist/oauth/scope.d.ts +11 -2
- package/dist/oauth/scope.d.ts.map +1 -1
- package/dist/oauth/scope.js +18 -7
- package/dist/oauth/scope.js.map +1 -1
- package/dist/oauth/sealed.d.ts +17 -0
- package/dist/oauth/sealed.d.ts.map +1 -0
- package/dist/oauth/sealed.js +57 -0
- package/dist/oauth/sealed.js.map +1 -0
- package/dist/oauth/secrethash.d.ts +44 -0
- package/dist/oauth/secrethash.d.ts.map +1 -0
- package/dist/oauth/secrethash.js +66 -0
- package/dist/oauth/secrethash.js.map +1 -0
- package/dist/oauth/token.d.ts.map +1 -1
- package/dist/oauth/token.js +13 -34
- package/dist/oauth/token.js.map +1 -1
- package/dist/oauth/types.d.ts +106 -9
- package/dist/oauth/types.d.ts.map +1 -1
- package/dist/oauth/types.js +67 -4
- package/dist/oauth/types.js.map +1 -1
- package/dist/oauth/wellknown.d.ts.map +1 -1
- package/dist/oauth/wellknown.js +6 -4
- package/dist/oauth/wellknown.js.map +1 -1
- package/dist/protocol.d.ts +15 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +16 -6
- package/dist/protocol.js.map +1 -1
- package/dist/registry.d.ts +4 -4
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +15 -7
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +14 -10
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +8 -8
- package/dist/server.js.map +1 -1
- package/dist/tools.d.ts +11 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +14 -13
- package/dist/tools.js.map +1 -1
- package/dist/validate.d.ts +16 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +46 -8
- package/dist/validate.js.map +1 -1
- package/docs/ROADMAP.md +295 -0
- package/docs/guide.md +417 -0
- package/docs/reference.md +178 -0
- package/docs/security.md +63 -0
- package/package.json +7 -3
package/docs/security.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Protocol promises, library threat model, and explicit non-goals.
|
|
4
|
+
Ports and audience wiring: [guide.md](guide.md). Roadmap: [ROADMAP.md](ROADMAP.md).
|
|
5
|
+
|
|
6
|
+
## Protocol promises
|
|
7
|
+
|
|
8
|
+
- Notifications → HTTP **202** with an empty body
|
|
9
|
+
- Batch arrays → `-32600` (MCP 2025-06-18)
|
|
10
|
+
- Protocol version negotiated from client `initialize` params — an unrecognized or missing request falls back to the **newest** supported revision, not the oldest
|
|
11
|
+
- `jsonrpc` must be exactly `"2.0"` on every `/mcp` request → `-32600` otherwise, echoing the request id when one was present
|
|
12
|
+
- `MCP-Protocol-Version` header validated on every request past `initialize` (which is what negotiates it) — unsupported → 400; missing → assumed `2025-03-26` per the spec's own backwards-compatibility rule
|
|
13
|
+
- Query-string tokens rejected (`token` and RFC 6750 `access_token`)
|
|
14
|
+
- `tools/call` checks `principal.scopes` (`*` = all), and the OAuth grant that produced them is validated at `authorize`
|
|
15
|
+
- With `createMcpApp`, tokens whose audience is not this server's canonical resource are rejected by the library
|
|
16
|
+
- **`fetch` never rejects.** If a host port you supply (`authenticate`, `context`, `resolveUser`, `mintAccessToken`, `clientStore`, …) throws, `createMcpHandler`, `createOAuthRouter`, and `createMcpApp` all catch it and return a real `500` (`-32603` for MCP, `server_error` for OAuth) instead of an unhandled rejection — whose shape and whether it even reaches the client varies by runtime. `asNodeHandler` catches independently too, since on raw `http.createServer` a rejection there leaves the socket open with no response ever sent.
|
|
17
|
+
|
|
18
|
+
## Threat model (library)
|
|
19
|
+
|
|
20
|
+
This is a **library** threat model, not a third-party audit badge.
|
|
21
|
+
|
|
22
|
+
| Risk | Mitigation |
|
|
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. |
|
|
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
|
+
| 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
|
+
| 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
|
+
| Open redirect after login | Your login page must validate `next` is same-origin before redirecting |
|
|
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) |
|
|
31
|
+
| 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` |
|
|
32
|
+
| 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 |
|
|
33
|
+
| 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 |
|
|
34
|
+
| 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). An unknown `client_id` on a locked-down server (`allowUnregisteredClients: false`) also runs a fixed-cost dummy comparison, so the crypto-verification cost itself can't distinguish "unknown" from "known, wrong secret." `/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` |
|
|
35
|
+
| 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 |
|
|
36
|
+
| 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 |
|
|
37
|
+
| `/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 |
|
|
38
|
+
| 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. The IETF's Client ID Metadata Document (CIMD) draft is the next step past this |
|
|
39
|
+
| 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` |
|
|
40
|
+
| 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 |
|
|
41
|
+
| 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 form throws at `createOAuthRouter` construction, a function form on every call. Minimum 32 characters, and the exact literals this package's own docs and examples publish are denylisted by name — copy-paste is the realistic failure mode. This only catches the obvious cases; a leaked or brute-forceable 32+ character secret is still a full bypass, same as before |
|
|
42
|
+
| 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 |
|
|
43
|
+
| A hanging `ports.audit` sink stalling every audited response indefinitely | `McpHandlerOptions.auditTimeoutMs` (default 1000ms) 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 |
|
|
44
|
+
|
|
45
|
+
**Also designed in:** PKCE S256 (timing-safe, length-capped); auth codes, consent tickets, and DCR client assertions are all AES-GCM sealed with domain-separated keys (`sealed.ts`) so none of the three token types can be presented as another, and each binds `userId`/`clientId`/`redirectUri`/challenge/`resource` as applicable; redirect allowlist + DCR filter; a `redirect_uri` carrying a fragment or embedded credentials is rejected outright; no query-string tokens (`token` and `access_token`); honest `grant_types_supported`; `/token` and `/revoke` omit CORS `*` and both accept JSON or form bodies; request bodies are capped (1 MiB on `/mcp`, 64 KiB on `/token`/`/revoke`/`/register`) and a chunked body with no declared length is aborted mid-stream past the cap, not buffered first; audit fires on auth/scope denial as well as tool results; 401 responses echo the real JSON-RPC request id instead of hardcoding `null`; `createOAuthRouter` refuses to construct if `oauthPath` and `resourcePath` would collide, or if `resourcePath` starts with `/.well-known`; `resourcePath` is normalized once at construction (`normalizeConfiguredPath`) so a trailing slash can't make `/authorize`, `/token`, and route matching disagree with each other about what the canonical resource is.
|
|
46
|
+
|
|
47
|
+
**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* until CIMD, so an attacker can still self-register (see the DCR row above).
|
|
48
|
+
|
|
49
|
+
## Not in scope
|
|
50
|
+
|
|
51
|
+
Explicitly **out** of this package (do not expect parity with full MCP hosts or enterprise AS products):
|
|
52
|
+
|
|
53
|
+
- **Tools only** — no `resources/*`, no `prompts/*`, and capabilities advertise tools alone
|
|
54
|
+
- **No server-initiated messages** — Streamable HTTP is served as a single JSON response per POST (which the spec permits, and which Gemini Enterprise requires). No SSE response streams, so no `notifications/progress` and no long-running tool keepalive
|
|
55
|
+
- **No sessions** — removed from the protocol in `2026-07-28` anyway
|
|
56
|
+
- **No enterprise-managed auth** — no ID-JAG, no IdP product integration; you wire login + minting
|
|
57
|
+
- **No login UI, no token store, no sessions** — the consent/approval screen at `/authorize` *is* shipped (built in, overridable via `consent.render`), because it's a security control determined entirely by protocol data the library already holds (`client_id`, `scope`, `redirect_uri`, `resource`), not by your IdP or branding. Login is the boundary: who the user is, and how they prove it, stays yours
|
|
58
|
+
- **No stdio** transport; no batch JSON-RPC arrays
|
|
59
|
+
- **No DPoP, PAR, or `client_credentials`** — this AS is authorization_code (+ optional refresh and revoke)
|
|
60
|
+
- **No rate limiting** — put quotas on `/register` and `/token` at the edge or reverse proxy
|
|
61
|
+
- **No paid external security audit** claimed here — see Threat model above
|
|
62
|
+
|
|
63
|
+
See [ROADMAP.md](ROADMAP.md) for what is next — CIMD and the `2026-07-28` stateless core are the two open items.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-trellis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Zero-dependency MCP server + OAuth 2.1 authorization server in one package, for Claude, Gemini, and Codex connectors",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"authorization-server",
|
|
28
28
|
"connector"
|
|
29
29
|
],
|
|
30
|
+
"types": "./dist/server.d.ts",
|
|
30
31
|
"exports": {
|
|
31
32
|
".": {
|
|
32
33
|
"types": "./dist/server.d.ts",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
},
|
|
44
45
|
"files": [
|
|
45
46
|
"dist",
|
|
46
|
-
"docs
|
|
47
|
+
"docs",
|
|
47
48
|
"README.md",
|
|
48
49
|
"LICENSE"
|
|
49
50
|
],
|
|
@@ -52,15 +53,18 @@
|
|
|
52
53
|
},
|
|
53
54
|
"scripts": {
|
|
54
55
|
"build": "tsc -p tsconfig.build.json",
|
|
55
|
-
"test": "tsx --test test/*.test.ts",
|
|
56
|
+
"test": "tsx --test test/oauth/*.test.ts test/mcp/*.test.ts test/adapters/*.test.ts test/auth/*.test.ts",
|
|
56
57
|
"test:e2e": "tsx --test test/e2e/*.test.ts",
|
|
58
|
+
"test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-lines=85 --test-coverage-branches=75 --enable-source-maps test/oauth/*.test.ts test/mcp/*.test.ts test/adapters/*.test.ts test/auth/*.test.ts",
|
|
57
59
|
"typecheck": "tsc --noEmit",
|
|
60
|
+
"lint": "biome check .",
|
|
58
61
|
"prepublishOnly": "npm run build && npm test"
|
|
59
62
|
},
|
|
60
63
|
"engines": {
|
|
61
64
|
"node": ">=20"
|
|
62
65
|
},
|
|
63
66
|
"devDependencies": {
|
|
67
|
+
"@biomejs/biome": "2.5.9",
|
|
64
68
|
"@types/node": "^22.13.10",
|
|
65
69
|
"tsx": "^4.19.3",
|
|
66
70
|
"typescript": "^5.8.2"
|