mcp-authz 0.1.0 → 0.2.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 CHANGED
@@ -1,23 +1,134 @@
1
1
  # mcp-authz
2
2
 
3
+ **Give an MCP server per-user permissions without deploying an authorization system.**
4
+
3
5
  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
6
 
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.
7
+ 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.
8
+
9
+ ## Where it fits
10
+
11
+ Use it when you can reach the server's code, or wrap the builder that makes it, and the callers are people who already sign in somewhere. Dana reads cases, Alice also writes them, and that decision sits next to the handler running the query. You install a dependency, and you run nothing new.
12
+
13
+ Plenty of good tools solve the neighbouring problems. Take one of them when its row describes you:
14
+
15
+ | Approach | Example | What you take on |
16
+ | ------------------------------ | ---------------------- | ---------------------------------------------------------------------- |
17
+ | MCP gateway | agentgateway | A data-plane component to deploy and keep available |
18
+ | Gateway plus policy service | Permit | The gateway, and the policy infrastructure behind it |
19
+ | External decision point | Cerbos | A PDP to run, and a call out to it on every decision |
20
+ | Relationship-based permissions | Auth0 FGA | Richer rules than this offers, in a second authorization system |
21
+ | Framework feature | FastMCP | Convenience, tied to that framework |
22
+ | OAuth plumbing | official SDK, mcp-auth | Authentication and resource-server mechanics, with no permission model |
23
+ | Embedded dependency | **mcp-authz** | One package, and a policy engine that stays small by design |
24
+
25
+ Two cases need none of this. A stdio server on one laptop already has the OS account as its boundary. A server where every caller gets identical access wants one service credential and no policy.
26
+
27
+ ### You need one integration seam
28
+
29
+ Which one you get decides the tool, and the source is not the only seam that
30
+ works.
31
+
32
+ ```mermaid
33
+ flowchart TD
34
+ Q["How much of the server can you reach?"]
35
+ Q -->|"You write the tools"| A["<b>authz()</b><br/>permissions declared on the capability,<br/>names checked by tsc,<br/>catalogue reconciled at boot"]
36
+ Q -->|"You call somebody else's<br/>builder, and it lets you<br/>wrap the server it makes"| B["<b>gate()</b><br/>a permission map over tools<br/>that were never written for one"]
37
+ Q -->|"All you have is a URL"| C["<b>createMcpProxy</b><br/>record the catalogue,<br/>enforce at the edge"]
38
+ ```
39
+
40
+ The middle rung is the one people miss. A package you install, whose tools you
41
+ did not write, still works here as long as its builder hands you the server
42
+ before returning it. That hook is two lines, it is worth asking an upstream
43
+ maintainer for, and [`gate()`](#gateserver-principal-permissions) shows both
44
+ sides of it. Without the hook nothing in this package can help: the SDK keeps a
45
+ built server's tool list private, so no code can filter what it never saw.
46
+
47
+ Writing the server in Python? [`mcp-authz` on PyPI](https://pypi.org/project/mcp-authz/) makes the same decisions on the official Python SDK.
48
+
49
+ ### FastMCP and `canAccess`
50
+
51
+ FastMCP gates a capability with a predicate:
52
+
53
+ ```ts
54
+ canAccess: requireRole('admin');
55
+ canAccess: (auth) => auth?.role === 'admin' && auth?.department === 'engineering';
56
+ ```
57
+
58
+ That runs, and for one rule in one place it is the right amount of machinery.
59
+
60
+ A predicate carries no vocabulary. TypeScript cannot derive the permission names from it, startup cannot compare the tools against the roles, and each closure agrees with the others only because you kept them in step by hand. `definePolicy` hands you one object that the compiler reads and that boot reconciles:
61
+
62
+ - `permission: 'cases:wrtie'` fails `tsc` rather than a request at 3am.
63
+ - A tool requiring `cases:delete` that no role grants refuses to boot.
64
+ - An administrator edits one file instead of eleven closures.
65
+
66
+ Twenty tools sharing a vocabulary is where this pays. One tool with one rule is where it does not.
67
+
68
+ ## Deployment
69
+
70
+ You ship one MCP server process with this library inside it. Claude dials your
71
+ public URL. Your authorization server runs login. TestRail or Jira keeps one
72
+ service credential. Full stack detail is in the
73
+ [docs](https://jagreehal.github.io/mcp-authz/concepts/deployment/).
74
+
75
+ ```mermaid
76
+ flowchart LR
77
+ Client["MCP client"] -->|"OAuth login, PKCE"| AS["Authorization server"]
78
+ AS -->|"OIDC login"| IdP["Google Workspace"]
79
+ Client -->|"Bearer token<br/>aud = your MCP URL"| MCP["Your MCP server<br/>mcp-authz"]
80
+ MCP -->|"service credential"| API["Downstream API"]
81
+ ```
6
82
 
7
83
  ## Who does what
8
84
 
85
+ ```mermaid
86
+ flowchart TD
87
+ Client["MCP client"] -->|"OAuth 2.1, PKCE, resource = MCP URL"| AS["Authorization server"]
88
+ AS -->|"OIDC login"| IdP["Google Workspace"]
89
+ IdP -->|"verified email, hd, groups"| AS
90
+ AS -->|"access token, aud = MCP URL"| Client
91
+ Client -->|"Bearer on POST /mcp"| Lib["mcp-authz"]
92
+ Lib -->|"createServer(principal)"| App["Your handlers"]
9
93
  ```
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)
94
+
95
+ **We are a resource server, and nothing more.** Verifying tokens is ours.
96
+ Registering Claude, showing consent and running PKCE belongs to an authorization
97
+ server that already exists. Google cannot fill that role itself: it has no
98
+ client registration for your MCP audience, and it will not mint a token whose
99
+ audience is your MCP endpoint. WorkOS, Stytch and Auth0 all do both halves,
100
+ including Google Workspace login.
101
+
102
+ ## What a request goes through
103
+
104
+ ```mermaid
105
+ flowchart TD
106
+ R["POST /mcp"] --> Path{"Path matches<br/>resourceServerUrl?"}
107
+ Path -->|no| E404["404 naming the path<br/>this server does answer on"]
108
+ Path -->|yes| Bearer{"Baseline token valid?<br/>signature, iss, aud, exp"}
109
+ Bearer -->|no| E401["401 + WWW-Authenticate<br/>carrying resource_metadata"]
110
+ Bearer -->|yes| Pol["identityFromAuth → policy<br/>or authorize() → Principal"]
111
+ Pol -->|"no rule matched"| E403["403 forbidden / policy_denied<br/>no Bearer challenge"]
112
+ Pol --> Pre["Classify the request and check<br/>Mcp-Method / Mcp-Name against the body"]
113
+ Pre -->|"headers disagree,<br/>or are missing"| E400["400, before any tool is chosen"]
114
+ Pre --> Perm{"principal.can()<br/>for this capability?"}
115
+ Perm -->|no| E403
116
+ Perm -->|yes| Scope{"Token carries the<br/>capability's scope?"}
117
+ Scope -->|no| E403S["403 insufficient_scope<br/>the client can re-authorise"]
118
+ Scope -->|yes| Ctx["resolve() adds tenant,<br/>credentials, request context"]
119
+ Ctx --> Build["createServer registers only<br/>the capabilities this caller may use"]
120
+ Build --> Ask{"Does this call ask<br/>for a person?"}
121
+ Ask -->|no| H
122
+ Ask -->|"declined, unanswered,<br/>or approved by nobody"| E403A["ApprovalRefusedError,<br/>logged as decision: deny"]
123
+ Ask -->|approved| H["Handler runs, holding the Principal"]
18
124
  ```
19
125
 
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.
126
+ Three orderings in there are deliberate. Authentication precedes the body read,
127
+ so nobody who cannot present a valid token can make this process parse anything.
128
+ Header validation precedes both the permission lookup and scope selection, so an
129
+ untrusted `Mcp-Name` can talk its way into neither a cheaper scope nor another
130
+ capability's permission. Permission precedes scope, so an unpermitted caller is
131
+ never prompted to re-authorise for an action your policy will refuse anyway.
21
132
 
22
133
  ## Install
23
134
 
@@ -27,6 +138,64 @@ pnpm add mcp-authz @modelcontextprotocol/server
27
138
  pnpm add @modelcontextprotocol/node
28
139
  ```
29
140
 
141
+ Requires Node.js 24 or newer.
142
+
143
+ ## Point your authorization server at it
144
+
145
+ Five steps, in order. Step 2 is the one people skip, and its failure is the
146
+ confusing kind: the client completes the whole OAuth flow, receives a valid
147
+ token, and then never sends it.
148
+
149
+ 1. **Settle the public URL.** `resourceServerUrl` has to be the URL clients
150
+ dial, path and all. The library publishes RFC 9728 metadata at
151
+ `/.well-known/oauth-protected-resource/mcp` for an `/mcp` path and names that
152
+ same URL as `resource`. A client that reads a different host there declines
153
+ to attach its token, and nothing in the logs says "wrong host".
154
+
155
+ 2. **Register that URL at your AS as a resource identifier, with RFC 8707
156
+ resource indicators enabled.** The client sends
157
+ `resource=https://mcp.acme.com/mcp`, and your AS has to mint a token whose
158
+ `aud` equals it. Auth0 models this as an API with an audience; WorkOS and
159
+ Stytch expose it as the MCP resource server settings. Without it you get a
160
+ perfectly signed token that this library refuses with 401.
161
+
162
+ 3. **Define the scopes.** The baseline is whatever you pass to `requiredScopes`
163
+ (default `mcp`), plus one scope for each entry in `toolScopes` or
164
+ `capabilityScopes`, such as `write`. Grant them to the client. The library
165
+ advertises the full set as `scopes_supported` in the resource metadata.
166
+
167
+ 4. **Decide how clients register.** Prefer an AS that supports CIMD. If yours
168
+ only offers DCR, publish `registration_endpoint` in `oauthMetadata`, and
169
+ treat it as a stopgap: DCR is deprecated in this protocol revision.
170
+
171
+ 5. **Check the claims your rules need.** `iss`, `sub`, `aud` and `exp` are
172
+ always required. Any `email` or `domain` rule also needs `email` and
173
+ `email_verified: true`. A Workspace `domain` rule reads `hd`, so the AS has
174
+ to pass it through from Google.
175
+
176
+ Then hand the library the issuer and let it read the rest:
177
+
178
+ ```ts
179
+ const fetch = createMcpFetch({
180
+ resourceServerUrl: new URL(process.env.MCP_PUBLIC_URL!),
181
+ oauthMetadata: await discoverOAuth(process.env.OAUTH_ISSUER!),
182
+ policy,
183
+ createServer,
184
+ });
185
+ ```
186
+
187
+ Add `https://mcp.acme.com/mcp` in Claude as a custom connector and sign in.
188
+
189
+ ### When it does not work
190
+
191
+ | Symptom | Cause |
192
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------- |
193
+ | Login succeeds, then the client never attaches a token | `resourceServerUrl` is not the URL the client dialled (step 1) |
194
+ | 401 on every call, signature verifies fine | `aud` is not your MCP URL (step 2), or `iss` does not match |
195
+ | 401 for some people only | `email_verified` missing or false, or `hd` outside your Workspace |
196
+ | 403 `forbidden/policy_denied` | The token is fine and no rule matched. Working as designed |
197
+ | 403 `insufficient_scope` | Permission granted, token lacks the capability's scope. Client re-authorises |
198
+
30
199
  ## API
31
200
 
32
201
  ### `createMcpFetch(options)`
@@ -51,9 +220,16 @@ Returns `(request: Request) => Promise<Response>`.
51
220
  | `onDecision` | Awaited allow/deny decision sink |
52
221
  | `createServer` | `(context) => McpServer` once per request; see `authz` |
53
222
  | `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 |
223
+ | `maxRequestBytes` | Cap on the post-auth body read that names a capability. 1 MiB |
55
224
  | `legacy` | 2025 handling; strict `reject` by default |
56
225
 
226
+ **`legacy` needs care.** The SDK client 2.0.0 still opens with an `initialize` handshake, and
227
+ 2026-07-28 removed it (SEP-2567) — so from this handler's side every client shipping today is a
228
+ legacy client, and the default `reject` turns all of them away. A deployment real clients must
229
+ reach wants `legacy: 'stateless'` until a client ships without the handshake. The refusal says
230
+ so itself rather than returning a bare protocol error. The gate applies identically on both
231
+ paths; `src/e2e.story.test.ts` drives a real client through each.
232
+
57
233
  `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
234
 
59
235
  ### `discoverOAuth(issuer)`
@@ -128,7 +304,7 @@ tool(
128
304
 
129
305
  Handed
130
306
  `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
307
+ the shape and still reconciles at boot. It cannot check names TypeScript
132
308
  never saw. The library reads neither the environment nor the filesystem: how an
133
309
  application loads configuration is the application's business.
134
310
 
@@ -178,13 +354,18 @@ const createServer = server(
178
354
 
179
355
  Whatever the caller lacks the permission for is **never registered**, so it is absent
180
356
  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.
357
+ naming an unregistered prompt is refused before dispatch with the same `403
358
+ forbidden / policy_denied`, and that denied attempt reaches `onDecision`. A probe
359
+ for a capability somebody was never shown is exactly the one worth having in a log.
360
+ OAuth scopes do not affect visibility: a permitted capability remains visible when
361
+ the current token needs step-up.
362
+
363
+ `onAudit` emits `attempt`, followed by `success`, `failure` or `refused`, with
364
+ identity, capability, permission, resource, timestamp and duration. The server
365
+ awaits each write. A rejected `attempt` write stops the handler before it runs.
366
+ A terminal write happens after the action has reached its result, so its failure
367
+ cannot change that result. Set `onAuditError` to send the failed event and error
368
+ to your retry queue or operator alert.
188
369
 
189
370
  The boot-time check keys prompts and resources as `prompt:triage_case` and
190
371
  `resource:cases`, so a prompt sharing a tool's name stays its own entry. A server
@@ -207,6 +388,142 @@ A handler never checks the permission it declared, because `server` checked it b
207
388
  registering. Call `can` when one handler branches on a second permission, such as
208
389
  returning extra fields to an admin.
209
390
 
391
+ ### Where the audit events go
392
+
393
+ The downstream API sees one service account. These events are the only record
394
+ tying a person to an action, so they are worth sending somewhere a security team
395
+ already looks — their SIEM, their warehouse, the log pipeline behind their
396
+ dashboards. There is no sink to configure and no adapter to install: each hook is
397
+ a function, and the useful ones are one line.
398
+
399
+ ```ts
400
+ onAudit: (event) => logger.info(event), // structured log → wherever it already ships
401
+ onAudit: (event) => db.insert(auditLog).values(event),
402
+ onDecision: (event) => logger.warn(event),
403
+ ```
404
+
405
+ **Wire both.** `onAudit` sees calls that were permitted. `onDecision` sees the
406
+ refusals — including somebody naming a capability they were never shown, which is
407
+ the single most interesting line in the whole log. They are separate hooks because
408
+ they happen in different places: one wraps the handler, the other answers the
409
+ request before dispatch.
410
+
411
+ **Keep third-party HTTP off the hot path.** The `attempt` write is awaited, and
412
+ rejecting it stops the call before it runs — that is what makes the trail
413
+ fail-closed, and it means a webhook here puts somebody else's uptime in front of
414
+ your tools. Write locally, ship asynchronously; if you must call out, do it from
415
+ `onAuditError`, or from the terminal events, which cannot change a result that has
416
+ already happened.
417
+
418
+ **Three fields exist for the reader, not the caller.** `callId` is the same on
419
+ both events of one call and different for every other, so a store can join an
420
+ attempt to its outcome without guessing from timestamps. `domain` is the verified
421
+ Workspace domain — the nearest thing to an organisation this can prove, so key one
422
+ by `(issuer, domain)`. `emitter` names the deployment, and it is the one you have
423
+ to set yourself:
424
+
425
+ ```ts
426
+ server(definitions, { name: 'cases', version: '1.0.0', emitter: 'cases-prod', onAudit });
427
+ createMcpFetch({ ..., emitter: 'cases-prod', onDecision });
428
+ ```
429
+
430
+ Set the same value in every entry point of one deployment, or a reader cannot tell
431
+ that a refusal and a call came from the same place. Left unset the field is absent
432
+ rather than guessed. With those three, "what does this org use, and who used it"
433
+ is a group-by rather than a schema migration.
434
+
435
+ **Every event says what it is.** `mcp_authz.audit.v1` and
436
+ `mcp_authz.decision.v1` share half their fields and land in the same store, often
437
+ for years, so each carries its own `type`. Query on that rather than on which
438
+ fields happen to be present:
439
+
440
+ ```ts
441
+ onAudit: (event) => sink.write({ ...event, index: event.type }); // event.type is a literal union
442
+ ```
443
+
444
+ A new optional field does not move the `v1`. Changing what an existing field means
445
+ does, because a stored query cannot tell that apart.
446
+
447
+ There is no dashboard here, and there should not be: it would mean storage,
448
+ retention, its own authentication and a tenancy model, competing with the tool
449
+ your security team already pays for. The events are JSON with a stable shape —
450
+ send them there.
451
+
452
+ ### Human approval
453
+
454
+ Some actions want a second person even when the caller is permitted. Declare it
455
+ on the capability and the call waits for an answer before it runs:
456
+
457
+ ```ts
458
+ tool(
459
+ 'delete_run',
460
+ {
461
+ permission: 'testrail:delete',
462
+ // `true` for every call, or a predicate when only some arguments warrant it
463
+ approval: ({ force }) => force,
464
+ audit: ({ id }) => `run:${id}`,
465
+ },
466
+ deleteRun,
467
+ );
468
+
469
+ server(definitions, {
470
+ name: 'acme',
471
+ version: '1.0.0',
472
+ onApproval: async (request) => {
473
+ const reply = await askInSlack('#ops', request); // request carries who, what, and the arguments
474
+ return reply.ok ? { approved: true, by: reply.user } : { approved: false, reason: reply.text };
475
+ },
476
+ });
477
+ ```
478
+
479
+ `{ approved: true }` will not compile without `by`, and an approval that arrives
480
+ naming nobody is refused at runtime as well. The type is the first line and not
481
+ the only one: an adapter written in untyped code, or one handing back a JSON
482
+ reply from a chat tool, can still produce an anonymous yes, and that would run a
483
+ destructive call under an audit trail claiming somebody had agreed to it.
484
+
485
+ An approval nobody's name is on is not a second pair of eyes. The approver's
486
+ name lands on the `success` audit event beside the caller's, the only place both
487
+ people appear, because the downstream API still sees one service account.
488
+
489
+ This is not the permission check repeated. The caller already holds
490
+ `testrail:delete`; approval is the separate question of whether _this_ call
491
+ should happen. It is also the one check here that cannot be a decision about
492
+ whether to register something, because it turns on arguments that do not exist
493
+ until the call. An unapproved capability is therefore visible in `tools/list` and
494
+ refused on invocation, unlike an unpermitted one.
495
+
496
+ **Silence is a refusal.** `approvalTimeoutMs` defaults to 45s, under the 60s
497
+ idle timeout most proxies ship with. An `onApproval` that throws refuses too,
498
+ rather than letting the action through on the strength of a Slack outage, and an
499
+ answer arriving after the deadline changes nothing: the caller has already been
500
+ told no.
501
+
502
+ A refusal reaches the handler's place as an `ApprovalRefusedError` carrying the
503
+ capability and, when there was one, the person who declined. The audit event is
504
+ `phase: 'refused'` with `decision: 'deny'`, and its `durationMs` covers the wait,
505
+ so the log shows what a call actually cost rather than what the handler did.
506
+
507
+ **A capability that asks for a person while no sink can be reached fails at
508
+ boot**, not at the first destructive call. `server(...)` and `gate(...)` both
509
+ refuse to wire it, the same way an unreachable capability refuses to start.
510
+
511
+ Nothing here is durable, and it does not need to be. The caller is still on the
512
+ other end of an open request, so a process that dies mid-question takes the
513
+ request with it and the action correctly did not happen. Durability is only owed
514
+ once you have told a caller you are done and promised to act later, which is a
515
+ promise this never makes.
516
+
517
+ If you need approval that outlives the request, keep the same seam: have
518
+ `onApproval` write a pending row, return `{ approved: false, reason: 'ticket
519
+ appr_123' }`, and add a tool of your own that polls it. The store lives in your
520
+ application, where you can see it, and this package stays something you install
521
+ rather than something you run.
522
+
523
+ `gate()` takes the same options for tools you did not write: an `approval` map
524
+ keyed exactly like `permissions`, plus `onApproval`. Adding a person in front of
525
+ somebody else's destructive tool is the best reason to reach for it.
526
+
210
527
  ### `gate(server, principal, permissions)`
211
528
 
212
529
  For an MCP server you already have, from another package or one you are not ready
@@ -235,6 +552,127 @@ This needs the hook because the SDK keeps a built server's tool list private, so
235
552
  nothing can filter what it never saw. Pass the same map as `permissions` to
236
553
  `createMcpFetch` and the boot-time check still covers the roles side.
237
554
 
555
+ ### `recordCapabilities(factory)` — building that map
556
+
557
+ The map above has to name every capability the server registers, and nothing
558
+ produced it: you wrote it by hand and it drifted quietly. `mcp-authz/testing`
559
+ reads it off the server instead.
560
+
561
+ ```ts
562
+ import { recordCapabilities, toPermissionsModule } from 'mcp-authz/testing';
563
+
564
+ const { names, fingerprints } = await recordCapabilities(() => buildServer(TEST_CONFIG));
565
+
566
+ expect(names).toEqual(Object.keys(PERMISSIONS).sort());
567
+ expect(fingerprints).toMatchSnapshot();
568
+ ```
569
+
570
+ It builds your server, connects a client over an in-memory transport, and lists
571
+ tools, prompts, resources and templates, labelled the way `gate()` labels them.
572
+ Build it **ungated**: a gated server answers per principal, so listing one hands
573
+ you a map missing exactly the capabilities that most need a price.
574
+
575
+ `toPermissionsModule(record)` renders the starting map as TypeScript source —
576
+ `as const` with the derived permission type — every capability priced
577
+ `TODO:unassigned`, which no role grants, so reconciliation refuses the boot until
578
+ a person has decided what each one costs. Permissions are never guessed from
579
+ `readOnlyHint`: the specification says annotations are hints and that tool-use
580
+ decisions must not be made from them.
581
+
582
+ `gate()` already refuses to start on a capability with no price, which covers a
583
+ dependency that adds a tool. `fingerprints` covers the one it cannot see — a
584
+ capability that keeps its name while its description, input schema, prompt
585
+ arguments or URI template change underneath. Each digests the whole definition
586
+ as served, so a snapshot turns that into a diff on the pull request. Nothing is
587
+ enforced at boot: a digest in production is a second source of truth, and would
588
+ make a description edit an outage.
589
+
590
+ `@modelcontextprotocol/client` is an optional peer, needed only by this subpath.
591
+
592
+ ### `mcp-authz/openapi` — the same bet on an HTTP API
593
+
594
+ An OpenAPI document is the other catalogue an agent reads. A tool that is never
595
+ registered is a tool the model never sees; an operation that is never in the
596
+ served document is an operation the agent never calls. Same move, same policy,
597
+ same audit events, on `operationId` instead of a tool name.
598
+
599
+ ```ts
600
+ import { createOpenApiFetch, recordOperations } from 'mcp-authz/openapi';
601
+ import { toPermissionsModule } from 'mcp-authz/testing';
602
+
603
+ // The map, generated from the document rather than written by hand.
604
+ // recordOperations(spec).names → toPermissionsModule → PERMISSIONS
605
+ export default createOpenApiFetch({
606
+ spec,
607
+ permissions: PERMISSIONS, // { getCase: 'cases:read', deleteCase: 'cases:delete', … }
608
+ resourceServerUrl: new URL(process.env.API_PUBLIC_URL!),
609
+ oauthMetadata,
610
+ policy,
611
+ onAudit: (event) => logger.info(event),
612
+ upstream: (request) => app.fetch(request), // your API, unchanged
613
+ });
614
+ ```
615
+
616
+ Dana fetches `/openapi.json` and gets three read operations. Alice fetches the
617
+ same path and gets the write and the delete too. Dana calling `DELETE
618
+ /cases/C1234` anyway is refused with the permission she lacks — the smaller
619
+ document was a context saving, never the boundary.
620
+
621
+ **Anything the document does not describe is refused**, with a 404 that says so.
622
+ That is the same rule as `gate()`: a capability nobody priced is reachable by
623
+ everyone or by nobody, with no error to read. Routes you deliberately keep out
624
+ of the spec — a health check, static files — belong outside this wrapper rather
625
+ than behind it.
626
+
627
+ `recordOperations(spec)` needs the document and nothing else: no running server,
628
+ no introspection, and it refuses an operation with no `operationId`, because a
629
+ generated fallback name is a second naming scheme that changes under a path
630
+ rename. Boot fails on an operation the map does not price, on a map entry naming
631
+ an operation the document no longer has, and on a permission no role grants.
632
+
633
+ Audit events are the ones you already have — `mcp_authz.audit.v1` with
634
+ `kind: 'operation'`, the `operationId` as `name`, and the concrete path as
635
+ `resource` — so one query covers both surfaces of the same product.
636
+
637
+ This entry point does not import the MCP handler, its route classification or
638
+ its scope step-up: it is its own bundle, ~10 kB plus the policy and verifier
639
+ chunks it shares.
640
+
641
+ ### `createMcpProxy` — URL-only upstream
642
+
643
+ When there is no builder hook, `mcp-authz/proxy` sits in front of a vendor MCP
644
+ reached only by URL:
645
+
646
+ ```ts
647
+ import { createMcpProxy } from 'mcp-authz/proxy';
648
+
649
+ export default createMcpProxy({
650
+ resourceServerUrl: new URL(process.env.MCP_PUBLIC_URL!),
651
+ oauthMetadata: {/* same as createMcpFetch */},
652
+ verifier: { jwksUri: process.env.OAUTH_JWKS_URI! },
653
+ policy,
654
+ permissions: PERMISSIONS, // from recordUpstream → toPermissionsModule
655
+ resourceUris: RESOURCE_URIS, // same module; a read names a URI, not a label
656
+ upstream: {
657
+ url: process.env.UPSTREAM_URL!,
658
+ bearer: process.env.UPSTREAM_TOKEN!,
659
+ },
660
+ });
661
+ ```
662
+
663
+ Record the upstream with `recordUpstream` or `mcp-authz record --upstream`, price
664
+ the map, deploy the proxy.
665
+
666
+ The proxy is stricter than embed mode, because nothing downstream of it re-checks
667
+ anything and it forwards on a service credential that outranks the caller.
668
+ Routing headers that disagree with the body are refused rather than forwarded; a
669
+ request with no routing headers is authorized from the body, which is what the
670
+ upstream will act on; and a capability the map does not price is refused outright.
671
+ The caller's `Authorization` and `Cookie` stay at the edge.
672
+
673
+ See [proxy mode](https://jagreehal.github.io/mcp-authz/typescript/proxy/) and the
674
+ [`proxy-example`](../../apps/proxy-example) app.
675
+
238
676
  ### Boot-time reconciliation
239
677
 
240
678
  Pass both `policy` and a server built by `authz`, and startup compares them:
@@ -248,7 +686,7 @@ Unreachable capability:
248
686
  granted by: no role
249
687
  ```
250
688
 
251
- An unreachable capability throws — it is dead code that looks live. A permission no capability
689
+ An unreachable capability throws, because it is dead code that looks live. A permission no capability
252
690
  requires only warns, because granting a role ahead of the tool that will use it is
253
691
  how a staged rollout works.
254
692
 
@@ -324,6 +762,10 @@ toolScopes: {
324
762
 
325
763
  Use `capabilityScopes` when prompts or resources need step-up too. Keys are a
326
764
  bare tool name (or `tool:name`), `prompt:name`, and `resource:<uri>`.
765
+ `createServer` route metadata lets startup reject keys that name no registered
766
+ capability. Startup also rejects duplicate bare/`tool:` aliases. Each scope
767
+ must contain one RFC 6749 scope token. Empty lists and space-delimited values
768
+ fail during construction.
327
769
 
328
770
  Scopes and permissions are deliberately separate axes. A scope gap is a 403 the
329
771
  client can fix by re-authorising; a permission gap is an administrator's job.
@@ -349,16 +791,77 @@ match the JSON-RPC body. Missing, malformed or dishonest routing headers are
349
791
  HTTP 400 and never reach a tool. SEP-2243 Base64 sentinel names are decoded
350
792
  before lookup, so encoding a name cannot downgrade its scope.
351
793
 
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
794
+ Choosing a scope means reading the body, because the capability is named in it
795
+ and not trustworthily in a header. That read happens **after** the bearer gate,
796
+ not before: the per-capability scope is checked against the token this request
797
+ already carries, so nobody who cannot present a valid one gets to make this
798
+ process parse anything. Only a POST is read, only up to `maxRequestBytes` (1 MiB
799
+ by default, over it a 413), and only when its content type is JSON (415
355
800
  otherwise). A GET carries no body to check its headers against, so it is held to
356
801
  the baseline rather than to whatever its headers claim.
357
802
 
803
+ ### `policy.explain(identity)` and the CLI
804
+
805
+ The decision tells you what Alice may do. It cannot tell you why, and why is
806
+ what gets asked when somebody is surprised.
807
+
808
+ ```ts
809
+ const { principal, matched, deniedBy } = policy.explain(identity);
810
+ ```
811
+
812
+ `matched` carries every rule that fired, each with its index in your `rules`
813
+ array, so the answer points at the line an administrator edits. `deniedBy` is
814
+ set when a deny emptied the grant. Nothing on the request path calls this, so
815
+ it costs a served request nothing.
816
+
817
+ The same two questions from a terminal, with no server running:
818
+
819
+ ```bash
820
+ npx mcp-authz explain policy.json --identity alice.json --capabilities caps.json
821
+ ```
822
+
823
+ ```text
824
+ alice@acme.com
825
+
826
+ Matched rules
827
+ rule 0 domain=acme.com -> reader
828
+ rule 1 email=alice@acme.com -> editor
829
+ rule 2 org.groups=qa-leads -> admin
830
+
831
+ Effective roles
832
+ reader
833
+ editor
834
+ admin
835
+
836
+ Permissions
837
+ cases:read
838
+ cases:write
839
+ * (every permission)
840
+
841
+ Capabilities
842
+ get_case
843
+ update_case
844
+ prompt:triage_case
845
+ ```
846
+
847
+ `--identity` takes an `Identity` or a decoded token payload (`iss`, `sub`,
848
+ `email`, `email_verified`, `hd`), or `-` to read one from stdin. It answers a
849
+ what-if, so it trusts the file it is handed rather than verifying a signature.
850
+
851
+ ```bash
852
+ npx mcp-authz check policy.json --capabilities caps.json
853
+ ```
854
+
855
+ `check` runs the same `reconcile` your server runs at boot, so a capability no
856
+ role can reach fails the pull request rather than the deploy. It exits 1 on that
857
+ and on an invalid policy, and 0 on an unused permission, which stays a warning
858
+ because granting a role ahead of the tool that will use it is how a staged
859
+ rollout works.
860
+
358
861
  ### `mcp-authz/policy`
359
862
 
360
- The policy half on its own — `definePolicy`, `definePermissions`,
361
- `createPrincipal`, `Principal`, `Identity`, `reconcile` — with no MCP code in
863
+ The policy half on its own (`definePolicy`, `definePermissions`,
864
+ `createPrincipal`, `Principal`, `Identity`, `reconcile`) with no MCP code in
362
865
  the module graph:
363
866
 
364
867
  ```ts
@@ -383,6 +886,20 @@ await listenMcp(fetch, { port: 8200, name: 'acme-mcp', info: { resource: url } }
383
886
 
384
887
  Binds every interface; the bearer gate is the security boundary.
385
888
 
889
+ ## See it work, without an authorization server
890
+
891
+ ```bash
892
+ git clone https://github.com/jagreehal/mcp-authz && cd mcp-authz && pnpm install
893
+ pnpm --filter mcp-authz-node-example dev # :8200, local dev key
894
+ pnpm --filter mcp-authz-node-example token dana@acme.com # reader
895
+ pnpm --filter mcp-authz-node-example token alice@acme.com # editor
896
+ ```
897
+
898
+ Paste a token into the MCP Inspector. Dana sees `whoami` and `get_case`, Alice
899
+ also sees `update_case`, and `sam@other.com` gets HTTP 403 before any tool runs.
900
+ The full walkthrough, including scope step-up and an IdP group claim, is in
901
+ [apps/node-example](https://github.com/jagreehal/mcp-authz/tree/main/apps/node-example).
902
+
386
903
  ## Configuration (typical deployment)
387
904
 
388
905
  | Variable | Purpose |
@@ -396,6 +913,11 @@ Binds every interface; the bearer gate is the security boundary.
396
913
  | `GOOGLE_WORKSPACE_DOMAIN` | Optional. Refuse tokens outside your domain |
397
914
  | `MCP_POLICY` | Optional. Policy JSON, when not written in code |
398
915
 
916
+ Every value except the last two comes off your authorization server. Pass
917
+ `OAUTH_ISSUER` alone to `discoverOAuth` and it reads the endpoints and
918
+ `jwks_uri` for you. See [Point your authorization server at it](#point-your-authorization-server-at-it)
919
+ for what to configure there first.
920
+
399
921
  ## Spec (2026-07-28)
400
922
 
401
923
  - Strict 2026 Streamable HTTP via SDK `createMcpHandler`; opt in to legacy explicitly
@@ -404,7 +926,7 @@ Binds every interface; the bearer gate is the security boundary.
404
926
  - RFC 8707 resource indicators / audience binding
405
927
  - Prefer AS support for **CIMD**; DCR is deprecated in this protocol revision
406
928
 
407
- Requires Node.js 20 or newer; CI covers Node 20, 22 and 24.
929
+ CI covers Node 24 and 26.
408
930
 
409
931
  ## License
410
932