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