mcp-authz 0.1.0 → 0.3.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)`
@@ -78,6 +254,11 @@ provider-specific assurance claim, or explicitly set `requireEmailVerified:
78
254
  false` only when the issuer contract guarantees the email another way. For
79
255
  opaque tokens, pass any SDK-compatible `tokenVerifier` plus `identityFromAuth`.
80
256
 
257
+ An agent that signs in as itself, with an OAuth client-credentials token,
258
+ carries a `sub` and no email. Set `verifier.requireEmail: false` to accept it,
259
+ and name its `sub` in a rule to grant it roles. Email and domain rules never
260
+ match it and `allowedDomain` refuses it, but a rule with no `match` admits it.
261
+
81
262
  ### `definePolicy(spec)`
82
263
 
83
264
  ```ts
@@ -128,7 +309,7 @@ tool(
128
309
 
129
310
  Handed
130
311
  `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
312
+ the shape and still reconciles at boot. It cannot check names TypeScript
132
313
  never saw. The library reads neither the environment nor the filesystem: how an
133
314
  application loads configuration is the application's business.
134
315
 
@@ -178,13 +359,18 @@ const createServer = server(
178
359
 
179
360
  Whatever the caller lacks the permission for is **never registered**, so it is absent
180
361
  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.
362
+ naming an unregistered prompt is refused before dispatch with the same `403
363
+ forbidden / policy_denied`, and that denied attempt reaches `onDecision`. A probe
364
+ for a capability somebody was never shown is exactly the one worth having in a log.
365
+ OAuth scopes do not affect visibility: a permitted capability remains visible when
366
+ the current token needs step-up.
367
+
368
+ `onAudit` emits `attempt`, followed by `success`, `failure` or `refused`, with
369
+ identity, capability, permission, resource, timestamp and duration. The server
370
+ awaits each write. A rejected `attempt` write stops the handler before it runs.
371
+ A terminal write happens after the action has reached its result, so its failure
372
+ cannot change that result. Set `onAuditError` to send the failed event and error
373
+ to your retry queue or operator alert.
188
374
 
189
375
  The boot-time check keys prompts and resources as `prompt:triage_case` and
190
376
  `resource:cases`, so a prompt sharing a tool's name stays its own entry. A server
@@ -207,6 +393,142 @@ A handler never checks the permission it declared, because `server` checked it b
207
393
  registering. Call `can` when one handler branches on a second permission, such as
208
394
  returning extra fields to an admin.
209
395
 
396
+ ### Where the audit events go
397
+
398
+ The downstream API sees one service account. These events are the only record
399
+ tying a person to an action, so they are worth sending somewhere a security team
400
+ already looks — their SIEM, their warehouse, the log pipeline behind their
401
+ dashboards. There is no sink to configure and no adapter to install: each hook is
402
+ a function, and the useful ones are one line.
403
+
404
+ ```ts
405
+ onAudit: (event) => logger.info(event), // structured log → wherever it already ships
406
+ onAudit: (event) => db.insert(auditLog).values(event),
407
+ onDecision: (event) => logger.warn(event),
408
+ ```
409
+
410
+ **Wire both.** `onAudit` sees calls that were permitted. `onDecision` sees the
411
+ refusals — including somebody naming a capability they were never shown, which is
412
+ the single most interesting line in the whole log. They are separate hooks because
413
+ they happen in different places: one wraps the handler, the other answers the
414
+ request before dispatch.
415
+
416
+ **Keep third-party HTTP off the hot path.** The `attempt` write is awaited, and
417
+ rejecting it stops the call before it runs — that is what makes the trail
418
+ fail-closed, and it means a webhook here puts somebody else's uptime in front of
419
+ your tools. Write locally, ship asynchronously; if you must call out, do it from
420
+ `onAuditError`, or from the terminal events, which cannot change a result that has
421
+ already happened.
422
+
423
+ **Three fields exist for the reader, not the caller.** `callId` is the same on
424
+ both events of one call and different for every other, so a store can join an
425
+ attempt to its outcome without guessing from timestamps. `domain` is the verified
426
+ Workspace domain — the nearest thing to an organisation this can prove, so key one
427
+ by `(issuer, domain)`. `emitter` names the deployment, and it is the one you have
428
+ to set yourself:
429
+
430
+ ```ts
431
+ server(definitions, { name: 'cases', version: '1.0.0', emitter: 'cases-prod', onAudit });
432
+ createMcpFetch({ ..., emitter: 'cases-prod', onDecision });
433
+ ```
434
+
435
+ Set the same value in every entry point of one deployment, or a reader cannot tell
436
+ that a refusal and a call came from the same place. Left unset the field is absent
437
+ rather than guessed. With those three, "what does this org use, and who used it"
438
+ is a group-by rather than a schema migration.
439
+
440
+ **Every event says what it is.** `mcp_authz.audit.v1` and
441
+ `mcp_authz.decision.v1` share half their fields and land in the same store, often
442
+ for years, so each carries its own `type`. Query on that rather than on which
443
+ fields happen to be present:
444
+
445
+ ```ts
446
+ onAudit: (event) => sink.write({ ...event, index: event.type }); // event.type is a literal union
447
+ ```
448
+
449
+ A new optional field does not move the `v1`. Changing what an existing field means
450
+ does, because a stored query cannot tell that apart.
451
+
452
+ There is no dashboard here, and there should not be: it would mean storage,
453
+ retention, its own authentication and a tenancy model, competing with the tool
454
+ your security team already pays for. The events are JSON with a stable shape —
455
+ send them there.
456
+
457
+ ### Human approval
458
+
459
+ Some actions want a second person even when the caller is permitted. Declare it
460
+ on the capability and the call waits for an answer before it runs:
461
+
462
+ ```ts
463
+ tool(
464
+ 'delete_run',
465
+ {
466
+ permission: 'testrail:delete',
467
+ // `true` for every call, or a predicate when only some arguments warrant it
468
+ approval: ({ force }) => force,
469
+ audit: ({ id }) => `run:${id}`,
470
+ },
471
+ deleteRun,
472
+ );
473
+
474
+ server(definitions, {
475
+ name: 'acme',
476
+ version: '1.0.0',
477
+ onApproval: async (request) => {
478
+ const reply = await askInSlack('#ops', request); // request carries who, what, and the arguments
479
+ return reply.ok ? { approved: true, by: reply.user } : { approved: false, reason: reply.text };
480
+ },
481
+ });
482
+ ```
483
+
484
+ `{ approved: true }` will not compile without `by`, and an approval that arrives
485
+ naming nobody is refused at runtime as well. The type is the first line and not
486
+ the only one: an adapter written in untyped code, or one handing back a JSON
487
+ reply from a chat tool, can still produce an anonymous yes, and that would run a
488
+ destructive call under an audit trail claiming somebody had agreed to it.
489
+
490
+ An approval nobody's name is on is not a second pair of eyes. The approver's
491
+ name lands on the `success` audit event beside the caller's, the only place both
492
+ people appear, because the downstream API still sees one service account.
493
+
494
+ This is not the permission check repeated. The caller already holds
495
+ `testrail:delete`; approval is the separate question of whether _this_ call
496
+ should happen. It is also the one check here that cannot be a decision about
497
+ whether to register something, because it turns on arguments that do not exist
498
+ until the call. An unapproved capability is therefore visible in `tools/list` and
499
+ refused on invocation, unlike an unpermitted one.
500
+
501
+ **Silence is a refusal.** `approvalTimeoutMs` defaults to 45s, under the 60s
502
+ idle timeout most proxies ship with. An `onApproval` that throws refuses too,
503
+ rather than letting the action through on the strength of a Slack outage, and an
504
+ answer arriving after the deadline changes nothing: the caller has already been
505
+ told no.
506
+
507
+ A refusal reaches the handler's place as an `ApprovalRefusedError` carrying the
508
+ capability and, when there was one, the person who declined. The audit event is
509
+ `phase: 'refused'` with `decision: 'deny'`, and its `durationMs` covers the wait,
510
+ so the log shows what a call actually cost rather than what the handler did.
511
+
512
+ **A capability that asks for a person while no sink can be reached fails at
513
+ boot**, not at the first destructive call. `server(...)` and `gate(...)` both
514
+ refuse to wire it, the same way an unreachable capability refuses to start.
515
+
516
+ Nothing here is durable, and it does not need to be. The caller is still on the
517
+ other end of an open request, so a process that dies mid-question takes the
518
+ request with it and the action correctly did not happen. Durability is only owed
519
+ once you have told a caller you are done and promised to act later, which is a
520
+ promise this never makes.
521
+
522
+ If you need approval that outlives the request, keep the same seam: have
523
+ `onApproval` write a pending row, return `{ approved: false, reason: 'ticket
524
+ appr_123' }`, and add a tool of your own that polls it. The store lives in your
525
+ application, where you can see it, and this package stays something you install
526
+ rather than something you run.
527
+
528
+ `gate()` takes the same options for tools you did not write: an `approval` map
529
+ keyed exactly like `permissions`, plus `onApproval`. Adding a person in front of
530
+ somebody else's destructive tool is the best reason to reach for it.
531
+
210
532
  ### `gate(server, principal, permissions)`
211
533
 
212
534
  For an MCP server you already have, from another package or one you are not ready
@@ -235,6 +557,127 @@ This needs the hook because the SDK keeps a built server's tool list private, so
235
557
  nothing can filter what it never saw. Pass the same map as `permissions` to
236
558
  `createMcpFetch` and the boot-time check still covers the roles side.
237
559
 
560
+ ### `recordCapabilities(factory)` — building that map
561
+
562
+ The map above has to name every capability the server registers, and nothing
563
+ produced it: you wrote it by hand and it drifted quietly. `mcp-authz/testing`
564
+ reads it off the server instead.
565
+
566
+ ```ts
567
+ import { recordCapabilities, toPermissionsModule } from 'mcp-authz/testing';
568
+
569
+ const { names, fingerprints } = await recordCapabilities(() => buildServer(TEST_CONFIG));
570
+
571
+ expect(names).toEqual(Object.keys(PERMISSIONS).sort());
572
+ expect(fingerprints).toMatchSnapshot();
573
+ ```
574
+
575
+ It builds your server, connects a client over an in-memory transport, and lists
576
+ tools, prompts, resources and templates, labelled the way `gate()` labels them.
577
+ Build it **ungated**: a gated server answers per principal, so listing one hands
578
+ you a map missing exactly the capabilities that most need a price.
579
+
580
+ `toPermissionsModule(record)` renders the starting map as TypeScript source —
581
+ `as const` with the derived permission type — every capability priced
582
+ `TODO:unassigned`, which no role grants, so reconciliation refuses the boot until
583
+ a person has decided what each one costs. Permissions are never guessed from
584
+ `readOnlyHint`: the specification says annotations are hints and that tool-use
585
+ decisions must not be made from them.
586
+
587
+ `gate()` already refuses to start on a capability with no price, which covers a
588
+ dependency that adds a tool. `fingerprints` covers the one it cannot see — a
589
+ capability that keeps its name while its description, input schema, prompt
590
+ arguments or URI template change underneath. Each digests the whole definition
591
+ as served, so a snapshot turns that into a diff on the pull request. Nothing is
592
+ enforced at boot: a digest in production is a second source of truth, and would
593
+ make a description edit an outage.
594
+
595
+ `@modelcontextprotocol/client` is an optional peer, needed only by this subpath.
596
+
597
+ ### `mcp-authz/openapi` — the same bet on an HTTP API
598
+
599
+ An OpenAPI document is the other catalogue an agent reads. A tool that is never
600
+ registered is a tool the model never sees; an operation that is never in the
601
+ served document is an operation the agent never calls. Same move, same policy,
602
+ same audit events, on `operationId` instead of a tool name.
603
+
604
+ ```ts
605
+ import { createOpenApiFetch, recordOperations } from 'mcp-authz/openapi';
606
+ import { toPermissionsModule } from 'mcp-authz/testing';
607
+
608
+ // The map, generated from the document rather than written by hand.
609
+ // recordOperations(spec).names → toPermissionsModule → PERMISSIONS
610
+ export default createOpenApiFetch({
611
+ spec,
612
+ permissions: PERMISSIONS, // { getCase: 'cases:read', deleteCase: 'cases:delete', … }
613
+ resourceServerUrl: new URL(process.env.API_PUBLIC_URL!),
614
+ oauthMetadata,
615
+ policy,
616
+ onAudit: (event) => logger.info(event),
617
+ upstream: (request) => app.fetch(request), // your API, unchanged
618
+ });
619
+ ```
620
+
621
+ Dana fetches `/openapi.json` and gets three read operations. Alice fetches the
622
+ same path and gets the write and the delete too. Dana calling `DELETE
623
+ /cases/C1234` anyway is refused with the permission she lacks — the smaller
624
+ document was a context saving, never the boundary.
625
+
626
+ **Anything the document does not describe is refused**, with a 404 that says so.
627
+ That is the same rule as `gate()`: a capability nobody priced is reachable by
628
+ everyone or by nobody, with no error to read. Routes you deliberately keep out
629
+ of the spec — a health check, static files — belong outside this wrapper rather
630
+ than behind it.
631
+
632
+ `recordOperations(spec)` needs the document and nothing else: no running server,
633
+ no introspection, and it refuses an operation with no `operationId`, because a
634
+ generated fallback name is a second naming scheme that changes under a path
635
+ rename. Boot fails on an operation the map does not price, on a map entry naming
636
+ an operation the document no longer has, and on a permission no role grants.
637
+
638
+ Audit events are the ones you already have — `mcp_authz.audit.v1` with
639
+ `kind: 'operation'`, the `operationId` as `name`, and the concrete path as
640
+ `resource` — so one query covers both surfaces of the same product.
641
+
642
+ This entry point does not import the MCP handler, its route classification or
643
+ its scope step-up: it is its own bundle, ~10 kB plus the policy and verifier
644
+ chunks it shares.
645
+
646
+ ### `createMcpProxy` — URL-only upstream
647
+
648
+ When there is no builder hook, `mcp-authz/proxy` sits in front of a vendor MCP
649
+ reached only by URL:
650
+
651
+ ```ts
652
+ import { createMcpProxy } from 'mcp-authz/proxy';
653
+
654
+ export default createMcpProxy({
655
+ resourceServerUrl: new URL(process.env.MCP_PUBLIC_URL!),
656
+ oauthMetadata: {/* same as createMcpFetch */},
657
+ verifier: { jwksUri: process.env.OAUTH_JWKS_URI! },
658
+ policy,
659
+ permissions: PERMISSIONS, // from recordUpstream → toPermissionsModule
660
+ resourceUris: RESOURCE_URIS, // same module; a read names a URI, not a label
661
+ upstream: {
662
+ url: process.env.UPSTREAM_URL!,
663
+ bearer: process.env.UPSTREAM_TOKEN!,
664
+ },
665
+ });
666
+ ```
667
+
668
+ Record the upstream with `recordUpstream` or `mcp-authz record --upstream`, price
669
+ the map, deploy the proxy.
670
+
671
+ The proxy is stricter than embed mode, because nothing downstream of it re-checks
672
+ anything and it forwards on a service credential that outranks the caller.
673
+ Routing headers that disagree with the body are refused rather than forwarded; a
674
+ request with no routing headers is authorized from the body, which is what the
675
+ upstream will act on; and a capability the map does not price is refused outright.
676
+ The caller's `Authorization` and `Cookie` stay at the edge.
677
+
678
+ See [proxy mode](https://jagreehal.github.io/mcp-authz/typescript/proxy/) and the
679
+ [`proxy-example`](../../apps/proxy-example) app.
680
+
238
681
  ### Boot-time reconciliation
239
682
 
240
683
  Pass both `policy` and a server built by `authz`, and startup compares them:
@@ -248,7 +691,7 @@ Unreachable capability:
248
691
  granted by: no role
249
692
  ```
250
693
 
251
- An unreachable capability throws — it is dead code that looks live. A permission no capability
694
+ An unreachable capability throws, because it is dead code that looks live. A permission no capability
252
695
  requires only warns, because granting a role ahead of the tool that will use it is
253
696
  how a staged rollout works.
254
697
 
@@ -324,6 +767,10 @@ toolScopes: {
324
767
 
325
768
  Use `capabilityScopes` when prompts or resources need step-up too. Keys are a
326
769
  bare tool name (or `tool:name`), `prompt:name`, and `resource:<uri>`.
770
+ `createServer` route metadata lets startup reject keys that name no registered
771
+ capability. Startup also rejects duplicate bare/`tool:` aliases. Each scope
772
+ must contain one RFC 6749 scope token. Empty lists and space-delimited values
773
+ fail during construction.
327
774
 
328
775
  Scopes and permissions are deliberately separate axes. A scope gap is a 403 the
329
776
  client can fix by re-authorising; a permission gap is an administrator's job.
@@ -349,16 +796,77 @@ match the JSON-RPC body. Missing, malformed or dishonest routing headers are
349
796
  HTTP 400 and never reach a tool. SEP-2243 Base64 sentinel names are decoded
350
797
  before lookup, so encoding a name cannot downgrade its scope.
351
798
 
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
799
+ Choosing a scope means reading the body, because the capability is named in it
800
+ and not trustworthily in a header. That read happens **after** the bearer gate,
801
+ not before: the per-capability scope is checked against the token this request
802
+ already carries, so nobody who cannot present a valid one gets to make this
803
+ process parse anything. Only a POST is read, only up to `maxRequestBytes` (1 MiB
804
+ by default, over it a 413), and only when its content type is JSON (415
355
805
  otherwise). A GET carries no body to check its headers against, so it is held to
356
806
  the baseline rather than to whatever its headers claim.
357
807
 
808
+ ### `policy.explain(identity)` and the CLI
809
+
810
+ The decision tells you what Alice may do. It cannot tell you why, and why is
811
+ what gets asked when somebody is surprised.
812
+
813
+ ```ts
814
+ const { principal, matched, deniedBy } = policy.explain(identity);
815
+ ```
816
+
817
+ `matched` carries every rule that fired, each with its index in your `rules`
818
+ array, so the answer points at the line an administrator edits. `deniedBy` is
819
+ set when a deny emptied the grant. Nothing on the request path calls this, so
820
+ it costs a served request nothing.
821
+
822
+ The same two questions from a terminal, with no server running:
823
+
824
+ ```bash
825
+ npx mcp-authz explain policy.json --identity alice.json --capabilities caps.json
826
+ ```
827
+
828
+ ```text
829
+ alice@acme.com
830
+
831
+ Matched rules
832
+ rule 0 domain=acme.com -> reader
833
+ rule 1 email=alice@acme.com -> editor
834
+ rule 2 org.groups=qa-leads -> admin
835
+
836
+ Effective roles
837
+ reader
838
+ editor
839
+ admin
840
+
841
+ Permissions
842
+ cases:read
843
+ cases:write
844
+ * (every permission)
845
+
846
+ Capabilities
847
+ get_case
848
+ update_case
849
+ prompt:triage_case
850
+ ```
851
+
852
+ `--identity` takes an `Identity` or a decoded token payload (`iss`, `sub`,
853
+ `email`, `email_verified`, `hd`), or `-` to read one from stdin. It answers a
854
+ what-if, so it trusts the file it is handed rather than verifying a signature.
855
+
856
+ ```bash
857
+ npx mcp-authz check policy.json --capabilities caps.json
858
+ ```
859
+
860
+ `check` runs the same `reconcile` your server runs at boot, so a capability no
861
+ role can reach fails the pull request rather than the deploy. It exits 1 on that
862
+ and on an invalid policy, and 0 on an unused permission, which stays a warning
863
+ because granting a role ahead of the tool that will use it is how a staged
864
+ rollout works.
865
+
358
866
  ### `mcp-authz/policy`
359
867
 
360
- The policy half on its own — `definePolicy`, `definePermissions`,
361
- `createPrincipal`, `Principal`, `Identity`, `reconcile` — with no MCP code in
868
+ The policy half on its own (`definePolicy`, `definePermissions`,
869
+ `createPrincipal`, `Principal`, `Identity`, `reconcile`) with no MCP code in
362
870
  the module graph:
363
871
 
364
872
  ```ts
@@ -383,6 +891,20 @@ await listenMcp(fetch, { port: 8200, name: 'acme-mcp', info: { resource: url } }
383
891
 
384
892
  Binds every interface; the bearer gate is the security boundary.
385
893
 
894
+ ## See it work, without an authorization server
895
+
896
+ ```bash
897
+ git clone https://github.com/jagreehal/mcp-authz && cd mcp-authz && pnpm install
898
+ pnpm --filter mcp-authz-node-example dev # :8200, local dev key
899
+ pnpm --filter mcp-authz-node-example token dana@acme.com # reader
900
+ pnpm --filter mcp-authz-node-example token alice@acme.com # editor
901
+ ```
902
+
903
+ Paste a token into the MCP Inspector. Dana sees `whoami` and `get_case`, Alice
904
+ also sees `update_case`, and `sam@other.com` gets HTTP 403 before any tool runs.
905
+ The full walkthrough, including scope step-up and an IdP group claim, is in
906
+ [apps/node-example](https://github.com/jagreehal/mcp-authz/tree/main/apps/node-example).
907
+
386
908
  ## Configuration (typical deployment)
387
909
 
388
910
  | Variable | Purpose |
@@ -396,6 +918,11 @@ Binds every interface; the bearer gate is the security boundary.
396
918
  | `GOOGLE_WORKSPACE_DOMAIN` | Optional. Refuse tokens outside your domain |
397
919
  | `MCP_POLICY` | Optional. Policy JSON, when not written in code |
398
920
 
921
+ Every value except the last two comes off your authorization server. Pass
922
+ `OAUTH_ISSUER` alone to `discoverOAuth` and it reads the endpoints and
923
+ `jwks_uri` for you. See [Point your authorization server at it](#point-your-authorization-server-at-it)
924
+ for what to configure there first.
925
+
399
926
  ## Spec (2026-07-28)
400
927
 
401
928
  - Strict 2026 Streamable HTTP via SDK `createMcpHandler`; opt in to legacy explicitly
@@ -404,7 +931,7 @@ Binds every interface; the bearer gate is the security boundary.
404
931
  - RFC 8707 resource indicators / audience binding
405
932
  - Prefer AS support for **CIMD**; DCR is deprecated in this protocol revision
406
933
 
407
- Requires Node.js 20 or newer; CI covers Node 20, 22 and 24.
934
+ CI covers Node 24 and 26.
408
935
 
409
936
  ## License
410
937