mcp-authz 0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jag Reehal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,411 @@
1
+ # mcp-authz
2
+
3
+ A remote MCP **resource server** you put behind Claude (or any MCP client that speaks OAuth). People sign in through your authorization server; a policy decides which permissions they hold; MCP capabilities declare the permission they need, and a caller only ever sees the ones they may use.
4
+
5
+ This package is the generic shell. Wire TestRail, Jira, Help Scout, or anything else behind it — the downstream service keeps its single service credential, and this decides which person may make it do what.
6
+
7
+ ## Who does what
8
+
9
+ ```
10
+ Claude ──OAuth 2.1 (CIMD/DCR, PKCE, resource=your URL)──▶ authorization server
11
+ │ signs the human in
12
+ ▼
13
+ Google Workspace (OIDC)
14
+ │ verified email
15
+ ▼
16
+ this library: verify, map,
17
+ call createServer(context)
18
+ ```
19
+
20
+ **We are a resource server, and nothing more.** Verifying tokens is ours. Registering Claude, showing consent and running PKCE belongs to an authorization server that already exists. Google cannot fill that role itself: it has no client registration for your MCP audience, and it will not mint a token whose audience is your MCP endpoint. WorkOS, Stytch and Auth0 all do both halves, including Google Workspace login.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pnpm add mcp-authz @modelcontextprotocol/server
26
+ # optional Node helper:
27
+ pnpm add @modelcontextprotocol/node
28
+ ```
29
+
30
+ ## API
31
+
32
+ ### `createMcpFetch(options)`
33
+
34
+ Returns `(request: Request) => Promise<Response>`.
35
+
36
+ | Option | Purpose |
37
+ | ------------------- | -------------------------------------------------------------- |
38
+ | `resourceServerUrl` | Public URL Claude reaches, e.g. `https://mcp.acme.com/mcp` |
39
+ | `oauthMetadata` | Your AS's RFC 8414 fields, or `await discoverOAuth(issuer)` |
40
+ | `verifier` | Built-in JWT/JWKS verification and claim options |
41
+ | `tokenVerifier` | Custom SDK verifier, including opaque-token introspection |
42
+ | `identityFromAuth` | Map custom `AuthInfo` to an `Identity` |
43
+ | `requiredScopes` | Baseline scopes (default `['mcp']`) |
44
+ | `toolScopes` | Declarative tool name → scope(s) for safe step-up |
45
+ | `capabilityScopes` | Tools, prompts and resource URIs → scope(s) |
46
+ | `supportedScopes` | Additional scopes advertised in resource metadata |
47
+ | `scopesForRequest` | Advanced `(request, trustedRoute) => scopes` resolver |
48
+ | `policy` | `definePolicy(...)`; matching no rule is HTTP 403 |
49
+ | `authorize` | Async alternative to `policy` for external entitlements |
50
+ | `resolve` | Optional: `(identity, principal) => context` to enrich context |
51
+ | `onDecision` | Awaited allow/deny decision sink |
52
+ | `createServer` | `(context) => McpServer` once per request; see `authz` |
53
+ | `permissions` | Optional: the boot-time map, when a factory of yours hides it |
54
+ | `maxRequestBytes` | Cap on the pre-auth body read for scopes. Default 1 MiB |
55
+ | `legacy` | 2025 handling; strict `reject` by default |
56
+
57
+ `MCP_PUBLIC_URL` / `resourceServerUrl` **must** be the URL clients actually reach. Advertise anything else and a conforming client will not attach its token to a resource it was not issued for.
58
+
59
+ ### `discoverOAuth(issuer)`
60
+
61
+ ```ts
62
+ oauthMetadata: await discoverOAuth('https://auth.acme.com'),
63
+ ```
64
+
65
+ Reads the endpoints off the authorization server rather than out of a config file,
66
+ including `jwks_uri`, which leaves the `verifier` block optional. It tries the RFC
67
+ 8414 location first and the OIDC one second, because Auth0 and Google publish only
68
+ the latter. A document declaring a different issuer is refused: its endpoints would
69
+ send your users somewhere else to sign in.
70
+
71
+ The cost is a fetch during boot. An AS that is down now stops your deploy rather
72
+ than only your logins, so keep passing `oauthMetadata` by hand if you would rather
73
+ own that trade.
74
+
75
+ The built-in JWT verifier requires `email_verified: true` by default before
76
+ email or domain rules can run. Use `verifier.emailVerifiedClaim` for a
77
+ provider-specific assurance claim, or explicitly set `requireEmailVerified:
78
+ false` only when the issuer contract guarantees the email another way. For
79
+ opaque tokens, pass any SDK-compatible `tokenVerifier` plus `identityFromAuth`.
80
+
81
+ ### `definePolicy(spec)`
82
+
83
+ ```ts
84
+ import { definePolicy } from 'mcp-authz';
85
+
86
+ const policy = definePolicy({
87
+ roles: {
88
+ reader: ['cases:read'],
89
+ editor: ['cases:read', 'cases:write'],
90
+ admin: ['*'],
91
+ },
92
+ rules: [
93
+ { match: { domain: 'acme.com' }, role: 'reader' },
94
+ { match: { email: 'alice@acme.com' }, role: 'editor' },
95
+ { match: { claim: { 'org.groups': 'qa-leads' } }, role: 'editor' },
96
+ { match: { sub: 'auth0|left-last-march' }, deny: true },
97
+ ],
98
+ });
99
+
100
+ export type Permission = PermissionOf<typeof policy>; // 'cases:read' | 'cases:write'
101
+ ```
102
+
103
+ Match on `issuer`, `sub`, `email`, `domain`, or any verified `claim` (dotted paths; a scalar
104
+ must equal, an array must contain). Every matching rule applies and the permissions
105
+ union; one `deny` match wins over all of them.
106
+
107
+ Email and domain rules match only when the identity mapper marked the email as
108
+ verified. Stable identities are the pair `(issuer, sub)`, not `sub` alone; include
109
+ `issuer` in a rule when one policy accepts identities from multiple issuers.
110
+
111
+ **Matching no rule means no permissions**, and there is no setting to change that.
112
+ A default that can be widened eventually is, and the failure is silent.
113
+
114
+ Written as a literal above, permission names become a string-literal union:
115
+
116
+ ```ts
117
+ tool(
118
+ 'update_case',
119
+ {
120
+ permission: 'cases:wrtie',
121
+ // ^^^^^^^^^^^^^
122
+ // Type '"cases:wrtie"' is not assignable to
123
+ // type '"cases:read" | "cases:write"'.
124
+ },
125
+ updateCase,
126
+ );
127
+ ```
128
+
129
+ Handed
130
+ `definePolicy(JSON.parse(process.env.MCP_POLICY!))` the same call still validates
131
+ the shape and still reconciles at boot — it just cannot check names TypeScript
132
+ never saw. The library reads neither the environment nor the filesystem: how an
133
+ application loads configuration is the application's business.
134
+
135
+ The full runtime shape is validated at startup: catalogues, roles, permission
136
+ strings, rules, match fields and claim values. Malformed JSON cannot survive
137
+ boot and become a request-time 500.
138
+
139
+ For a wildcard-only policy, declare the concrete vocabulary so permission
140
+ names remain compile-time checked:
141
+
142
+ ```ts
143
+ const policy = definePolicy({
144
+ permissions: ['cases:read', 'cases:write'],
145
+ roles: { admin: ['*'] },
146
+ rules: [{ match: { email: 'admin@acme.com' }, role: 'admin' }],
147
+ });
148
+ ```
149
+
150
+ ### `authz(policy)`
151
+
152
+ One call binds everything to one policy. Each builder accepts only a permission that
153
+ policy can grant, so `cases:wrtie` is a build error in all three.
154
+
155
+ ```ts
156
+ const { tool, prompt, resource, server } = authz(policy);
157
+
158
+ const createServer = server(
159
+ [
160
+ tool(
161
+ 'update_case',
162
+ {
163
+ permission: 'cases:write',
164
+ inputSchema: z.object({ id: z.string(), title: z.string() }),
165
+ audit: ({ id }) => `case:${id}`,
166
+ },
167
+ async ({ id, title }, { principal }) => renameCase(id, title, principal),
168
+ ),
169
+
170
+ // A prompt is a tool call somebody else composed, so it costs what that
171
+ // call costs. A resource is read-only to MCP and still the whole case list.
172
+ prompt('triage_case', { permission: 'cases:write', argsSchema }, walkThroughRename),
173
+ resource('cases', { permission: 'cases:read', uri: 'cases://all' }, readCases),
174
+ ],
175
+ { name: 'acme', version: '1.0.0', onAudit: (event) => log.info(event) },
176
+ );
177
+ ```
178
+
179
+ Whatever the caller lacks the permission for is **never registered**, so it is absent
180
+ from `tools/list` and there is no per-handler check to forget. Skipping the list and
181
+ naming an unregistered prompt gets the same refusal, because registration is the
182
+ authorization seam rather than the listing. OAuth scopes do not affect visibility:
183
+ a permitted capability remains visible when the current token needs step-up.
184
+
185
+ `onAudit` is awaited and emits `attempt`, followed by `success` or `failure`,
186
+ with identity, capability, permission, resource, timestamp and duration. A
187
+ rejected sink fails closed instead of silently losing the record.
188
+
189
+ The boot-time check keys prompts and resources as `prompt:triage_case` and
190
+ `resource:cases`, so a prompt sharing a tool's name stays its own entry. A server
191
+ advertises only the kinds it actually has.
192
+
193
+ Each handler takes the tool's arguments and a context carrying the `Principal` the
194
+ policy produced:
195
+
196
+ | Field | What it holds |
197
+ | ----------------- | -------------------------------------------------------------- |
198
+ | `issuer` | Exact token issuer; key durable identity with `sub`. |
199
+ | `sub` | Token subject, unique only within its issuer. |
200
+ | `email` | Verified email when the issuer supplies one. |
201
+ | `domain` | Workspace domain, when the AS passes `hd` through. |
202
+ | `roles` | Names of the roles that matched. |
203
+ | `permissions` | What those roles grant. |
204
+ | `can(permission)` | Honours a `*` grant, so ask this instead of reading the array. |
205
+
206
+ A handler never checks the permission it declared, because `server` checked it before
207
+ registering. Call `can` when one handler branches on a second permission, such as
208
+ returning extra fields to an admin.
209
+
210
+ ### `gate(server, principal, permissions)`
211
+
212
+ For an MCP server you already have, from another package or one you are not ready
213
+ to change, whose tools were never declared with a permission.
214
+
215
+ ```ts
216
+ // one hook in their builder
217
+ const server = options.wrap ? options.wrap(new McpServer(info, opts)) : new McpServer(info, opts);
218
+
219
+ // your connector
220
+ createServer: (principal) =>
221
+ buildServer(config, {
222
+ wrap: (server) => gate(server, principal, { get_case: 'testrail:read', delete_run: 'testrail:delete' }),
223
+ }),
224
+ ```
225
+
226
+ The reader never sees `delete_run` in `tools/list`, from a builder that knows
227
+ nothing about any of this. Their calls land in your `onAudit` log, which is the only
228
+ record tying a person to an action when the downstream API sees one service account.
229
+
230
+ A registration with no entry in the map throws, naming the tool. Dropping it either
231
+ way is how a tool nobody priced ends up reachable by everyone, or by nobody, with
232
+ no error to read.
233
+
234
+ This needs the hook because the SDK keeps a built server's tool list private, so
235
+ nothing can filter what it never saw. Pass the same map as `permissions` to
236
+ `createMcpFetch` and the boot-time check still covers the roles side.
237
+
238
+ ### Boot-time reconciliation
239
+
240
+ Pass both `policy` and a server built by `authz`, and startup compares them:
241
+
242
+ ```
243
+ MCP policy validation failed
244
+
245
+ Unreachable capability:
246
+ delete_case
247
+ requires: cases:delete
248
+ granted by: no role
249
+ ```
250
+
251
+ An unreachable capability throws — it is dead code that looks live. A permission no capability
252
+ requires only warns, because granting a role ahead of the tool that will use it is
253
+ how a staged rollout works.
254
+
255
+ ### External entitlements and rich context
256
+
257
+ Use `authorize` instead of `policy` when permissions live in an IdP, database
258
+ or policy service. `definePermissions` preserves compile-time names without
259
+ inventing a static policy, and `createPrincipal` builds the decision safely.
260
+
261
+ ```ts
262
+ import { authz, createPrincipal, definePermissions, type Principal } from 'mcp-authz';
263
+
264
+ const permissions = definePermissions(['cases:read', 'cases:write'] as const);
265
+ type Permission = (typeof permissions.permissions)[number];
266
+ type Context = { principal: Principal<Permission>; tenant: Tenant };
267
+
268
+ const { tool, server } = authz(permissions, {
269
+ principal: (context: Context) => context.principal,
270
+ });
271
+
272
+ const createServer = server(
273
+ [tool('whoami', { permission: 'cases:read' }, async (_args, context) => describeTenant(context.tenant))],
274
+ { name: 'acme', version: '1.0.0' },
275
+ );
276
+
277
+ const fetch = createMcpFetch({
278
+ // ...resourceServerUrl, oauthMetadata, verifier
279
+ authorize: async (identity) => {
280
+ const grants = await entitlementsFor(identity.issuer, identity.sub);
281
+ return createPrincipal(identity, grants.roles, grants.permissions);
282
+ },
283
+ resolve: async (_identity, principal) => ({
284
+ principal,
285
+ tenant: await tenantFor(principal.issuer, principal.sub),
286
+ }),
287
+ createServer,
288
+ });
289
+ ```
290
+
291
+ With a static `policy`, `resolve` still enriches its decision without widening
292
+ it. In either mode the complete context reaches handlers directly; definitions
293
+ stay at module scope and boot-time reconciliation remains available for static
294
+ policies.
295
+
296
+ Pass `permissions` only when your own wrapper hides the map attached to a built
297
+ server factory.
298
+
299
+ ### Refusing a caller
300
+
301
+ ```ts
302
+ throw AccessDeniedError.notPermitted(email);
303
+ throw AccessDeniedError.noCredential(email, 'a TestRail API key');
304
+ ```
305
+
306
+ The gate turns either one into HTTP `403` with
307
+ `{"error":"forbidden","reason":"policy_denied"}` and the message as
308
+ `error_description`. It deliberately carries no Bearer challenge: a token upgrade
309
+ cannot repair company policy.
310
+ `error.reason` is `'not_permitted'` or `'no_credential'` for your logs. A caller
311
+ who matches no rule gets the first of these before your `resolve` runs.
312
+
313
+ Throw it from `resolve`, never from `createServer`. The SDK owns factory failures,
314
+ so a refusal raised in there reaches the caller as a 500 with the reason stripped.
315
+
316
+ ### Scope step-up (SEP-2243)
317
+
318
+ ```ts
319
+ toolScopes: {
320
+ search_docs: 'mcp',
321
+ update_doc: ['mcp', 'write'],
322
+ },
323
+ ```
324
+
325
+ Use `capabilityScopes` when prompts or resources need step-up too. Keys are a
326
+ bare tool name (or `tool:name`), `prompt:name`, and `resource:<uri>`.
327
+
328
+ Scopes and permissions are deliberately separate axes. A scope gap is a 403 the
329
+ client can fix by re-authorising; a permission gap is an administrator's job.
330
+
331
+ Permissions determine capability visibility. Scopes determine whether this token
332
+ can invoke a visible capability. The request order is therefore: verify the baseline
333
+ token, evaluate capability permission, challenge for missing capability scope, then
334
+ dispatch. An unpermitted caller is never prompted through a futile step-up.
335
+
336
+ ```text
337
+ cases:write permission ✓ + write scope ✗
338
+ tools/list → update_case is visible
339
+ tools/call → 403 insufficient_scope, scope="mcp write"
340
+ client re-authorises → retry
341
+ ```
342
+
343
+ Insufficient scope becomes HTTP `403` with `WWW-Authenticate: … error="insufficient_scope"`, which a conforming client can act on. A tool `isError` cannot.
344
+
345
+ The map is also used to advertise every supported scope in protected-resource
346
+ metadata. Scope selection happens only after the 2026 request classifier and
347
+ standard-header validation have proved `Mcp-Method` and the decoded `Mcp-Name`
348
+ match the JSON-RPC body. Missing, malformed or dishonest routing headers are
349
+ HTTP 400 and never reach a tool. SEP-2243 Base64 sentinel names are decoded
350
+ before lookup, so encoding a name cannot downgrade its scope.
351
+
352
+ Choosing a scope means reading the body before the bearer gate runs, because the
353
+ scope is what that gate checks. Only a POST is read, only up to `maxRequestBytes`
354
+ (1 MiB by default, over it a 413), and only when its content type is JSON (415
355
+ otherwise). A GET carries no body to check its headers against, so it is held to
356
+ the baseline rather than to whatever its headers claim.
357
+
358
+ ### `mcp-authz/policy`
359
+
360
+ The policy half on its own — `definePolicy`, `definePermissions`,
361
+ `createPrincipal`, `Principal`, `Identity`, `reconcile` — with no MCP code in
362
+ the module graph:
363
+
364
+ ```ts
365
+ import { definePolicy, type Identity } from 'mcp-authz/policy';
366
+ ```
367
+
368
+ The decision is `identity → principal → permissions`; MCP is one surface that
369
+ consumes it. Gating an Agent SDK tool loop or a plain HTTP tool API reuses the
370
+ same policy object and the same file an administrator edits.
371
+
372
+ Note this shrinks what you _load_, not what you _install_: `@modelcontextprotocol/server`
373
+ is still a dependency of the package. Splitting it out only becomes worth doing
374
+ if a non-MCP consumer actually turns up.
375
+
376
+ ### Node listen helper
377
+
378
+ ```ts
379
+ import { listenMcp } from 'mcp-authz/node';
380
+
381
+ await listenMcp(fetch, { port: 8200, name: 'acme-mcp', info: { resource: url } });
382
+ ```
383
+
384
+ Binds every interface; the bearer gate is the security boundary.
385
+
386
+ ## Configuration (typical deployment)
387
+
388
+ | Variable | Purpose |
389
+ | ------------------------------ | ----------------------------------------------- |
390
+ | `MCP_PUBLIC_URL` | URL Claude reaches |
391
+ | `OAUTH_ISSUER` | Authorization server issuer |
392
+ | `OAUTH_AUTHORIZATION_ENDPOINT` | From AS metadata |
393
+ | `OAUTH_TOKEN_ENDPOINT` | Same |
394
+ | `OAUTH_REGISTRATION_ENDPOINT` | Optional; needed if clients still use DCR |
395
+ | `OAUTH_JWKS_URI` | Signing keys |
396
+ | `GOOGLE_WORKSPACE_DOMAIN` | Optional. Refuse tokens outside your domain |
397
+ | `MCP_POLICY` | Optional. Policy JSON, when not written in code |
398
+
399
+ ## Spec (2026-07-28)
400
+
401
+ - Strict 2026 Streamable HTTP via SDK `createMcpHandler`; opt in to legacy explicitly
402
+ - Standard routing headers are validated against the body before scope selection
403
+ - RFC 9728 Protected Resource Metadata
404
+ - RFC 8707 resource indicators / audience binding
405
+ - Prefer AS support for **CIMD**; DCR is deprecated in this protocol revision
406
+
407
+ Requires Node.js 20 or newer; CI covers Node 20, 22 and 24.
408
+
409
+ ## License
410
+
411
+ MIT