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 +553 -26
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +228 -0
- package/dist/index.d.ts +31 -209
- package/dist/index.js +417 -544
- package/dist/ladder-CUzOKudC.js +407 -0
- package/dist/ladder-D18eJ7tD.d.ts +32 -0
- package/dist/openapi.d.ts +112 -0
- package/dist/openapi.js +254 -0
- package/dist/permissions-module-DxCHuE-N.d.ts +31 -0
- package/dist/policy-BBp3Jq6G.js +226 -0
- package/dist/{policy-CnQj53Hq.d.ts → policy-DuZbwrKf.d.ts} +29 -1
- package/dist/policy.d.ts +2 -2
- package/dist/policy.js +1 -202
- package/dist/proxy.d.ts +46 -0
- package/dist/proxy.js +492 -0
- package/dist/testing.d.ts +44 -0
- package/dist/testing.js +176 -0
- package/dist/tools-BQE1O-7P.d.ts +271 -0
- package/dist/verifier-D6VIAYuT.js +152 -0
- package/dist/verifier-DF6gUMQ6.d.ts +84 -0
- package/package.json +35 -11
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
a
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
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
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
361
|
-
`createPrincipal`, `Principal`, `Identity`, `reconcile`
|
|
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
|
-
|
|
934
|
+
CI covers Node 24 and 26.
|
|
408
935
|
|
|
409
936
|
## License
|
|
410
937
|
|