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.
- package/README.md +70 -620
- package/dist/adapters/node-request.d.ts +53 -0
- package/dist/adapters/node-request.d.ts.map +1 -0
- package/dist/adapters/node-request.js +165 -0
- package/dist/adapters/node-request.js.map +1 -0
- package/dist/adapters/node.d.ts +16 -24
- package/dist/adapters/node.d.ts.map +1 -1
- package/dist/adapters/node.js +69 -88
- package/dist/adapters/node.js.map +1 -1
- package/dist/advanced.d.ts +18 -0
- package/dist/advanced.d.ts.map +1 -0
- package/dist/advanced.js +18 -0
- package/dist/advanced.js.map +1 -0
- package/dist/app-options.d.ts +132 -0
- package/dist/app-options.d.ts.map +1 -0
- package/dist/app-options.js +2 -0
- package/dist/app-options.js.map +1 -0
- package/dist/app.d.ts +2 -66
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +150 -57
- package/dist/app.js.map +1 -1
- package/dist/audit.d.ts +5 -5
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +19 -8
- package/dist/audit.js.map +1 -1
- package/dist/auth/bearer.d.ts +4 -1
- package/dist/auth/bearer.d.ts.map +1 -1
- package/dist/auth/bearer.js +25 -5
- package/dist/auth/bearer.js.map +1 -1
- package/dist/clients.d.ts.map +1 -1
- package/dist/clients.js +3 -6
- package/dist/clients.js.map +1 -1
- package/dist/deprecated.d.ts +42 -0
- package/dist/deprecated.d.ts.map +1 -0
- package/dist/deprecated.js +35 -0
- package/dist/deprecated.js.map +1 -0
- package/dist/http/body.d.ts +69 -0
- package/dist/http/body.d.ts.map +1 -0
- package/dist/http/body.js +165 -0
- package/dist/http/body.js.map +1 -0
- package/dist/http/http.d.ts +42 -0
- package/dist/http/http.d.ts.map +1 -0
- package/dist/http/http.js +100 -0
- package/dist/http/http.js.map +1 -0
- package/dist/http/origins.d.ts +37 -0
- package/dist/http/origins.d.ts.map +1 -0
- package/dist/http/origins.js +87 -0
- package/dist/http/origins.js.map +1 -0
- package/dist/mcp/dispatch.d.ts +7 -0
- package/dist/mcp/dispatch.d.ts.map +1 -0
- package/dist/mcp/dispatch.js +99 -0
- package/dist/mcp/dispatch.js.map +1 -0
- package/dist/{jsonrpc.d.ts → mcp/jsonrpc.d.ts} +10 -0
- package/dist/mcp/jsonrpc.d.ts.map +1 -0
- package/dist/mcp/jsonrpc.js +88 -0
- package/dist/mcp/jsonrpc.js.map +1 -0
- package/dist/mcp/methods.d.ts +133 -0
- package/dist/mcp/methods.d.ts.map +1 -0
- package/dist/mcp/methods.js +84 -0
- package/dist/mcp/methods.js.map +1 -0
- package/dist/mcp/protocol.d.ts +16 -0
- package/dist/mcp/protocol.d.ts.map +1 -0
- package/dist/mcp/protocol.js +22 -0
- package/dist/mcp/protocol.js.map +1 -0
- package/dist/mcp/registry.d.ts +58 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +81 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/rpc-handlers.d.ts +11 -0
- package/dist/mcp/rpc-handlers.d.ts.map +1 -0
- package/dist/mcp/rpc-handlers.js +133 -0
- package/dist/mcp/rpc-handlers.js.map +1 -0
- package/dist/{tools.d.ts → mcp/tools.d.ts} +30 -2
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +166 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/{validate.d.ts → mcp/validate.d.ts} +24 -12
- package/dist/mcp/validate.d.ts.map +1 -0
- package/dist/mcp/validate.js +186 -0
- package/dist/mcp/validate.js.map +1 -0
- package/dist/oauth/audit.d.ts +20 -0
- package/dist/oauth/audit.d.ts.map +1 -0
- package/dist/oauth/audit.js +29 -0
- package/dist/oauth/audit.js.map +1 -0
- package/dist/oauth/cimd/cimd-parse.d.ts +4 -0
- package/dist/oauth/cimd/cimd-parse.d.ts.map +1 -0
- package/dist/oauth/cimd/cimd-parse.js +70 -0
- package/dist/oauth/cimd/cimd-parse.js.map +1 -0
- package/dist/oauth/cimd/cimd-ssrf.d.ts +8 -0
- package/dist/oauth/cimd/cimd-ssrf.d.ts.map +1 -0
- package/dist/oauth/cimd/cimd-ssrf.js +193 -0
- package/dist/oauth/cimd/cimd-ssrf.js.map +1 -0
- package/dist/oauth/cimd/cimd.d.ts +36 -0
- package/dist/oauth/cimd/cimd.d.ts.map +1 -0
- package/dist/oauth/cimd/cimd.js +132 -0
- package/dist/oauth/cimd/cimd.js.map +1 -0
- package/dist/oauth/cimd/types.d.ts +12 -0
- package/dist/oauth/cimd/types.d.ts.map +1 -0
- package/dist/oauth/cimd/types.js +3 -0
- package/dist/oauth/cimd/types.js.map +1 -0
- package/dist/oauth/config.d.ts +51 -0
- package/dist/oauth/config.d.ts.map +1 -0
- package/dist/oauth/config.js +108 -0
- package/dist/oauth/config.js.map +1 -0
- package/dist/oauth/constants.d.ts +15 -0
- package/dist/oauth/constants.d.ts.map +1 -1
- package/dist/oauth/constants.js +15 -0
- package/dist/oauth/constants.js.map +1 -1
- package/dist/oauth/{base64url.d.ts → crypto/base64url.d.ts} +1 -2
- package/dist/oauth/crypto/base64url.d.ts.map +1 -0
- package/dist/oauth/{base64url.js → crypto/base64url.js} +1 -5
- package/dist/oauth/crypto/base64url.js.map +1 -0
- package/dist/oauth/crypto/clientauth.d.ts +13 -0
- package/dist/oauth/crypto/clientauth.d.ts.map +1 -0
- package/dist/oauth/crypto/clientauth.js +195 -0
- package/dist/oauth/crypto/clientauth.js.map +1 -0
- package/dist/oauth/crypto/codes.d.ts +65 -0
- package/dist/oauth/crypto/codes.d.ts.map +1 -0
- package/dist/oauth/crypto/codes.js +121 -0
- package/dist/oauth/crypto/codes.js.map +1 -0
- package/dist/oauth/crypto/hkdf.d.ts +13 -0
- package/dist/oauth/crypto/hkdf.d.ts.map +1 -0
- package/dist/oauth/crypto/hkdf.js +30 -0
- package/dist/oauth/crypto/hkdf.js.map +1 -0
- package/dist/oauth/crypto/keycache.d.ts +19 -0
- package/dist/oauth/crypto/keycache.d.ts.map +1 -0
- package/dist/oauth/crypto/keycache.js +44 -0
- package/dist/oauth/crypto/keycache.js.map +1 -0
- package/dist/oauth/crypto/pkce.d.ts.map +1 -0
- package/dist/oauth/{pkce.js → crypto/pkce.js} +1 -1
- package/dist/oauth/crypto/pkce.js.map +1 -0
- package/dist/oauth/crypto/sealed.d.ts +26 -0
- package/dist/oauth/crypto/sealed.d.ts.map +1 -0
- package/dist/oauth/crypto/sealed.js +65 -0
- package/dist/oauth/crypto/sealed.js.map +1 -0
- package/dist/oauth/crypto/secrethash.d.ts +58 -0
- package/dist/oauth/crypto/secrethash.d.ts.map +1 -0
- package/dist/oauth/crypto/secrethash.js +82 -0
- package/dist/oauth/crypto/secrethash.js.map +1 -0
- package/dist/oauth/deprecated.d.ts +52 -0
- package/dist/oauth/deprecated.d.ts.map +1 -0
- package/dist/oauth/deprecated.js +49 -0
- package/dist/oauth/deprecated.js.map +1 -0
- package/dist/oauth/endpoints/authorize.d.ts +3 -0
- package/dist/oauth/endpoints/authorize.d.ts.map +1 -0
- package/dist/oauth/endpoints/authorize.js +209 -0
- package/dist/oauth/endpoints/authorize.js.map +1 -0
- package/dist/oauth/endpoints/callback.d.ts +20 -0
- package/dist/oauth/endpoints/callback.d.ts.map +1 -0
- package/dist/oauth/endpoints/callback.js +31 -0
- package/dist/oauth/endpoints/callback.js.map +1 -0
- package/dist/oauth/endpoints/consent-page.d.ts +5 -0
- package/dist/oauth/endpoints/consent-page.d.ts.map +1 -0
- package/dist/oauth/endpoints/consent-page.js +63 -0
- package/dist/oauth/endpoints/consent-page.js.map +1 -0
- package/dist/oauth/endpoints/consent.d.ts +29 -0
- package/dist/oauth/endpoints/consent.d.ts.map +1 -0
- package/dist/oauth/endpoints/consent.js +98 -0
- package/dist/oauth/endpoints/consent.js.map +1 -0
- package/dist/oauth/{metadata.d.ts → endpoints/metadata.d.ts} +14 -8
- package/dist/oauth/endpoints/metadata.d.ts.map +1 -0
- package/dist/oauth/{metadata.js → endpoints/metadata.js} +10 -4
- package/dist/oauth/endpoints/metadata.js.map +1 -0
- package/dist/oauth/endpoints/register.d.ts +10 -0
- package/dist/oauth/endpoints/register.d.ts.map +1 -0
- package/dist/oauth/endpoints/register.js +96 -0
- package/dist/oauth/endpoints/register.js.map +1 -0
- package/dist/oauth/{revoke.d.ts → endpoints/revoke.d.ts} +1 -1
- package/dist/oauth/endpoints/revoke.d.ts.map +1 -0
- package/dist/oauth/{revoke.js → endpoints/revoke.js} +11 -9
- package/dist/oauth/endpoints/revoke.js.map +1 -0
- package/dist/oauth/endpoints/token.d.ts +3 -0
- package/dist/oauth/endpoints/token.d.ts.map +1 -0
- package/dist/oauth/{token.js → endpoints/token.js} +33 -47
- package/dist/oauth/endpoints/token.js.map +1 -0
- package/dist/oauth/{wellknown.d.ts → endpoints/wellknown.d.ts} +1 -1
- package/dist/oauth/endpoints/wellknown.d.ts.map +1 -0
- package/dist/oauth/{wellknown.js → endpoints/wellknown.js} +7 -7
- package/dist/oauth/endpoints/wellknown.js.map +1 -0
- package/dist/oauth/{redirect.d.ts → policy/redirect.d.ts} +6 -1
- package/dist/oauth/policy/redirect.d.ts.map +1 -0
- package/dist/oauth/policy/redirect.js +39 -0
- package/dist/oauth/policy/redirect.js.map +1 -0
- package/dist/oauth/policy/reqbody.d.ts +18 -0
- package/dist/oauth/policy/reqbody.d.ts.map +1 -0
- package/dist/oauth/policy/reqbody.js +68 -0
- package/dist/oauth/policy/reqbody.js.map +1 -0
- package/dist/oauth/policy/resource.d.ts +37 -0
- package/dist/oauth/policy/resource.d.ts.map +1 -0
- package/dist/oauth/policy/resource.js +74 -0
- package/dist/oauth/policy/resource.js.map +1 -0
- package/dist/oauth/policy/scope.d.ts +20 -0
- package/dist/oauth/policy/scope.d.ts.map +1 -0
- package/dist/oauth/policy/scope.js +35 -0
- package/dist/oauth/policy/scope.js.map +1 -0
- package/dist/oauth/router.d.ts +12 -12
- package/dist/oauth/router.d.ts.map +1 -1
- package/dist/oauth/router.js +105 -87
- package/dist/oauth/router.js.map +1 -1
- package/dist/oauth/types.d.ts +177 -26
- package/dist/oauth/types.d.ts.map +1 -1
- package/dist/oauth/types.js +1 -27
- package/dist/oauth/types.js.map +1 -1
- package/dist/server.d.ts +14 -13
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -9
- package/dist/server.js.map +1 -1
- package/dist/util/defined.d.ts +11 -0
- package/dist/util/defined.d.ts.map +1 -0
- package/dist/util/defined.js +15 -0
- package/dist/util/defined.js.map +1 -0
- package/dist/util/json.d.ts +3 -0
- package/dist/util/json.d.ts.map +1 -0
- package/dist/util/json.js +3 -0
- package/dist/util/json.js.map +1 -0
- package/docs/ROADMAP.md +283 -0
- package/docs/compatibility.md +84 -0
- package/docs/diagrams/mcp-trellis-architecture.html +305 -0
- package/docs/guide.md +441 -0
- package/docs/reference.md +210 -0
- package/docs/security.md +112 -0
- package/docs/troubleshooting.md +19 -0
- package/package.json +24 -8
- package/dist/adapters/origins.d.ts +0 -19
- package/dist/adapters/origins.d.ts.map +0 -1
- package/dist/adapters/origins.js +0 -55
- package/dist/adapters/origins.js.map +0 -1
- package/dist/dispatch.d.ts +0 -7
- package/dist/dispatch.d.ts.map +0 -1
- package/dist/dispatch.js +0 -79
- package/dist/dispatch.js.map +0 -1
- package/dist/http.d.ts +0 -17
- package/dist/http.d.ts.map +0 -1
- package/dist/http.js +0 -38
- package/dist/http.js.map +0 -1
- package/dist/jsonrpc.d.ts.map +0 -1
- package/dist/jsonrpc.js +0 -17
- package/dist/jsonrpc.js.map +0 -1
- package/dist/methods.d.ts +0 -61
- package/dist/methods.d.ts.map +0 -1
- package/dist/methods.js +0 -114
- package/dist/methods.js.map +0 -1
- package/dist/oauth/authorize.d.ts +0 -3
- package/dist/oauth/authorize.d.ts.map +0 -1
- package/dist/oauth/authorize.js +0 -109
- package/dist/oauth/authorize.js.map +0 -1
- package/dist/oauth/base64url.d.ts.map +0 -1
- package/dist/oauth/base64url.js.map +0 -1
- package/dist/oauth/clientauth.d.ts +0 -19
- package/dist/oauth/clientauth.d.ts.map +0 -1
- package/dist/oauth/clientauth.js +0 -76
- package/dist/oauth/clientauth.js.map +0 -1
- package/dist/oauth/codes.d.ts +0 -30
- package/dist/oauth/codes.d.ts.map +0 -1
- package/dist/oauth/codes.js +0 -93
- package/dist/oauth/codes.js.map +0 -1
- package/dist/oauth/metadata.d.ts.map +0 -1
- package/dist/oauth/metadata.js.map +0 -1
- package/dist/oauth/pkce.d.ts.map +0 -1
- package/dist/oauth/pkce.js.map +0 -1
- package/dist/oauth/redirect.d.ts.map +0 -1
- package/dist/oauth/redirect.js +0 -26
- package/dist/oauth/redirect.js.map +0 -1
- package/dist/oauth/resource.d.ts +0 -7
- package/dist/oauth/resource.d.ts.map +0 -1
- package/dist/oauth/resource.js +0 -44
- package/dist/oauth/resource.js.map +0 -1
- package/dist/oauth/revoke.d.ts.map +0 -1
- package/dist/oauth/revoke.js.map +0 -1
- package/dist/oauth/scope.d.ts +0 -11
- package/dist/oauth/scope.d.ts.map +0 -1
- package/dist/oauth/scope.js +0 -24
- package/dist/oauth/scope.js.map +0 -1
- package/dist/oauth/token.d.ts +0 -3
- package/dist/oauth/token.d.ts.map +0 -1
- package/dist/oauth/token.js.map +0 -1
- package/dist/oauth/wellknown.d.ts.map +0 -1
- package/dist/oauth/wellknown.js.map +0 -1
- package/dist/protocol.d.ts +0 -4
- package/dist/protocol.d.ts.map +0 -1
- package/dist/protocol.js +0 -14
- package/dist/protocol.js.map +0 -1
- package/dist/registry.d.ts +0 -37
- package/dist/registry.d.ts.map +0 -1
- package/dist/registry.js +0 -69
- package/dist/registry.js.map +0 -1
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -97
- package/dist/tools.js.map +0 -1
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -130
- package/dist/validate.js.map +0 -1
- /package/dist/oauth/{pkce.d.ts → crypto/pkce.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,663 +1,117 @@
|
|
|
1
1
|
# mcp-trellis
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](LICENSE)
|
|
5
|
-
|
|
6
|
-
**Host-agnostic MCP + OAuth ports** — you bring the runtime and IdP; the library owns the connector protocol.
|
|
7
|
-
|
|
8
|
-
Web-standard `Request` → `Response`. Same handler on Cloudflare Workers, Next.js App Router, Deno, Bun, and Node HTTP via `mcp-trellis/node`. Zero runtime dependencies.
|
|
9
|
-
|
|
10
|
-
## Why mcp-trellis
|
|
11
|
-
|
|
12
|
-
Pick this when you want **the whole connector stack in one package** — the MCP
|
|
13
|
-
handler *and* a real OAuth 2.1 authorization server — with no database to run
|
|
14
|
-
and no vendor to sign up with:
|
|
3
|
+
**Existing SaaS → authenticated AI connector SDK.**
|
|
15
4
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
| MCP handler | ✅ | ✅ | ❌ | ❌ | ❌ |
|
|
19
|
-
| OAuth 2.1 authorization server | ✅ | ❌ bring your own | ✅ | ✅ | ✅ |
|
|
20
|
-
| Runtime | any Web-standard | any Web-standard | Workers only | Node | — |
|
|
21
|
-
| Database | none | — | KV (optional) | **required** — Prisma/Drizzle, with migrations | — |
|
|
22
|
-
| Runtime dependencies | **zero** | several | several | several | — |
|
|
23
|
-
| Self-hosted | ✅ | ✅ | ✅ | ✅ | ❌ SaaS |
|
|
24
|
-
| Named connector profiles (Claude / Gemini / Codex), enforced not just advertised | ✅ | ❌ | ❌ | ❌ | ❌ |
|
|
5
|
+
Claude, ChatGPT and Gemini against the login and data you already have. No new
|
|
6
|
+
database. No auth vendor. One TypeScript package.
|
|
25
7
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
than operate one. Prefer mcp-trellis when you want the protocol layer and the
|
|
31
|
-
AS in one package, with **nothing to provision** — you still own login, token
|
|
32
|
-
minting, and storage; the library owns the protocol.
|
|
8
|
+
mcp-trellis is the **vendor-neutral embedded BYO-auth** path: MCP handler and
|
|
9
|
+
self-hosted OAuth AS in one npm install. Contrast composition stacks such as
|
|
10
|
+
**xmcp + Scalekit** (or WorkOS / Descope connectors) that split the MCP runtime
|
|
11
|
+
and the identity product across 2–3 packages.
|
|
33
12
|
|
|
34
|
-
|
|
13
|
+
Your app already knows who the user is. Connecting an AI client still means
|
|
14
|
+
wiring OAuth discovery, registration, consent, PKCE and token exchange.
|
|
15
|
+
**mcp-trellis handles that protocol work**, while your app keeps login, token
|
|
16
|
+
verification and data permissions.
|
|
35
17
|
|
|
36
|
-
|
|
37
|
-
|
|
18
|
+
```mermaid
|
|
19
|
+
flowchart LR
|
|
20
|
+
A[Existing app: login + user data] --> B[mcp-trellis: OAuth + MCP]
|
|
21
|
+
B --> C[AI client: user-scoped tools]
|
|
22
|
+
```
|
|
38
23
|
|
|
39
|
-
##
|
|
24
|
+
## The 30-second example
|
|
40
25
|
|
|
41
26
|
```bash
|
|
42
27
|
npm install mcp-trellis
|
|
43
28
|
```
|
|
44
29
|
|
|
45
|
-
## 60-second server
|
|
46
|
-
|
|
47
|
-
One call mounts the MCP endpoint, the OAuth authorization server, and both
|
|
48
|
-
discovery documents — and routes between them:
|
|
49
|
-
|
|
50
30
|
```ts
|
|
51
31
|
import { createMcpApp } from "mcp-trellis";
|
|
32
|
+
import { existingAuth, projectsForUser } from "./your-app.js";
|
|
52
33
|
|
|
53
|
-
const
|
|
54
|
-
serverInfo: { name: "
|
|
34
|
+
export const mcp = createMcpApp<{ userId: string }>({
|
|
35
|
+
serverInfo: { name: "my-saas", version: "1.0.0" },
|
|
55
36
|
clients: ["claude"],
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
properties: { text: { type: "string" } },
|
|
63
|
-
required: ["text"],
|
|
64
|
-
},
|
|
65
|
-
scope: "mcp",
|
|
66
|
-
handler: (_ctx, args) => String(args.text ?? ""),
|
|
67
|
-
},
|
|
68
|
-
],
|
|
69
|
-
auth: {
|
|
70
|
-
codeSecret: process.env.OAUTH_CODE_SECRET!,
|
|
71
|
-
resolveUser: async (req) => getSession(req),
|
|
72
|
-
loginUrl: (_req, next) => `/login?next=${encodeURIComponent(next)}`,
|
|
73
|
-
mintAccessToken: async ({ userId, scope, resource }) => ({
|
|
74
|
-
// Embed `resource` as the token audience (RFC 8707).
|
|
75
|
-
accessToken: await issueUserToken(userId, { aud: resource, scope }),
|
|
76
|
-
expiresIn: 3600,
|
|
77
|
-
}),
|
|
78
|
-
verifyToken: async (token) => {
|
|
79
|
-
const claims = await readUserToken(token);
|
|
80
|
-
if (!claims) return null;
|
|
81
|
-
return {
|
|
82
|
-
userId: claims.sub,
|
|
83
|
-
scopes: claims.scope.split(" "),
|
|
84
|
-
audience: claims.aud,
|
|
85
|
-
};
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
export default { fetch: (req: Request) => app.fetch(req) };
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
You return the token's `audience`; **the library rejects tokens minted for a
|
|
94
|
-
different resource** before any tool runs. That check is the one preventing
|
|
95
|
-
confused-deputy attacks, so it is not yours to remember.
|
|
96
|
-
|
|
97
|
-
Smoke-test with `initialize` (public — no Bearer needed):
|
|
98
|
-
|
|
99
|
-
```bash
|
|
100
|
-
curl -s http://127.0.0.1:8787/mcp \
|
|
101
|
-
-H 'content-type: application/json' \
|
|
102
|
-
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}'
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
## Client compatibility
|
|
106
|
-
|
|
107
|
-
Name the connector clients you serve; the AS is configured to match.
|
|
108
|
-
|
|
109
|
-
| Client | Registration | Token endpoint auth | Notes |
|
|
110
|
-
|--------|--------------|---------------------|-------|
|
|
111
|
-
| `claude` | Dynamic (DCR), public + PKCE | `none` | Claude Custom Connectors callback is allowlisted for you |
|
|
112
|
-
| `gemini` | **Pre-registered**, confidential | `client_secret_basic`, `client_secret_post` | Gemini Enterprise is registered with your org's IdP — supply `auth.clientStore` |
|
|
113
|
-
| `codex` | OAuth 2.1 per MCP auth spec, public + PKCE | `none` | ChatGPT / Codex share one connector contract |
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
createMcpApp({
|
|
117
|
-
clients: ["claude", "gemini"],
|
|
118
|
-
auth: {
|
|
119
|
-
// …
|
|
120
|
-
clientStore: {
|
|
121
|
-
get: async (clientId) => lookupClient(clientId),
|
|
122
|
-
verifySecret: async (clientId, presented) =>
|
|
123
|
-
checkSecret(clientId, presented),
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
Naming a pre-registered client without a `clientStore` throws at construction
|
|
130
|
-
rather than failing at the first token exchange. Secret storage and comparison
|
|
131
|
-
stay in your `clientStore` — the library never sees or persists credentials.
|
|
132
|
-
|
|
133
|
-
Naming only pre-registered clients (`clients: ["gemini"]`, no `claude` /
|
|
134
|
-
`codex`) sets `allowUnregisteredClients: false`: `/register` is unmounted
|
|
135
|
-
and dropped from AS metadata, unknown ids are rejected with
|
|
136
|
-
`unauthorized_client` at `authorize` and `invalid_client` at `token` and
|
|
137
|
-
`revoke`. Naming `claude` or `codex` alongside `gemini` reopens dynamic
|
|
138
|
-
registration.
|
|
139
|
-
|
|
140
|
-
## How it fits together
|
|
141
|
-
|
|
142
|
-
What `createMcpApp` wires for you — two pieces, one deciding which requests go
|
|
143
|
-
to the other:
|
|
144
|
-
|
|
145
|
-

|
|
146
|
-
|
|
147
|
-
Typical first-connection path:
|
|
148
|
-
|
|
149
|
-

|
|
150
|
-
|
|
151
|
-
You only touch this routing directly if you skip `createMcpApp` — see
|
|
152
|
-
[Advanced: compose the primitives](#advanced-compose-the-primitives).
|
|
153
|
-
|
|
154
|
-
## Package surface
|
|
155
|
-
|
|
156
|
-
| Import | What you get |
|
|
157
|
-
|--------|----------------|
|
|
158
|
-
| `mcp-trellis` | `createMcpApp`, client profiles, MCP handler, tool registry, bearer helpers, HTTP utils |
|
|
159
|
-
| `mcp-trellis/oauth` | OAuth 2.1 AS router, PKCE, auth codes, client auth, metadata |
|
|
160
|
-
| `mcp-trellis/node` | `asNodeHandler` + `resolveOrigin` for Node `(req, res)` |
|
|
161
|
-
|
|
162
|
-
`createMcpApp` is the batteries-included layer. `createMcpHandler` and
|
|
163
|
-
`createOAuthRouter` remain exported and unchanged — reach for them when you want
|
|
164
|
-
to own the routing or mount the two halves separately.
|
|
165
|
-
|
|
166
|
-
### Default OAuth routes
|
|
167
|
-
|
|
168
|
-
With defaults `resourcePath: "/mcp"` and `oauthPath: "/mcp/oauth"`:
|
|
169
|
-
|
|
170
|
-
| Path | Purpose |
|
|
171
|
-
|------|---------|
|
|
172
|
-
| `/.well-known/oauth-protected-resource` (+ `/mcp`) | Protected resource metadata |
|
|
173
|
-
| `/.well-known/oauth-authorization-server` (+ `/mcp`) | Authorization server metadata |
|
|
174
|
-
| `/mcp/oauth/register` | Dynamic client registration (unmounted when DCR is off) |
|
|
175
|
-
| `/mcp/oauth/authorize` | Authorization endpoint |
|
|
176
|
-
| `/mcp/oauth/token` | Token endpoint |
|
|
177
|
-
| `/mcp/oauth/revoke` | Token revocation (RFC 7009; mounted only when `revokeToken` is set) |
|
|
178
|
-
|
|
179
|
-
## Recipes
|
|
180
|
-
|
|
181
|
-
### Cloudflare Worker, Next.js, Deno, Bun
|
|
182
|
-
|
|
183
|
-
`createMcpApp` returns a plain `fetch`, so the same object mounts everywhere:
|
|
184
|
-
|
|
185
|
-
```ts
|
|
186
|
-
// Worker
|
|
187
|
-
export default { fetch: (req: Request, env: Env) => buildApp(env).fetch(req) };
|
|
188
|
-
|
|
189
|
-
// Next.js App Router — app/[[...mcp]]/route.ts
|
|
190
|
-
export const GET = (req: Request) => app.fetch(req);
|
|
191
|
-
export const POST = (req: Request) => app.fetch(req);
|
|
192
|
-
export const OPTIONS = (req: Request) => app.fetch(req);
|
|
193
|
-
|
|
194
|
-
// Node / Express / Cloud Run
|
|
195
|
-
import { asNodeHandler } from "mcp-trellis/node";
|
|
196
|
-
export const mcp = asNodeHandler(app, { origin: process.env.MCP_PUBLIC_ORIGIN! });
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
The Node bridge reads the request stream when `req.body` is absent, so raw
|
|
200
|
-
`http.createServer` works without a body-parser. Runnable recipe:
|
|
201
|
-
[`examples/http-server.ts`](examples/http-server.ts).
|
|
202
|
-
|
|
203
|
-
## Advanced: compose the primitives
|
|
204
|
-
|
|
205
|
-
Skip `createMcpApp` when you want to own the routing. Same behavior, more wiring:
|
|
206
|
-
|
|
207
|
-
```ts
|
|
208
|
-
import { createMcpHandler, createToolRegistry, parseBearer } from "mcp-trellis";
|
|
209
|
-
import { createOAuthRouter } from "mcp-trellis/oauth";
|
|
210
|
-
|
|
211
|
-
type Ctx = { userId: string };
|
|
212
|
-
type Env = { OAUTH_CODE_SECRET: string };
|
|
213
|
-
|
|
214
|
-
const registry = createToolRegistry<Ctx>([
|
|
215
|
-
{
|
|
216
|
-
name: "ping_db",
|
|
217
|
-
description: "Health check",
|
|
37
|
+
// existingAuth must include codeStore (required since 2.0) plus the usual ports.
|
|
38
|
+
auth: existingAuth,
|
|
39
|
+
context: (_request, principal) => ({ userId: principal!.id }),
|
|
40
|
+
tools: [{
|
|
41
|
+
name: "list_my_projects",
|
|
42
|
+
description: "List the signed-in user's projects",
|
|
218
43
|
inputSchema: { type: "object", properties: {} },
|
|
219
|
-
scope: "
|
|
220
|
-
handler: async () =>
|
|
221
|
-
},
|
|
222
|
-
]);
|
|
223
|
-
|
|
224
|
-
const handler = createMcpHandler<Ctx>({
|
|
225
|
-
registry,
|
|
226
|
-
serverInfo: { name: "my-app", version: "1.0.0" },
|
|
227
|
-
instructions: "Tools available to authenticated clients.",
|
|
228
|
-
wwwAuthenticate: (req) => ({
|
|
229
|
-
realm: "my-app",
|
|
230
|
-
resourceMetadataUrl: `${new URL(req.url).origin}/.well-known/oauth-protected-resource/mcp`,
|
|
231
|
-
}),
|
|
232
|
-
ports: {
|
|
233
|
-
authenticate: async (req) => {
|
|
234
|
-
const token = parseBearer(req.headers.get("authorization"));
|
|
235
|
-
if (!token) return null;
|
|
236
|
-
// Reject tokens whose aud ≠ this server's canonical resource.
|
|
237
|
-
return { id: "user-1", scopes: ["read"] };
|
|
238
|
-
},
|
|
239
|
-
context: async (_req, principal) => ({
|
|
240
|
-
userId: principal?.id ?? "",
|
|
241
|
-
}),
|
|
242
|
-
},
|
|
44
|
+
scope: "mcp",
|
|
45
|
+
handler: async ({ userId }) => JSON.stringify(await projectsForUser(userId)),
|
|
46
|
+
}],
|
|
243
47
|
});
|
|
244
|
-
|
|
245
|
-
export default {
|
|
246
|
-
async fetch(request: Request, env: Env): Promise<Response> {
|
|
247
|
-
const oauth = createOAuthRouter({
|
|
248
|
-
ports: {
|
|
249
|
-
codeSecret: env.OAUTH_CODE_SECRET,
|
|
250
|
-
resolveUser: async () => ({ id: "user-1" }),
|
|
251
|
-
loginUrl: (req, next) =>
|
|
252
|
-
`${new URL(req.url).origin}/login?next=${encodeURIComponent(next)}`,
|
|
253
|
-
mintAccessToken: async ({ userId, resource }) => ({
|
|
254
|
-
// Embed `resource` as the token audience (RFC 8707).
|
|
255
|
-
accessToken: await issueUserToken(userId, { aud: resource }),
|
|
256
|
-
expiresIn: 3600,
|
|
257
|
-
scope: "mcp",
|
|
258
|
-
}),
|
|
259
|
-
},
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
const oauthResponse = await oauth.tryHandle(request);
|
|
263
|
-
if (oauthResponse) return oauthResponse;
|
|
264
|
-
if (new URL(request.url).pathname === "/mcp") return handler.fetch(request);
|
|
265
|
-
return new Response("Not found", { status: 404 });
|
|
266
|
-
},
|
|
267
|
-
};
|
|
268
|
-
```
|
|
269
|
-
|
|
270
|
-
### Next.js App Router
|
|
271
|
-
|
|
272
|
-
Delegate Web `Request` straight through — same handler as above:
|
|
273
|
-
|
|
274
|
-
```ts
|
|
275
|
-
// app/mcp/route.ts
|
|
276
|
-
import { handler, oauth } from "@/lib/mcp"; // your shared setup
|
|
277
|
-
|
|
278
|
-
export async function GET(req: Request) {
|
|
279
|
-
const oauthResponse = await oauth.tryHandle(req);
|
|
280
|
-
if (oauthResponse) return oauthResponse;
|
|
281
|
-
return handler.fetch(req);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export async function POST(req: Request) {
|
|
285
|
-
const oauthResponse = await oauth.tryHandle(req);
|
|
286
|
-
if (oauthResponse) return oauthResponse;
|
|
287
|
-
return handler.fetch(req);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export async function OPTIONS() {
|
|
291
|
-
return handler.fetch(new Request("https://local/mcp", { method: "OPTIONS" }));
|
|
292
|
-
}
|
|
293
48
|
```
|
|
294
49
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
### Node HTTP `(req, res)`
|
|
298
|
-
|
|
299
|
-
Express, Cloud Functions, Cloud Run, `http.createServer` — bridge with
|
|
300
|
-
`asNodeHandler`. Runnable recipe: [`examples/http-server.ts`](examples/http-server.ts)
|
|
301
|
-
(`npx tsx examples/http-server.ts`).
|
|
50
|
+
This shows the integration shape: `your-app.js` is your application's auth/data adapter, not a supplied module. Production needs a shared `codeStore` (see [examples/stores.ts](examples/stores.ts)). **[Run the complete Node demo](examples/saas-demo/README.md)** for working login, token handling and two users with different projects. Node ≥20 is required.
|
|
302
51
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-

|
|
52
|
+
[](https://www.npmjs.com/package/mcp-trellis)
|
|
53
|
+
[](https://github.com/amir1824/mcp-trellis/actions/workflows/ci.yml)
|
|
54
|
+
[](LICENSE)
|
|
308
55
|
|
|
309
|
-
|
|
56
|
+
## When should I use this?
|
|
310
57
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
| `resolveUser`, `loginUrl`, `mintAccessToken`, `codeSecret` | Same as the OAuth ports below |
|
|
315
|
-
| `clientStore?` | Pre-registered clients; required for confidential clients like Gemini |
|
|
316
|
-
| `refreshAccessToken?` | If set, metadata advertises `refresh_token` |
|
|
317
|
-
| `revokeToken?` | RFC 7009; presence mounts `/revoke` and advertises `revocation_endpoint` |
|
|
318
|
-
| `context?`, `audit?` | Same as the MCP ports below; `context` defaults to an empty object. Receives `principal.claims` when `verifyToken` set them |
|
|
58
|
+
- Your TypeScript app already has login and user data, and you want to expose user-scoped tools through remote MCP.
|
|
59
|
+
- You want the MCP handler and OAuth authorization endpoints in one package, with zero runtime dependencies.
|
|
60
|
+
- You want to reuse your runtime and existing storage. The library does not require a new database; production deployments still need appropriate replay, token and session storage.
|
|
319
61
|
|
|
320
|
-
|
|
62
|
+
## When should I not use this?
|
|
321
63
|
|
|
322
|
-
|
|
323
|
-
|
|
64
|
+
- You need a managed identity provider, a user database, or a login system built for you.
|
|
65
|
+
- You already have an MCP server and authorization server that meet your needs.
|
|
66
|
+
- You need protocol features or client combinations absent from the [verified support table](docs/compatibility.md).
|
|
67
|
+
- You only need a local stdio tool with no remote authorization flow.
|
|
324
68
|
|
|
325
|
-
|
|
326
|
-
|------|------|
|
|
327
|
-
| `authenticate(req, method, tool?)` | Return `{ id, scopes, claims? }`, or `null` → 401 + `WWW-Authenticate`. **Must reject tokens whose audience is not this server’s canonical resource** (see Threat model) |
|
|
328
|
-
| `context(req, principal)` | Build per-request ctx for tools (DB, env, …). Use `principal?.claims` for tenant / plan / role without re-decoding the bearer |
|
|
329
|
-
| `audit?(entry)` | Opt-in **metrics hook**: pass any function to receive tool results **and** every denial (bad token, missing scope, query-string token). `entry.method` is `""` for transport-level denials made before parsing. Throwing from this port never fails the request. Omit it and the library stays silent |
|
|
69
|
+
## Run a real tool
|
|
330
70
|
|
|
331
|
-
|
|
332
|
-
(DB, APM, admin UI). The library never writes to stdout or a store on its
|
|
333
|
-
own. `consoleAudit` is only a convenience for local / small deploys;
|
|
334
|
-
[`examples/audit-store.ts`](examples/audit-store.ts) shows a ring buffer and
|
|
335
|
-
a DB-shaped sketch:
|
|
71
|
+
The [Project desk demo](examples/saas-demo/README.md) proves the local flow: app login → OAuth consent → access token → `list_my_projects`. Alice sees her two projects; Bob sees his own. It uses fictional data and an independent app session.
|
|
336
72
|
|
|
337
|
-
|
|
338
|
-
import { createMcpApp, consoleAudit } from "mcp-trellis";
|
|
73
|
+
**Live Claude / ChatGPT recordings are still pending.** Automated OAuth tests are not evidence that a current vendor client has connected successfully.
|
|
339
74
|
|
|
340
|
-
|
|
75
|
+
## Clients and compatibility
|
|
341
76
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
void metrics.record(entry);
|
|
348
|
-
},
|
|
349
|
-
});
|
|
350
|
-
```
|
|
77
|
+
| Profile | Implemented server flow | Live client evidence |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Claude (`claude`) | Dynamic registration, public client, PKCE | Pending |
|
|
80
|
+
| ChatGPT / Codex (`codex`) | Public client, PKCE; configure exact hosted callback as needed | Pending for each product |
|
|
81
|
+
| Gemini Enterprise (`gemini`) | Pre-registered client; secret basic/post; requires `clientStore` | Pending; does not imply every Gemini product |
|
|
351
82
|
|
|
352
|
-
|
|
353
|
-
`consoleAudit`; everything else to `console.log`. Entries never carry raw
|
|
354
|
-
exceptions or stack traces, so console output is safe. For anything beyond
|
|
355
|
-
stdout, write your own `audit` function — that is the intended production path.
|
|
83
|
+
Protocol versions implemented: `2024-11-05`, `2025-03-26`, `2025-06-18`; default `2025-06-18`. See [test evidence and verification checklist](docs/compatibility.md), [client configuration](docs/guide.md#clients) and [troubleshooting](docs/troubleshooting.md).
|
|
356
84
|
|
|
357
|
-
|
|
85
|
+
## Integrate with your app
|
|
358
86
|
|
|
359
|
-
|
|
87
|
+
Implement `resolveUser`, `loginUrl`, `mintAccessToken` and `verifyToken` using your app's existing auth. Mint tokens for the requested MCP resource and return their verified `audience`; the library rejects an audience mismatch before executing tools. A logged-in user still sees an OAuth consent screen. Your data layer must enforce ownership and tenant permissions. Browser callers that send an `Origin` header need `allowedRequestOrigins` (fail-closed by default) — see [security.md](docs/security.md).
|
|
360
88
|
|
|
361
|
-
|
|
89
|
+
[Node demo](examples/saas-demo/README.md) · [Host recipes](docs/guide.md) · [Security responsibilities](docs/security.md) · [npm](https://www.npmjs.com/package/mcp-trellis)
|
|
362
90
|
|
|
363
|
-
|
|
364
|
-
|------|------|
|
|
365
|
-
| `codeSecret` | HMAC secret for signed auth codes (string or `(req) => string`) |
|
|
366
|
-
| `resolveUser` | Consenting user, or `null` → redirect to `loginUrl` |
|
|
367
|
-
| `loginUrl` | Where unauthenticated authorize requests go |
|
|
368
|
-
| `mintAccessToken` | Issue a **user-bound**, **audience-bound** access token (`resource` is the RFC 8707 URI) — never a shared god token |
|
|
369
|
-
| `refreshAccessToken?` | If set, metadata advertises `refresh_token`; also receives `resource` |
|
|
370
|
-
| `revokeToken?` | If set, mounts `/revoke` and advertises `revocation_endpoint`. Well-formed authenticated revoke → 200 even if the token is unknown; `invalid_request` 400 and `invalid_client` 401 still apply |
|
|
371
|
-
| `codeStore?` | Shared single-use jti store for multi-instance (`consume(jti, expMs)`); pruning in-memory default otherwise |
|
|
372
|
-
| `clientStore?` | Pre-registered clients: `get(clientId)` returns registered redirect URIs and auth method; `verifySecret(clientId, presented)` authenticates confidential clients. Credentials never enter the library |
|
|
91
|
+
## Architecture
|
|
373
92
|
|
|
374
|
-
|
|
93
|
+
`createMcpApp` wires MCP and OAuth and routes between them:
|
|
375
94
|
|
|
376
|
-
|
|
95
|
+

|
|
377
96
|
|
|
378
|
-
|
|
97
|
+

|
|
379
98
|
|
|
380
|
-
|
|
99
|
+
Host recipes, ports, tools, and multi-tenant: [docs/guide.md](docs/guide.md).
|
|
381
100
|
|
|
382
|
-
|
|
101
|
+
## Docs
|
|
383
102
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
103
|
+
| Doc | Contents |
|
|
104
|
+
|-----|----------|
|
|
105
|
+
| [docs/guide.md](docs/guide.md) | Architecture, clients, recipes, ports, tools, multi-tenant |
|
|
106
|
+
| [docs/reference.md](docs/reference.md) | Routes, methods, status codes, options, exports |
|
|
107
|
+
| [docs/security.md](docs/security.md) | Protocol promises, threat model, not in scope |
|
|
108
|
+
| [docs/ROADMAP.md](docs/ROADMAP.md) | What's next |
|
|
388
109
|
|
|
389
|
-
|
|
390
|
-
- Unknown tools and thrown errors become `isError: true` (not transport errors)
|
|
391
|
-
- Thrown exceptions are **redacted** by default (`"Tool execution failed"`); pass `onToolError` to map them. An `onToolError` that itself throws or returns a non-string falls back to the redacted default
|
|
392
|
-
- Duplicate tool names throw at registry construction
|
|
393
|
-
- Turn `validateArgs: true` once clients send schema-valid args. The evaluated
|
|
394
|
-
subset is `type`, `enum`, `required`, `properties`, `items`, `minimum`,
|
|
395
|
-
`maximum`. Pure metadata (`description`, `title`, `$schema`, `$id`,
|
|
396
|
-
`$comment`, `default`, `examples`, `deprecated`, `readOnly`, `writeOnly`,
|
|
397
|
-
`format`) is allowed and never evaluated. Any other keyword is not
|
|
398
|
-
evaluated — and with `validateArgs: true` `createToolRegistry` throws at
|
|
399
|
-
construction so a schema like `pattern` or `additionalProperties` cannot
|
|
400
|
-
silently look enforced
|
|
401
|
-
|
|
402
|
-
### Wrapping an API as a tool
|
|
403
|
-
|
|
404
|
-
`ToolDef` stays the primitive. `defineTool` and `apiTool` are optional sugar for
|
|
405
|
-
the common case — turning "I have an API" into "I have an MCP tool" without
|
|
406
|
-
hand-rolling arg validation or fetch/error plumbing each time:
|
|
110
|
+
Examples: [`examples/`](examples/) — HTTP server, Worker, multi-tenant, stores, audit.
|
|
407
111
|
|
|
408
|
-
|
|
409
|
-
import { apiTool } from "mcp-trellis";
|
|
410
|
-
|
|
411
|
-
const getWeather = apiTool({
|
|
412
|
-
name: "get_weather",
|
|
413
|
-
description: "Current weather for a city",
|
|
414
|
-
inputSchema: {
|
|
415
|
-
type: "object",
|
|
416
|
-
properties: { city: { type: "string" } },
|
|
417
|
-
required: ["city"],
|
|
418
|
-
},
|
|
419
|
-
// any Standard Schema v1 validator (https://standardschema.dev)
|
|
420
|
-
input: citySchema,
|
|
421
|
-
request: (_ctx, { city }) =>
|
|
422
|
-
`https://api.example.com/weather?city=${encodeURIComponent(city)}`,
|
|
423
|
-
respond: async (res) => {
|
|
424
|
-
const data = await res.json();
|
|
425
|
-
return `${data.tempC}°C, ${data.condition}`;
|
|
426
|
-
},
|
|
427
|
-
});
|
|
428
|
-
```
|
|
112
|
+
## Contributing
|
|
429
113
|
|
|
430
|
-
|
|
431
|
-
clients, and there's no library-agnostic way to derive JSON Schema from an
|
|
432
|
-
arbitrary Standard Schema validator. Keep the two in sync.
|
|
433
|
-
- **`input`** (optional) parses and types `args` before your handler runs — any
|
|
434
|
-
[Standard Schema](https://standardschema.dev)-compliant validator works,
|
|
435
|
-
nothing library-specific. A validation failure returns `isError: true` with
|
|
436
|
-
the issues and never calls your handler.
|
|
437
|
-
- **`apiTool`** builds on `defineTool`: give it `request` (build the outgoing
|
|
438
|
-
call from typed args) and optionally `respond` (shape a 2xx response;
|
|
439
|
-
default is the body as text). Non-2xx becomes `isError: true` automatically.
|
|
440
|
-
`fetch` is overridable — testing, request signing, a custom agent.
|
|
441
|
-
- `defineTool` alone (without `request`/`respond`) is just the typed-args
|
|
442
|
-
layer over a regular `ToolDef` — use it for anything, not only REST calls.
|
|
443
|
-
|
|
444
|
-
## Multi-tenant (SaaS connectors)
|
|
445
|
-
|
|
446
|
-
Resource-per-tenant already works with **zero library change**.
|
|
447
|
-
`canonicalResource(origin, resourcePath)` is derived per request, and the
|
|
448
|
-
audience check in `createMcpApp` rejects a token minted for another origin
|
|
449
|
-
before any tool runs. Deploy `acme.mcp.example.com` and
|
|
450
|
-
`globex.mcp.example.com` as separate Hosts and cross-tenant token reuse is a
|
|
451
|
-
protocol failure, not an application bug.
|
|
452
|
-
|
|
453
|
-
For the in-tool half, derive the tenant from the request Host (or from the
|
|
454
|
-
verified audience after the library check) in your `context` port — that is
|
|
455
|
-
the isolation key. Optional `claims` from `verifyToken` (plan, role, …) are
|
|
456
|
-
handy metadata on `principal`; they are **not** Host→tenant authorization.
|
|
457
|
-
A forged `claims.tenant_id` with a matching audience must not change which
|
|
458
|
-
tenant's data a tool can see.
|
|
459
|
-
|
|
460
|
-
**Host caveat (API requirement).** On Node, multi-tenant needs a Host-derived
|
|
461
|
-
origin (`trustProxy: true` without a fixed `origin`). That path **requires**
|
|
462
|
-
a non-empty `allowedOrigins` at `asNodeHandler` construction — omit it and
|
|
463
|
-
construction throws. Use exact origins and/or `*.example.com` wildcards for
|
|
464
|
-
**https** subdomains; the **resolved** origin must match or the request
|
|
465
|
-
returns **400** before any OAuth or MCP handling. Loud opt-out:
|
|
466
|
-
`allowedOrigins: ["*"]` (admits any Host — only behind a proxy you trust to
|
|
467
|
-
strip client `X-Forwarded-*`). A fixed `origin` absent from a non-empty list
|
|
468
|
-
fails at construction; with a fixed `origin` the allowlist is optional
|
|
469
|
-
defense-in-depth. Absolute-form request targets are rebased onto that
|
|
470
|
-
validated origin, so a spoofed URL line cannot override it. A fixed `origin`
|
|
471
|
-
is otherwise safe but collapses every tenant onto one resource, so it cannot
|
|
472
|
-
do subdomain-per-tenant.
|
|
473
|
-
|
|
474
|
-
On Cloudflare Workers (or any wildcard route), apply the equivalent
|
|
475
|
-
allowlist check on `new URL(req.url).origin` before calling `app.fetch`
|
|
476
|
-
(`isAllowedOrigin` from `mcp-trellis/node`, or the same module the Worker
|
|
477
|
-
example imports).
|
|
478
|
-
|
|
479
|
-
Typechecked sketch: [`examples/multi-tenant.ts`](examples/multi-tenant.ts)
|
|
480
|
-
(needs a Host matching `*.mcp.example.com` — loopback alone is rejected).
|
|
481
|
-
Shared `codeStore` / revocation shapes: [`examples/stores.ts`](examples/stores.ts)
|
|
482
|
-
(atomic `setIfAbsent` for auth codes; Workers KV alone is not enough).
|
|
483
|
-
`audit` metrics sink (ring + DB sketch): [`examples/audit-store.ts`](examples/audit-store.ts).
|
|
484
|
-
Worker mount: [`examples/cloudflare-worker.ts`](examples/cloudflare-worker.ts)
|
|
485
|
-
(typecheck-only in CI — no wrangler).
|
|
486
|
-
|
|
487
|
-
## Reference
|
|
488
|
-
|
|
489
|
-
### Supported MCP methods
|
|
490
|
-
|
|
491
|
-
| Method | Auth | Notes |
|
|
492
|
-
|--------|------|-------|
|
|
493
|
-
| `initialize` | public | Negotiates protocol version; returns `capabilities.tools` |
|
|
494
|
-
| `ping` | public | Empty result |
|
|
495
|
-
| `tools/list` | Bearer | Lists registry entries |
|
|
496
|
-
| `tools/call` | Bearer + scope | Runs the tool; missing scope → 401 |
|
|
497
|
-
| `notifications/*` | public | HTTP 202 empty body |
|
|
498
|
-
|
|
499
|
-
Anything else → JSON-RPC `-32601`. Capabilities advertise **tools only**.
|
|
500
|
-
|
|
501
|
-
### HTTP and JSON-RPC status codes
|
|
502
|
-
|
|
503
|
-
| HTTP | When |
|
|
504
|
-
|------|------|
|
|
505
|
-
| **202** | Notification (empty body) |
|
|
506
|
-
| **400** | Parse error, batch array, invalid request |
|
|
507
|
-
| **401** | Missing/invalid Bearer; includes `WWW-Authenticate` |
|
|
508
|
-
| **405** | Wrong HTTP verb on MCP |
|
|
509
|
-
|
|
510
|
-
| JSON-RPC code | Constant |
|
|
511
|
-
|---------------|----------|
|
|
512
|
-
| `-32700` | `JSONRPC_PARSE_ERROR` |
|
|
513
|
-
| `-32600` | `JSONRPC_INVALID_REQUEST` (batches) |
|
|
514
|
-
| `-32601` | `JSONRPC_METHOD_NOT_FOUND` |
|
|
515
|
-
| `-32602` | `JSONRPC_INVALID_PARAMS` |
|
|
516
|
-
| `-32603` | `JSONRPC_INTERNAL_ERROR` |
|
|
517
|
-
| `-32001` | `JSONRPC_UNAUTHORIZED` |
|
|
518
|
-
|
|
519
|
-
### `createMcpHandler` options
|
|
520
|
-
|
|
521
|
-
| Option | Required | Description |
|
|
522
|
-
|--------|----------|-------------|
|
|
523
|
-
| `registry` | yes | From `createToolRegistry` |
|
|
524
|
-
| `ports` | yes | `authenticate`, `context`, optional `audit` |
|
|
525
|
-
| `serverInfo` | yes | `{ name, version }` |
|
|
526
|
-
| `wwwAuthenticate` | yes | Static or `(req) => …` for RFC 9728 PRM URL |
|
|
527
|
-
| `instructions` | no | Returned on `initialize` |
|
|
528
|
-
| `publicMethods` | no | Default: `initialize`, `ping`, notifications |
|
|
529
|
-
|
|
530
|
-
### `createOAuthRouter` options
|
|
531
|
-
|
|
532
|
-
| Option | Default | Description |
|
|
533
|
-
|--------|---------|-------------|
|
|
534
|
-
| `ports` | — | Required OAuth ports (see above) |
|
|
535
|
-
| `resourcePath` | `"/mcp"` | MCP resource path in PRM |
|
|
536
|
-
| `oauthPath` | `"/mcp/oauth"` | Prefix for authorize / token / register / revoke |
|
|
537
|
-
| `realm` | — | Optional realm string |
|
|
538
|
-
| `scopes` | `["mcp"]` | Advertised in metadata, and the ceiling `authorize` validates against |
|
|
539
|
-
| `tokenEndpointAuthMethods` | `["none"]` | Advertised client auth methods |
|
|
540
|
-
| `allowUnregisteredClients` | `true` | `false` requires `clientStore`, unmounts DCR, and rejects unknown `client_id`s (`unauthorized_client` / `invalid_client`) |
|
|
541
|
-
| `redirect` | see below | Redirect URI allowlist |
|
|
542
|
-
|
|
543
|
-
**`redirect` (`RedirectAllowlistOptions`):**
|
|
544
|
-
|
|
545
|
-
| Field | Default | Description |
|
|
546
|
-
|-------|---------|-------------|
|
|
547
|
-
| `extra` | `[]` | Exact-match redirect URIs |
|
|
548
|
-
| `allowLoopback` | `true` | Allow `http(s)` loopback — `127.0.0.1`, `[::1]`, `localhost` |
|
|
549
|
-
| `allowClaude` | `true` | Allow the Claude.ai Custom Connector callback |
|
|
550
|
-
|
|
551
|
-
Pre-registered clients bypass this allowlist entirely — they are validated
|
|
552
|
-
against their own `redirectUris` from `clientStore`.
|
|
553
|
-
|
|
554
|
-
### `createMcpApp` options
|
|
555
|
-
|
|
556
|
-
| Option | Default | Description |
|
|
557
|
-
|--------|---------|-------------|
|
|
558
|
-
| `serverInfo`, `tools`, `auth` | — | Required |
|
|
559
|
-
| `clients` | `["claude"]` | Connector profiles to serve |
|
|
560
|
-
| `resourcePath` | `"/mcp"` | MCP endpoint path |
|
|
561
|
-
| `oauthPath` | `` `${resourcePath}/oauth` `` | OAuth prefix |
|
|
562
|
-
| `scopes` | `["mcp"]` | Scopes this server grants |
|
|
563
|
-
| `realm` | `serverInfo.name` | `WWW-Authenticate` realm |
|
|
564
|
-
| `extraRedirectUris` | `[]` | Callbacks beyond the client profiles |
|
|
565
|
-
| `allowLoopback` | `true` | Allow loopback redirects |
|
|
566
|
-
| `instructions`, `validateArgs`, `onToolError`, `context`, `audit` | — | Passed through |
|
|
567
|
-
|
|
568
|
-
### Exports
|
|
569
|
-
|
|
570
|
-
<details>
|
|
571
|
-
<summary><code>mcp-trellis</code></summary>
|
|
572
|
-
|
|
573
|
-
- `createMcpApp`, `createMcpHandler`, `createToolRegistry`
|
|
574
|
-
- `consoleAudit` — convenience `audit` sink that logs to the console (pass any function for metrics / DB / APM)
|
|
575
|
-
- `defineTool`, `apiTool` — typed, validated tool authoring on top of `ToolDef`
|
|
576
|
-
- `CLIENT_PROFILES`, `DEFAULT_CLIENTS`, `authMethodsFor`, `redirectUrisFor`, `preRegisteredClients`, `hasDynamicClient`
|
|
577
|
-
- `parseBearer`, `timingSafeEqual`, `matchesAny`, `wwwAuthenticateHeader`, `rejectQueryToken`
|
|
578
|
-
- `validateAgainstSchema`, `JSON_SCHEMA_TYPES`, `SUPPORTED_SCHEMA_KEYWORDS`, `IGNORED_SCHEMA_KEYWORDS`, `unsupportedKeywords`
|
|
579
|
-
- `rpcResult`, `rpcError`, JSON-RPC error constants
|
|
580
|
-
- `pickProtocolVersion`, `PROTOCOL_VERSIONS`, `DEFAULT_PROTOCOL_VERSION`
|
|
581
|
-
- `jsonResponse`, `emptyResponse`, `optionsResponse`, `corsHeaders`, `methodNotAllowed`
|
|
582
|
-
- Types: `McpApp`, `McpAppOptions`, `McpAppAuth`, `VerifiedToken`, `ClientName`, `ClientProfile`, `McpHandler`, `McpHandlerOptions`, `McpPorts`, `Principal`, `AuditEntry`, `ServerInfo`, `ToolDef`, `ToolHandler`, `ToolResult`, `ToolRegistry`, `JsonSchema`, `StandardSchemaV1`, `DefineToolOptions`, `ApiToolOptions`, `ApiRequest`, `ClientStore`, `RegisteredClient`, `CodeStore`, `MintAccessTokenInput`, `RefreshAccessTokenInput`, `RevokeTokenInput`, `MintedToken`, `OAuthUser`, …
|
|
583
|
-
|
|
584
|
-
</details>
|
|
585
|
-
|
|
586
|
-
<details>
|
|
587
|
-
<summary><code>mcp-trellis/oauth</code></summary>
|
|
588
|
-
|
|
589
|
-
- `createOAuthRouter`
|
|
590
|
-
- `authorizationServerMetadata`, `protectedResourceMetadata`, `mcpWwwAuthenticate`
|
|
591
|
-
- `canonicalResource`, `resourcesEqual`, `firstResourceError`, `DEFAULT_RESOURCE_PATH`
|
|
592
|
-
- `isAllowedRedirectUri`, `CLAUDE_CALLBACK`
|
|
593
|
-
- `issueAuthCode`, `consumeAuthCode`, `newClientId`
|
|
594
|
-
- `verifyPkceS256`, `sha256Base64Url`, `randomBase64Url`
|
|
595
|
-
- `readClientAuth`, `firstClientAuthError`, `unregisteredClientsAllowed`
|
|
596
|
-
- `parseScope`, `formatScope`, `requestedScopes`, `firstScopeError`
|
|
597
|
-
- `GRANT_TYPES`, `OAUTH_ERRORS`, `DEFAULT_SCOPE`, `TOKEN_ENDPOINT_AUTH_METHODS`
|
|
598
|
-
- Types: `OAuthUser`, `MintedToken`, `OAuthPorts`, `OAuthRouterOptions`, `AuthCodeRecord`, `CodeStore`, `ClientStore`, `RegisteredClient`, `ClientAuth`, `TokenEndpointAuthMethod`, `MintAccessTokenInput`, `RefreshAccessTokenInput`, `RevokeTokenInput`
|
|
599
|
-
|
|
600
|
-
</details>
|
|
601
|
-
|
|
602
|
-
<details>
|
|
603
|
-
<summary><code>mcp-trellis/node</code></summary>
|
|
604
|
-
|
|
605
|
-
- `asNodeHandler`, `resolveOrigin`, `isAllowedOrigin`, `toWebRequest`, `sendWebResponse`, `readNodeBody`
|
|
606
|
-
- Types: `NodeRequestLike`, `NodeResponseLike`, `ToWebRequestOptions`, `ResolveOriginOptions`, `AsNodeHandlerOptions`, `OriginAllowlistOptions`
|
|
607
|
-
|
|
608
|
-
</details>
|
|
609
|
-
|
|
610
|
-
## Protocol promises
|
|
611
|
-
|
|
612
|
-
- Notifications → HTTP **202** with an empty body
|
|
613
|
-
- Batch arrays → `-32600` (MCP 2025-06-18)
|
|
614
|
-
- Protocol version negotiated from client `initialize` params
|
|
615
|
-
- Query-string tokens rejected (`token` and RFC 6750 `access_token`)
|
|
616
|
-
- `tools/call` checks `principal.scopes` (`*` = all), and the OAuth grant that produced them is validated at `authorize`
|
|
617
|
-
- With `createMcpApp`, tokens whose audience is not this server's canonical resource are rejected by the library
|
|
618
|
-
- **`fetch` never rejects.** If a host port you supply (`authenticate`, `context`, `resolveUser`, `mintAccessToken`, `clientStore`, …) throws, `createMcpHandler`, `createOAuthRouter`, and `createMcpApp` all catch it and return a real `500` (`-32603` for MCP, `server_error` for OAuth) instead of an unhandled rejection — whose shape and whether it even reaches the client varies by runtime. `asNodeHandler` catches independently too, since on raw `http.createServer` a rejection there leaves the socket open with no response ever sent.
|
|
619
|
-
|
|
620
|
-
## Threat model (library)
|
|
621
|
-
|
|
622
|
-
This is a **library** threat model, not a third-party audit badge.
|
|
623
|
-
|
|
624
|
-
| Risk | Mitigation |
|
|
625
|
-
|------|------------|
|
|
626
|
-
| Confused deputy (token usable at the wrong MCP) | RFC 8707: `resource` required on authorize + token; bound into the auth code; must equal this AS’s canonical resource (`canonicalResource(origin, resourcePath)`). Passed into `mintAccessToken` / `refreshAccessToken` so you can embed `aud`. |
|
|
627
|
-
| Refresh token replayed at another resource on the same AS | Library cannot inspect opaque refresh tokens — **`refreshAccessToken` MUST reject tokens not originally issued for the given `resource`** |
|
|
628
|
-
| Audience not enforced at the RS | **`createMcpApp` enforces this** — `verifyToken` returns the token's `audience` and the library rejects any mismatch against `canonicalResource(origin, resourcePath)`. With bare `createMcpHandler`, the check is yours. |
|
|
629
|
-
| Open redirect after login | Your login page must validate `next` is same-origin before redirecting |
|
|
630
|
-
| Auth-code replay across instances | Pass a shared `codeStore`; in-memory jti map is process-local only |
|
|
631
|
-
| Origin spoofing on Node | Pass explicit `origin`, or `trustProxy: true` only behind a proxy that strips client `X-Forwarded-*`. Host-derived origin **requires** non-empty `allowedOrigins` on `asNodeHandler` (`["*"]` to opt out loudly) |
|
|
632
|
-
| Scope escalation at authorize | Requested `scope` is validated against the advertised `scopes` and rejected with `invalid_scope`; the grant is bound into the auth code and handed to `mintAccessToken` |
|
|
633
|
-
| Stolen access or refresh token | Host `revokeToken` (denylist or equivalent) that `verifyToken` and `refreshAccessToken` consult. Unknown / wrong-client tokens MUST no-op, not throw. Well-formed authenticated revoke is 200 even if the token is unknown |
|
|
634
|
-
| Stolen confidential-client secret | `clientStore.verifySecret` owns comparison — store hashes, not plaintext. The library never sees or persists credentials |
|
|
635
|
-
| Registration-free DCR | `/register` returns a random `client_id` that is **never stored**; any unknown `client_id` works with an allowlisted `redirect_uri`. Defensible for public clients with PKCE — the model dynamic connectors (Claude, Codex) actually need. Pre-registered clients bypass this entirely, bound to their own `redirectUris` and auth method. CIMD replaces this model |
|
|
636
|
-
| Naming only confidential clients doesn't actually block public ones | `clients: ["gemini"]` (or `allowUnregisteredClients: false`) is **enforced**: DCR unmounted, dropped from AS metadata, unresolved `client_id` rejected with `unauthorized_client` at `authorize` and `invalid_client` at `token` and `revoke` |
|
|
637
|
-
| DCR / token-endpoint abuse | Edge or reverse-proxy rate limiting on `/register` and `/token`. The library does not rate-limit |
|
|
638
|
-
|
|
639
|
-
**Also designed in:** PKCE S256 (timing-safe, length-capped); HMAC auth codes bind `userId`, `clientId`, `redirectUri`, challenge, and `resource`; redirect allowlist + DCR filter; no query-string tokens (`token` and `access_token`); honest `grant_types_supported`; `/token` and `/revoke` omit CORS `*`; audit fires on auth/scope denial as well as tool results.
|
|
640
|
-
|
|
641
|
-
**Known limits:** in-memory jti map is process-local (multi-instance needs `codeStore`); loopback redirect allowlist permits any path/port on `localhost` / `127.0.0.1` (expected for native clients); DCR does not bind credentials to a stored client record until CIMD.
|
|
642
|
-
|
|
643
|
-
## Not in scope
|
|
644
|
-
|
|
645
|
-
Explicitly **out** of this package (do not expect parity with full MCP hosts or enterprise AS products):
|
|
646
|
-
|
|
647
|
-
- **Tools only** — no `resources/*`, no `prompts/*`, and capabilities advertise tools alone
|
|
648
|
-
- **No server-initiated messages** — Streamable HTTP is served as a single JSON response per POST (which the spec permits, and which Gemini Enterprise requires). No SSE response streams, so no `notifications/progress` and no long-running tool keepalive
|
|
649
|
-
- **No sessions** — removed from the protocol in `2026-07-28` anyway
|
|
650
|
-
- **No enterprise-managed auth** — no ID-JAG, no IdP product integration; you wire login + minting
|
|
651
|
-
- **No embedded product surface** — no token store, sessions, or login UI
|
|
652
|
-
- **No stdio** transport; no batch JSON-RPC arrays
|
|
653
|
-
- **No DPoP, PAR, or `client_credentials`** — this AS is authorization_code (+ optional refresh and revoke)
|
|
654
|
-
- **No rate limiting** — put quotas on `/register` and `/token` at the edge or reverse proxy
|
|
655
|
-
- **No paid external security audit** claimed here — see Threat model above
|
|
656
|
-
|
|
657
|
-
See [docs/ROADMAP.md](docs/ROADMAP.md) for what is next — CIMD and the
|
|
658
|
-
`2026-07-28` stateless core are the two open items.
|
|
659
|
-
|
|
660
|
-
## Scripts
|
|
114
|
+
PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
661
115
|
|
|
662
116
|
```bash
|
|
663
117
|
npm test
|
|
@@ -665,10 +119,6 @@ npm run build
|
|
|
665
119
|
npm run typecheck
|
|
666
120
|
```
|
|
667
121
|
|
|
668
|
-
## Contributing
|
|
669
|
-
|
|
670
|
-
PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
671
|
-
|
|
672
122
|
## License
|
|
673
123
|
|
|
674
124
|
MIT
|