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/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { a as policyDenied, i as emitDecision, n as jwksVerifier, o as principalLabel, r as verifierFor, s as AccessDeniedError, t as identityFromAuth } from "./verifier-D6VIAYuT.js";
|
|
2
|
+
import { d as scopesForCapability, f as scopesFromMcpHeaders, i as runScopedGate, r as permissionForRoute, u as decodeMcpNameHeader } from "./ladder-CUzOKudC.js";
|
|
3
|
+
import { i as reconcile, n as definePermissions, r as definePolicy, t as createPrincipal } from "./policy-BBp3Jq6G.js";
|
|
4
|
+
import { McpServer, UriTemplate, createMcpHandler, getOAuthProtectedResourceMetadataUrl, oauthMetadataResponse, requireBearerAuth } from "@modelcontextprotocol/server";
|
|
4
5
|
//#region src/discovery.ts
|
|
5
6
|
/**
|
|
6
7
|
* Fetch the authorization server's own metadata instead of hand-copying it.
|
|
@@ -34,6 +35,310 @@ async function discoverOAuth(issuer, options = {}) {
|
|
|
34
35
|
throw new Error(`No OAuth metadata for '${issuer}'. Tried:\n ${tried.join("\n ")}\nPass \`oauthMetadata\` directly if your AS publishes it somewhere else.`);
|
|
35
36
|
}
|
|
36
37
|
//#endregion
|
|
38
|
+
//#region src/tools.ts
|
|
39
|
+
const AUDIT_EVENT_TYPE = "mcp_authz.audit.v1";
|
|
40
|
+
/** Thrown into the handler's place when a person said no, or said nothing in time. */
|
|
41
|
+
var ApprovalRefusedError = class extends Error {
|
|
42
|
+
capability;
|
|
43
|
+
by;
|
|
44
|
+
constructor(capability, reason, by) {
|
|
45
|
+
super(`${capability} was not approved: ${reason}`);
|
|
46
|
+
this.name = "ApprovalRefusedError";
|
|
47
|
+
this.capability = capability;
|
|
48
|
+
this.by = by;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Everything that wraps a permitted invocation, whatever kind it is, and
|
|
53
|
+
* whether the capability was defined here or wrapped by `gate`.
|
|
54
|
+
*
|
|
55
|
+
* Recording and asking live together because they share one decision: what the
|
|
56
|
+
* call touches. Splitting them would mean calling `audit(args)` twice and
|
|
57
|
+
* hoping the two answers stayed the same.
|
|
58
|
+
*/
|
|
59
|
+
function guarding(kind, name, permission, audit, principal, guards) {
|
|
60
|
+
const { onAudit, onAuditError, onApproval, approvalTimeoutMs, needsApproval, emitter } = guards;
|
|
61
|
+
return async (args, run) => {
|
|
62
|
+
const asking = needsApproval?.(args) === true;
|
|
63
|
+
if (!onAudit && !asking) return run();
|
|
64
|
+
const started = performance.now();
|
|
65
|
+
const resource = audit?.(args);
|
|
66
|
+
const base = {
|
|
67
|
+
issuer: principal.issuer,
|
|
68
|
+
sub: principal.sub,
|
|
69
|
+
email: principal.email,
|
|
70
|
+
domain: principal.domain,
|
|
71
|
+
kind,
|
|
72
|
+
name,
|
|
73
|
+
permission,
|
|
74
|
+
resource
|
|
75
|
+
};
|
|
76
|
+
const audited = {
|
|
77
|
+
...base,
|
|
78
|
+
callId: crypto.randomUUID(),
|
|
79
|
+
...emitter ? { emitter } : {}
|
|
80
|
+
};
|
|
81
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
82
|
+
await onAudit?.({
|
|
83
|
+
type: AUDIT_EVENT_TYPE,
|
|
84
|
+
...audited,
|
|
85
|
+
decision: "allow",
|
|
86
|
+
phase: "attempt",
|
|
87
|
+
at: now()
|
|
88
|
+
});
|
|
89
|
+
let approvedBy;
|
|
90
|
+
if (asking) {
|
|
91
|
+
const decision = await decideWithin(() => onApproval({
|
|
92
|
+
...base,
|
|
93
|
+
arguments: args,
|
|
94
|
+
at: now()
|
|
95
|
+
}), approvalTimeoutMs);
|
|
96
|
+
if (decision.approved && (typeof decision.by !== "string" || decision.by.trim().length === 0)) {
|
|
97
|
+
const reason = "approval did not name who gave it";
|
|
98
|
+
await deliverTerminalAudit(onAudit, onAuditError, {
|
|
99
|
+
...audited,
|
|
100
|
+
decision: "deny",
|
|
101
|
+
phase: "refused",
|
|
102
|
+
at: now(),
|
|
103
|
+
durationMs: performance.now() - started,
|
|
104
|
+
error: reason
|
|
105
|
+
});
|
|
106
|
+
throw new ApprovalRefusedError(name, reason);
|
|
107
|
+
}
|
|
108
|
+
if (!decision.approved) {
|
|
109
|
+
const reason = decision.reason ?? "refused";
|
|
110
|
+
await deliverTerminalAudit(onAudit, onAuditError, {
|
|
111
|
+
...audited,
|
|
112
|
+
decision: "deny",
|
|
113
|
+
phase: "refused",
|
|
114
|
+
approvedBy: decision.by,
|
|
115
|
+
at: now(),
|
|
116
|
+
durationMs: performance.now() - started,
|
|
117
|
+
error: reason
|
|
118
|
+
});
|
|
119
|
+
throw new ApprovalRefusedError(name, reason, decision.by);
|
|
120
|
+
}
|
|
121
|
+
approvedBy = decision.by;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const result = await run();
|
|
125
|
+
await deliverTerminalAudit(onAudit, onAuditError, {
|
|
126
|
+
...audited,
|
|
127
|
+
decision: "allow",
|
|
128
|
+
phase: "success",
|
|
129
|
+
approvedBy,
|
|
130
|
+
at: now(),
|
|
131
|
+
durationMs: performance.now() - started
|
|
132
|
+
});
|
|
133
|
+
return result;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
await deliverTerminalAudit(onAudit, onAuditError, {
|
|
136
|
+
...audited,
|
|
137
|
+
decision: "allow",
|
|
138
|
+
phase: "failure",
|
|
139
|
+
approvedBy,
|
|
140
|
+
at: now(),
|
|
141
|
+
durationMs: performance.now() - started,
|
|
142
|
+
error: error instanceof Error ? error.message : String(error)
|
|
143
|
+
});
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
async function deliverTerminalAudit(sink, onError, event) {
|
|
149
|
+
try {
|
|
150
|
+
await sink?.({
|
|
151
|
+
type: AUDIT_EVENT_TYPE,
|
|
152
|
+
...event
|
|
153
|
+
});
|
|
154
|
+
} catch (error) {
|
|
155
|
+
try {
|
|
156
|
+
await onError?.({
|
|
157
|
+
error,
|
|
158
|
+
event: {
|
|
159
|
+
type: AUDIT_EVENT_TYPE,
|
|
160
|
+
...event
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
} catch {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Wait for a person, but not forever.
|
|
168
|
+
*
|
|
169
|
+
* The deadline is the whole reason this stays a library rather than a service:
|
|
170
|
+
* the caller is still on the other end of an open request, so nothing has to
|
|
171
|
+
* survive a restart — a process that dies mid-question takes the request with
|
|
172
|
+
* it, and the action correctly did not happen.
|
|
173
|
+
*
|
|
174
|
+
* The ceiling is deliberate: blocking, in-memory, one process. Keep the
|
|
175
|
+
* deadline under the idle timeout of whatever proxy sits in front, and reach
|
|
176
|
+
* for MCP progress notifications (or a pending-ticket tool of your own) if you
|
|
177
|
+
* need longer.
|
|
178
|
+
*/
|
|
179
|
+
async function decideWithin(ask, ms) {
|
|
180
|
+
let timer;
|
|
181
|
+
const expiry = new Promise((resolve) => {
|
|
182
|
+
timer = setTimeout(() => resolve({
|
|
183
|
+
approved: false,
|
|
184
|
+
reason: `no answer within ${ms}ms`
|
|
185
|
+
}), ms);
|
|
186
|
+
});
|
|
187
|
+
try {
|
|
188
|
+
return await Promise.race([Promise.resolve(ask()), expiry]);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
approved: false,
|
|
192
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
193
|
+
};
|
|
194
|
+
} finally {
|
|
195
|
+
clearTimeout(timer);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function toolsFor(handlerContext) {
|
|
199
|
+
return function tool(name, config, handler) {
|
|
200
|
+
const { permission, audit, approval, ...toolConfig } = config;
|
|
201
|
+
return {
|
|
202
|
+
label: name,
|
|
203
|
+
kind: "tool",
|
|
204
|
+
routeName: name,
|
|
205
|
+
permission,
|
|
206
|
+
approval: Boolean(approval),
|
|
207
|
+
register(server, principal, context, guards) {
|
|
208
|
+
if (!principal.can(permission)) return;
|
|
209
|
+
const record = guarding("tool", name, permission, audit, principal, {
|
|
210
|
+
...guards,
|
|
211
|
+
needsApproval: approvalPredicate(approval)
|
|
212
|
+
});
|
|
213
|
+
server.registerTool(name, toolConfig, (async (args) => {
|
|
214
|
+
return record(args, () => handler(args, handlerContext(context, principal)));
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Approval is decided per call, not per registration, because it turns on the
|
|
222
|
+
* arguments — which is why it is the one check here that cannot be a decision
|
|
223
|
+
* about whether to register something.
|
|
224
|
+
*/
|
|
225
|
+
function approvalPredicate(approval) {
|
|
226
|
+
if (approval === void 0 || approval === false) return void 0;
|
|
227
|
+
return approval === true ? () => true : approval;
|
|
228
|
+
}
|
|
229
|
+
function promptsFor(handlerContext) {
|
|
230
|
+
return function prompt(name, config, handler) {
|
|
231
|
+
const { permission, audit, approval, ...promptConfig } = config;
|
|
232
|
+
return {
|
|
233
|
+
label: `prompt:${name}`,
|
|
234
|
+
kind: "prompt",
|
|
235
|
+
routeName: name,
|
|
236
|
+
permission,
|
|
237
|
+
approval: Boolean(approval),
|
|
238
|
+
register(server, principal, context, guards) {
|
|
239
|
+
if (!principal.can(permission)) return;
|
|
240
|
+
const record = guarding("prompt", name, permission, audit, principal, {
|
|
241
|
+
...guards,
|
|
242
|
+
needsApproval: approvalPredicate(approval)
|
|
243
|
+
});
|
|
244
|
+
server.registerPrompt(name, promptConfig, (async (args) => {
|
|
245
|
+
return record(args, () => handler(args, handlerContext(context, principal)));
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
function resourcesFor(handlerContext) {
|
|
252
|
+
return function resource(name, config, handler) {
|
|
253
|
+
const { permission, audit, approval, uri, ...metadata } = config;
|
|
254
|
+
const routeTemplate = typeof uri === "string" ? new UriTemplate(uri) : uri.uriTemplate;
|
|
255
|
+
return {
|
|
256
|
+
label: `resource:${name}`,
|
|
257
|
+
kind: "resource",
|
|
258
|
+
routeName: typeof uri === "string" ? uri : routeTemplate.toString(),
|
|
259
|
+
routeMatches: (target) => routeTemplate.match(target) !== null,
|
|
260
|
+
permission,
|
|
261
|
+
approval: Boolean(approval),
|
|
262
|
+
register(server, principal, context, guards) {
|
|
263
|
+
if (!principal.can(permission)) return;
|
|
264
|
+
const record = guarding("resource", name, permission, audit, principal, {
|
|
265
|
+
...guards,
|
|
266
|
+
needsApproval: approvalPredicate(approval)
|
|
267
|
+
});
|
|
268
|
+
server.registerResource(name, uri, metadata, (async (target) => {
|
|
269
|
+
return record(target, () => handler(target, handlerContext(context, principal)));
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function buildServer(definitions, options, principalOf) {
|
|
276
|
+
const permissions = /* @__PURE__ */ new Map();
|
|
277
|
+
const routePermissions = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const definition of definitions) {
|
|
279
|
+
if (permissions.has(definition.label)) throw new Error(`'${definition.label}' is registered twice.`);
|
|
280
|
+
permissions.set(definition.label, definition.permission);
|
|
281
|
+
if (definition.routeName !== void 0) routePermissions.set(`${definition.kind}:${definition.routeName}`, definition.permission);
|
|
282
|
+
}
|
|
283
|
+
const unaskable = definitions.filter((definition) => definition.approval).map((d) => d.label);
|
|
284
|
+
if (unaskable.length > 0 && !options.onApproval) throw new Error(`MCP approval is not configured\n\nThese capabilities ask for a person:\n${unaskable.map((label) => ` ${label}`).join("\n")}\n\nPass \`onApproval\` to server(), or drop \`approval\` from them.`);
|
|
285
|
+
const factory = (context) => {
|
|
286
|
+
const principal = principalOf(context);
|
|
287
|
+
const enabled = definitions.filter((definition) => principal.can(definition.permission));
|
|
288
|
+
const declared = {
|
|
289
|
+
...enabled.some((definition) => definition.kind === "tool") ? { tools: {} } : {},
|
|
290
|
+
...enabled.some((definition) => definition.kind === "prompt") ? { prompts: {} } : {},
|
|
291
|
+
...enabled.some((definition) => definition.kind === "resource") ? { resources: {} } : {}
|
|
292
|
+
};
|
|
293
|
+
const { onAudit, onAuditError, onApproval, emitter, approvalTimeoutMs = 45e3, mcp: mcpOptions = {}, ...serverInfo } = options;
|
|
294
|
+
const server = new McpServer(serverInfo, {
|
|
295
|
+
...mcpOptions,
|
|
296
|
+
capabilities: {
|
|
297
|
+
...mcpOptions.capabilities,
|
|
298
|
+
...declared
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
const guards = {
|
|
302
|
+
onAudit,
|
|
303
|
+
onAuditError,
|
|
304
|
+
onApproval,
|
|
305
|
+
approvalTimeoutMs,
|
|
306
|
+
emitter
|
|
307
|
+
};
|
|
308
|
+
for (const definition of enabled) definition.register(server, principal, context, guards);
|
|
309
|
+
return server;
|
|
310
|
+
};
|
|
311
|
+
return Object.assign(factory, {
|
|
312
|
+
permissions,
|
|
313
|
+
routePermissions,
|
|
314
|
+
routeNameFor: (kind, name) => definitions.find((definition) => definition.kind === kind && (definition.routeMatches?.(name) ?? definition.routeName === name))?.routeName,
|
|
315
|
+
permissionForRoute: (kind, name) => definitions.find((definition) => definition.kind === kind && (definition.routeMatches?.(name) ?? definition.routeName === name))?.permission
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Everything bound to one policy: `permission` accepts only what that policy can
|
|
320
|
+
* grant, in all four places, from one call.
|
|
321
|
+
*
|
|
322
|
+
* The policy is a type carrier here and is never invoked. Authorization happens
|
|
323
|
+
* once per request when the principal is resolved, not once per definition.
|
|
324
|
+
*
|
|
325
|
+
* ```ts
|
|
326
|
+
* const { tool, prompt, resource, server } = authz(policy);
|
|
327
|
+
* ```
|
|
328
|
+
*/
|
|
329
|
+
function bindAuthz(_permissions, principalOf, handlerContext) {
|
|
330
|
+
return {
|
|
331
|
+
tool: toolsFor(handlerContext),
|
|
332
|
+
prompt: promptsFor(handlerContext),
|
|
333
|
+
resource: resourcesFor(handlerContext),
|
|
334
|
+
server: (definitions, options) => buildServer(definitions, options, principalOf)
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function authz(policy, options) {
|
|
338
|
+
if (options) return bindAuthz(policy, options.principal, (context) => context);
|
|
339
|
+
return bindAuthz(policy, (principal) => principal, (_context, principal) => ({ principal }));
|
|
340
|
+
}
|
|
341
|
+
//#endregion
|
|
37
342
|
//#region src/gate.ts
|
|
38
343
|
function gate(server, principal, permissions, options = {}) {
|
|
39
344
|
const required = isMapLike(permissions) ? new Map(permissions) : new Map(Object.entries(permissions));
|
|
@@ -42,47 +347,22 @@ function gate(server, principal, permissions, options = {}) {
|
|
|
42
347
|
const label = kind === "tool" ? name : `${kind}:${name}`;
|
|
43
348
|
const permission = required.get(label);
|
|
44
349
|
if (permission === void 0) throw new Error(`gate(): no permission declared for ${kind} '${name}'. Add '${label}' to the permission map, or stop registering it.`);
|
|
350
|
+
const approval = options.approval?.[label];
|
|
351
|
+
if (approval && !options.onApproval) throw new Error(`gate(): ${kind} '${name}' asks for approval, but no 'onApproval' was passed.`);
|
|
45
352
|
const last = args.length - 1;
|
|
46
353
|
const handler = args[last];
|
|
47
|
-
if (options.onAudit && typeof handler === "function") {
|
|
48
|
-
const
|
|
354
|
+
if ((options.onAudit || approval) && typeof handler === "function") {
|
|
355
|
+
const record = guarding(kind, name, permission, options.audit?.[label], principal, {
|
|
356
|
+
onAudit: options.onAudit,
|
|
357
|
+
onAuditError: options.onAuditError,
|
|
358
|
+
onApproval: options.onApproval,
|
|
359
|
+
approvalTimeoutMs: options.approvalTimeoutMs ?? 45e3,
|
|
360
|
+
needsApproval: approvalPredicate(approval),
|
|
361
|
+
...options.emitter ? { emitter: options.emitter } : {}
|
|
362
|
+
});
|
|
49
363
|
const inner = handler;
|
|
50
|
-
args[last] = async function
|
|
51
|
-
|
|
52
|
-
const base = {
|
|
53
|
-
issuer: principal.issuer,
|
|
54
|
-
sub: principal.sub,
|
|
55
|
-
email: principal.email,
|
|
56
|
-
kind,
|
|
57
|
-
name,
|
|
58
|
-
permission,
|
|
59
|
-
resource: resource?.(called[0]),
|
|
60
|
-
decision: "allow"
|
|
61
|
-
};
|
|
62
|
-
await options.onAudit?.({
|
|
63
|
-
...base,
|
|
64
|
-
phase: "attempt",
|
|
65
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
66
|
-
});
|
|
67
|
-
try {
|
|
68
|
-
const result = await inner.apply(this, called);
|
|
69
|
-
await options.onAudit?.({
|
|
70
|
-
...base,
|
|
71
|
-
phase: "success",
|
|
72
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
73
|
-
durationMs: performance.now() - started
|
|
74
|
-
});
|
|
75
|
-
return result;
|
|
76
|
-
} catch (error) {
|
|
77
|
-
await options.onAudit?.({
|
|
78
|
-
...base,
|
|
79
|
-
phase: "failure",
|
|
80
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
81
|
-
durationMs: performance.now() - started,
|
|
82
|
-
error: error instanceof Error ? error.message : String(error)
|
|
83
|
-
});
|
|
84
|
-
throw error;
|
|
85
|
-
}
|
|
364
|
+
args[last] = async function guarded(...called) {
|
|
365
|
+
return record(called[0], () => inner.apply(this, called));
|
|
86
366
|
};
|
|
87
367
|
}
|
|
88
368
|
const registered = register.apply(server, args);
|
|
@@ -108,240 +388,38 @@ function isMapLike(value) {
|
|
|
108
388
|
return typeof value === "object" && value !== null && typeof value.get === "function" && Symbol.iterator in value;
|
|
109
389
|
}
|
|
110
390
|
//#endregion
|
|
111
|
-
//#region src/identity.ts
|
|
112
|
-
var AccessDeniedError = class AccessDeniedError extends Error {
|
|
113
|
-
email;
|
|
114
|
-
reason;
|
|
115
|
-
constructor(email, reason, message) {
|
|
116
|
-
super(message);
|
|
117
|
-
this.name = "AccessDeniedError";
|
|
118
|
-
this.email = email;
|
|
119
|
-
this.reason = reason;
|
|
120
|
-
}
|
|
121
|
-
static notPermitted(email, policyHint = "the access policy") {
|
|
122
|
-
return new AccessDeniedError(email, "not_permitted", `${email} matches no rule in ${policyHint}, so they hold no permissions. Ask an administrator to grant them a role.`);
|
|
123
|
-
}
|
|
124
|
-
static noCredential(email, credentialHint = "a backend credential") {
|
|
125
|
-
return new AccessDeniedError(email, "no_credential", `${email} is permitted but has no ${credentialHint}, and no shared account is configured.`);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
//#endregion
|
|
129
|
-
//#region src/scopes.ts
|
|
130
|
-
const BASE64_SENTINEL = /^=\?base64\?([A-Za-z0-9+/]*(?:={0,2}))\?=$/;
|
|
131
|
-
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
132
|
-
/**
|
|
133
|
-
* Tools use their bare name (or `tool:name`); prompts use `prompt:name`; and
|
|
134
|
-
* resources use `resource:<uri>`. Everything else needs only the baseline.
|
|
135
|
-
*/
|
|
136
|
-
function scopesFromMcpHeaders(request, toolScopes, baseline = "mcp") {
|
|
137
|
-
const method = request.headers.get("mcp-method");
|
|
138
|
-
const rawName = request.headers.get("mcp-name");
|
|
139
|
-
const name = rawName === null ? void 0 : decodeMcpNameHeader(rawName);
|
|
140
|
-
return scopesForCapability(method ?? void 0, name, toolScopes, baseline);
|
|
141
|
-
}
|
|
142
|
-
/** Select scopes from an already validated MCP method/name pair. */
|
|
143
|
-
function scopesForCapability(method, name, capabilityScopes, baseline = "mcp") {
|
|
144
|
-
if (!name) return [baseline];
|
|
145
|
-
const key = method === "tools/call" ? capabilityScopes[name] === void 0 ? `tool:${name}` : name : method === "prompts/get" ? `prompt:${name}` : method === "resources/read" ? `resource:${name}` : void 0;
|
|
146
|
-
const required = (key ? capabilityScopes[key] : void 0) ?? baseline;
|
|
147
|
-
return typeof required === "string" ? [required] : [...required];
|
|
148
|
-
}
|
|
149
|
-
/** Decode SEP-2243's optional Base64 sentinel without accepting non-canonical input. */
|
|
150
|
-
function decodeMcpNameHeader(value) {
|
|
151
|
-
const normalized = value.trim();
|
|
152
|
-
if (!normalized.startsWith("=?base64?") || !normalized.endsWith("?=")) return normalized;
|
|
153
|
-
const encoded = BASE64_SENTINEL.exec(normalized)?.[1];
|
|
154
|
-
if (encoded === void 0 || !CANONICAL_BASE64.test(encoded)) return void 0;
|
|
155
|
-
try {
|
|
156
|
-
const binary = atob(encoded);
|
|
157
|
-
const bytes = Uint8Array.from(binary, (character) => character.codePointAt(0) ?? 0);
|
|
158
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
159
|
-
} catch {
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
//#endregion
|
|
164
|
-
//#region src/routing.ts
|
|
165
|
-
const NAME_SOURCE = {
|
|
166
|
-
"tools/call": "name",
|
|
167
|
-
"prompts/get": "name",
|
|
168
|
-
"resources/read": "uri"
|
|
169
|
-
};
|
|
170
|
-
/**
|
|
171
|
-
* Parse and validate every routing input before it can influence OAuth scopes.
|
|
172
|
-
* This mirrors the SDK's standard-header rung, including Base64 sentinel
|
|
173
|
-
* decoding, but runs before bearer authorization rather than during dispatch.
|
|
174
|
-
*/
|
|
175
|
-
function classifyScopedRequest(request, body) {
|
|
176
|
-
if (request.method.toUpperCase() !== "POST") return {
|
|
177
|
-
kind: "legacy",
|
|
178
|
-
body: void 0,
|
|
179
|
-
outcome: {
|
|
180
|
-
kind: "legacy",
|
|
181
|
-
reason: "http-method"
|
|
182
|
-
}
|
|
183
|
-
};
|
|
184
|
-
const outcome = classifyInboundRequest({
|
|
185
|
-
httpMethod: request.method,
|
|
186
|
-
...header(request, "mcp-protocol-version", "protocolVersionHeader"),
|
|
187
|
-
...header(request, "mcp-method", "mcpMethodHeader"),
|
|
188
|
-
...header(request, "mcp-name", "mcpNameHeader"),
|
|
189
|
-
body
|
|
190
|
-
});
|
|
191
|
-
if (outcome.kind === "reject") return rejected(outcome.httpStatus, outcome.code, outcome.message, outcome.data, requestId$1(body));
|
|
192
|
-
if (outcome.kind === "legacy") return {
|
|
193
|
-
kind: "legacy",
|
|
194
|
-
body,
|
|
195
|
-
outcome
|
|
196
|
-
};
|
|
197
|
-
const method = outcome.message.method;
|
|
198
|
-
if (outcome.messageKind !== "request") return {
|
|
199
|
-
kind: "modern",
|
|
200
|
-
body,
|
|
201
|
-
outcome,
|
|
202
|
-
method
|
|
203
|
-
};
|
|
204
|
-
if (request.headers.get("mcp-method") === null) return mismatch("(missing)", `the body names method ${method} but the required Mcp-Method header is absent`, body);
|
|
205
|
-
const source = Object.hasOwn(NAME_SOURCE, method) ? NAME_SOURCE[method] : void 0;
|
|
206
|
-
if (source === void 0) return {
|
|
207
|
-
kind: "modern",
|
|
208
|
-
body,
|
|
209
|
-
outcome,
|
|
210
|
-
method
|
|
211
|
-
};
|
|
212
|
-
const params = isRecord$1(outcome.message.params) ? outcome.message.params : void 0;
|
|
213
|
-
const bodyName = typeof params?.[source] === "string" ? params[source] : void 0;
|
|
214
|
-
const rawName = request.headers.get("mcp-name");
|
|
215
|
-
if (rawName === null) {
|
|
216
|
-
if (bodyName === void 0) return {
|
|
217
|
-
kind: "modern",
|
|
218
|
-
body,
|
|
219
|
-
outcome,
|
|
220
|
-
method
|
|
221
|
-
};
|
|
222
|
-
return mismatch("(missing)", `the body carries params.${source}="${bodyName}" but the required Mcp-Name header is absent`, body);
|
|
223
|
-
}
|
|
224
|
-
const decodedName = decodeMcpNameHeader(rawName);
|
|
225
|
-
if (decodedName === void 0) return mismatch(rawName.trim(), "the Mcp-Name header carries an invalid Base64 sentinel value", body);
|
|
226
|
-
if (bodyName !== void 0 && decodedName !== bodyName) return mismatch(rawName.trim(), `the body carries params.${source}="${bodyName}" but the Mcp-Name header names "${decodedName}"`, body);
|
|
227
|
-
return {
|
|
228
|
-
kind: "modern",
|
|
229
|
-
body,
|
|
230
|
-
outcome,
|
|
231
|
-
method,
|
|
232
|
-
...bodyName === void 0 ? {} : { name: bodyName }
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
function header(request, name, property) {
|
|
236
|
-
const value = request.headers.get(name);
|
|
237
|
-
return value === null ? {} : { [property]: value };
|
|
238
|
-
}
|
|
239
|
-
function mismatch(headerValue, bodyDescription, body) {
|
|
240
|
-
return rejected(400, -32020, `Bad Request: the request headers and body disagree: ${bodyDescription}`, { mismatch: {
|
|
241
|
-
header: headerValue,
|
|
242
|
-
body: bodyDescription
|
|
243
|
-
} }, requestId$1(body));
|
|
244
|
-
}
|
|
245
|
-
function rejected(httpStatus, code, message, data, id) {
|
|
246
|
-
return {
|
|
247
|
-
kind: "reject",
|
|
248
|
-
httpStatus,
|
|
249
|
-
code,
|
|
250
|
-
message,
|
|
251
|
-
...data === void 0 ? {} : { data },
|
|
252
|
-
id
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
function requestId$1(body) {
|
|
256
|
-
if (!isRecord$1(body)) return null;
|
|
257
|
-
const id = body.id;
|
|
258
|
-
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
259
|
-
}
|
|
260
|
-
function isRecord$1(value) {
|
|
261
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
262
|
-
}
|
|
263
|
-
//#endregion
|
|
264
|
-
//#region src/verifier.ts
|
|
265
|
-
const invalidToken = (message) => new OAuthError(OAuthErrorCode.InvalidToken, message);
|
|
266
|
-
/**
|
|
267
|
-
* A JWKS-backed verifier. Keys are fetched once and cached by `jose`, which
|
|
268
|
-
* also handles rotation, so a key roll at the AS does not need a redeploy.
|
|
269
|
-
*/
|
|
270
|
-
function jwksVerifier(options) {
|
|
271
|
-
const jwks = createRemoteJWKSet(new URL(options.jwksUri));
|
|
272
|
-
const emailClaim = options.emailClaim ?? "email";
|
|
273
|
-
const emailVerifiedClaim = options.emailVerifiedClaim ?? "email_verified";
|
|
274
|
-
const expectedResource = new URL(options.resource.href).href.split("#")[0];
|
|
275
|
-
return {
|
|
276
|
-
async verifyAccessToken(token) {
|
|
277
|
-
let payload;
|
|
278
|
-
try {
|
|
279
|
-
payload = (await jwtVerify(token, jwks, {
|
|
280
|
-
issuer: options.issuer,
|
|
281
|
-
audience: expectedResource
|
|
282
|
-
})).payload;
|
|
283
|
-
} catch (error) {
|
|
284
|
-
throw invalidToken(`Token rejected: ${error instanceof Error ? error.message : String(error)}`);
|
|
285
|
-
}
|
|
286
|
-
if (typeof payload.exp !== "number") throw invalidToken("Token has no `exp` claim.");
|
|
287
|
-
const email = payload[emailClaim];
|
|
288
|
-
if (typeof email !== "string" || !email) throw invalidToken(`Token carries no '${emailClaim}' claim, so there is no identity to map.`);
|
|
289
|
-
if ((options.requireEmailVerified ?? true) && payload[emailVerifiedClaim] !== true) throw invalidToken(`Token does not prove '${emailClaim}' with '${emailVerifiedClaim}: true'.`);
|
|
290
|
-
const sub = payload.sub;
|
|
291
|
-
if (typeof sub !== "string" || !sub) throw invalidToken("Token has no `sub` claim, so there is no stable subject to bind to.");
|
|
292
|
-
const domain = typeof payload.hd === "string" ? payload.hd : void 0;
|
|
293
|
-
if (options.allowedDomain && domain?.toLowerCase() !== options.allowedDomain.toLowerCase()) throw invalidToken(`Token is for ${domain ?? "an unknown domain"}, not ${options.allowedDomain}.`);
|
|
294
|
-
return {
|
|
295
|
-
token,
|
|
296
|
-
clientId: typeof payload.client_id === "string" ? payload.client_id : typeof payload.azp === "string" ? payload.azp : sub,
|
|
297
|
-
scopes: scopesOf(payload.scope),
|
|
298
|
-
expiresAt: payload.exp,
|
|
299
|
-
resource: options.resource,
|
|
300
|
-
extra: {
|
|
301
|
-
issuer: options.issuer,
|
|
302
|
-
sub,
|
|
303
|
-
email,
|
|
304
|
-
emailVerified: options.requireEmailVerified === false || payload[emailVerifiedClaim] === true,
|
|
305
|
-
domain,
|
|
306
|
-
claims: payload
|
|
307
|
-
}
|
|
308
|
-
};
|
|
309
|
-
},
|
|
310
|
-
identityOf: identityFromAuth
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
/** Default identity mapper for custom verifiers using `AuthInfo.extra`. */
|
|
314
|
-
function identityFromAuth(auth) {
|
|
315
|
-
const { issuer, sub, email, emailVerified, domain, claims } = auth.extra ?? {};
|
|
316
|
-
if (typeof issuer !== "string" || !issuer || typeof sub !== "string" || !sub) throw invalidToken("Verified token carried no issuer or subject.");
|
|
317
|
-
if (email !== void 0 && (typeof email !== "string" || !email)) throw invalidToken("Verified token carried an invalid email.");
|
|
318
|
-
return {
|
|
319
|
-
issuer,
|
|
320
|
-
sub,
|
|
321
|
-
email: typeof email === "string" ? email : void 0,
|
|
322
|
-
emailVerified: emailVerified === true,
|
|
323
|
-
domain: typeof domain === "string" ? domain : void 0,
|
|
324
|
-
claims: isRecord(claims) ? claims : {}
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
function isRecord(value) {
|
|
328
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
329
|
-
}
|
|
330
|
-
/** OAuth scope is a space-delimited string; some servers send an array anyway. */
|
|
331
|
-
function scopesOf(scope) {
|
|
332
|
-
if (Array.isArray(scope)) return scope.filter((s) => typeof s === "string");
|
|
333
|
-
if (typeof scope === "string") return scope.split(" ").filter(Boolean);
|
|
334
|
-
return [];
|
|
335
|
-
}
|
|
336
|
-
//#endregion
|
|
337
391
|
//#region src/handler.ts
|
|
338
392
|
/** Where the resolved per-request context rides from the gate to the factory. */
|
|
339
393
|
const CONTEXT_KEY = "mcp-authz.context";
|
|
394
|
+
const OAUTH_SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
340
395
|
function createMcpFetch(options) {
|
|
341
|
-
const { resourceServerUrl, oauthMetadata, requiredScopes = ["mcp"], supportedScopes, toolScopes, capabilityScopes, policy, createServer, contextExtraKey = CONTEXT_KEY, healthPath = "/health", legacy = "reject", maxRequestBytes = 1048576 } = options;
|
|
396
|
+
const { resourceServerUrl, oauthMetadata, requiredScopes = ["mcp"], supportedScopes, toolScopes, capabilityScopes, policy, createServer, emitter, contextExtraKey = CONTEXT_KEY, healthPath = "/health", legacy = "reject", maxRequestBytes = 1048576 } = options;
|
|
342
397
|
if (toolScopes && capabilityScopes) throw new Error("Pass either `toolScopes` or `capabilityScopes`, not both.");
|
|
398
|
+
const invalidRequiredScope = requiredScopes.find((scope) => !OAUTH_SCOPE_TOKEN.test(scope));
|
|
399
|
+
if (invalidRequiredScope !== void 0) throw new Error(`requiredScopes has an invalid scope: '${invalidRequiredScope}'.`);
|
|
400
|
+
const invalidSupportedScope = supportedScopes?.find((scope) => !OAUTH_SCOPE_TOKEN.test(scope));
|
|
401
|
+
if (invalidSupportedScope !== void 0) throw new Error(`supportedScopes has an invalid scope: '${invalidSupportedScope}'.`);
|
|
343
402
|
const scopeMap = capabilityScopes ?? toolScopes;
|
|
344
403
|
if (scopeMap && options.scopesForRequest) throw new Error("Pass a declarative scope map or `scopesForRequest`, not both.");
|
|
404
|
+
if (scopeMap && requiredScopes.length === 0) throw new Error("A declarative scope map needs at least one baseline scope.");
|
|
405
|
+
for (const [key, scopes] of Object.entries(scopeMap ?? {})) {
|
|
406
|
+
if (Array.isArray(scopes) && scopes.length === 0) throw new Error(`Scope map key '${key}' needs at least one scope.`);
|
|
407
|
+
if ((typeof scopes === "string" ? [scopes] : scopes).some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) throw new Error(`Scope map key '${key}' has an invalid scope.`);
|
|
408
|
+
}
|
|
409
|
+
const scopeRoutes = Object.keys(scopeMap ?? {}).map((key) => ({
|
|
410
|
+
key,
|
|
411
|
+
route: routeFromScopeKey(key)
|
|
412
|
+
}));
|
|
413
|
+
const named = /* @__PURE__ */ new Set();
|
|
414
|
+
for (const { route } of scopeRoutes) {
|
|
415
|
+
const canonical = `${route.kind}:${route.name}`;
|
|
416
|
+
if (named.has(canonical)) throw new Error(`Scope map names ${route.kind} '${route.name}' more than once.`);
|
|
417
|
+
named.add(canonical);
|
|
418
|
+
}
|
|
419
|
+
if (scopeMap && (createServer.permissionForRoute || createServer.routePermissions)) for (const { key, route } of scopeRoutes) {
|
|
420
|
+
const canonical = `${route.kind}:${route.name}`;
|
|
421
|
+
if ((createServer.permissionForRoute?.(route.kind, route.name) ?? createServer.routePermissions?.get(canonical)) === void 0) throw new Error(`Scope map key '${key}' names no registered capability.`);
|
|
422
|
+
}
|
|
345
423
|
const scopesForRequest = options.scopesForRequest;
|
|
346
424
|
const advertisedScopes = [.../* @__PURE__ */ new Set([
|
|
347
425
|
...requiredScopes,
|
|
@@ -363,7 +441,7 @@ function createMcpFetch(options) {
|
|
|
363
441
|
const principal = policy ? policy(identity) : await options.authorize(identity);
|
|
364
442
|
if (!isPrincipal(principal)) throw new Error("The authorizer returned an invalid principal. Use `createPrincipal` to construct one.");
|
|
365
443
|
if (principal.permissions.length === 0) {
|
|
366
|
-
await emitDecision(options.onDecision, principal, "deny", "not_permitted");
|
|
444
|
+
await emitDecision(options.onDecision, principal, "deny", "not_permitted", emitter);
|
|
367
445
|
throw AccessDeniedError.notPermitted(principalLabel(principal));
|
|
368
446
|
}
|
|
369
447
|
return principal;
|
|
@@ -372,32 +450,20 @@ function createMcpFetch(options) {
|
|
|
372
450
|
if (!policy && !options.authorize) return enrich(identity, void 0);
|
|
373
451
|
try {
|
|
374
452
|
const context = enrich ? await enrich(identity, principal) : principal;
|
|
375
|
-
await emitDecision(options.onDecision, principal, "allow");
|
|
453
|
+
await emitDecision(options.onDecision, principal, "allow", void 0, emitter);
|
|
376
454
|
return context;
|
|
377
455
|
} catch (error) {
|
|
378
|
-
if (error instanceof AccessDeniedError) await emitDecision(options.onDecision, principal, "deny", error.reason);
|
|
456
|
+
if (error instanceof AccessDeniedError) await emitDecision(options.onDecision, principal, "deny", error.reason, emitter);
|
|
379
457
|
throw error;
|
|
380
458
|
}
|
|
381
459
|
};
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
tokenVerifier
|
|
387
|
-
|
|
388
|
-
}
|
|
389
|
-
const published = typeof oauthMetadata.jwks_uri === "string" ? oauthMetadata.jwks_uri : void 0;
|
|
390
|
-
const jwksUri = options.verifier?.jwksUri ?? published;
|
|
391
|
-
if (!jwksUri) throw new Error("No JWKS to verify tokens against. Set `verifier.jwksUri`, use a custom `tokenVerifier`, or use `discoverOAuth(issuer)`, whose metadata carries `jwks_uri`.");
|
|
392
|
-
const builtIn = jwksVerifier({
|
|
393
|
-
...options.verifier,
|
|
394
|
-
jwksUri,
|
|
395
|
-
issuer: options.verifier?.issuer ?? oauthMetadata.issuer,
|
|
396
|
-
resource: options.verifier?.resource ?? resourceServerUrl
|
|
397
|
-
});
|
|
398
|
-
tokenVerifier = builtIn;
|
|
399
|
-
mapIdentity = options.identityFromAuth ?? builtIn.identityOf;
|
|
400
|
-
}
|
|
460
|
+
const { tokenVerifier, mapIdentity } = verifierFor({
|
|
461
|
+
oauthMetadata,
|
|
462
|
+
resourceServerUrl,
|
|
463
|
+
...options.verifier ? { verifier: options.verifier } : {},
|
|
464
|
+
...options.tokenVerifier ? { tokenVerifier: options.tokenVerifier } : {},
|
|
465
|
+
...options.identityFromAuth ? { identityFromAuth: options.identityFromAuth } : {}
|
|
466
|
+
});
|
|
401
467
|
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
|
|
402
468
|
const metadataOptions = {
|
|
403
469
|
oauthMetadata,
|
|
@@ -427,9 +493,6 @@ function createMcpFetch(options) {
|
|
|
427
493
|
status: 404,
|
|
428
494
|
headers: { "Content-Type": "text/plain" }
|
|
429
495
|
});
|
|
430
|
-
const preflight = Boolean(scopeMap || scopesForRequest) && request.method.toUpperCase() === "POST" ? await preflightScopedRequest(request, maxRequestBytes) : void 0;
|
|
431
|
-
if (preflight instanceof Response) return preflight;
|
|
432
|
-
const scopes = preflight ? scopeMap ? [.../* @__PURE__ */ new Set([...requiredScopes, ...scopesForCapability(preflight.route.method, preflight.route.name, scopeMap, requiredScopes[0] ?? "mcp")])] : scopesForRequest(request, preflight.route) : requiredScopes;
|
|
433
496
|
const auth = await requireBearerAuth({
|
|
434
497
|
verifier: tokenVerifier,
|
|
435
498
|
requiredScopes,
|
|
@@ -445,15 +508,30 @@ function createMcpFetch(options) {
|
|
|
445
508
|
if (error instanceof AccessDeniedError) return policyDenied(error);
|
|
446
509
|
throw error;
|
|
447
510
|
}
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
511
|
+
const scoped = Boolean(scopeMap || scopesForRequest);
|
|
512
|
+
const routed = Boolean(createServer.permissionForRoute ?? createServer.routePermissions);
|
|
513
|
+
const gateResult = await runScopedGate({
|
|
514
|
+
request,
|
|
515
|
+
auth,
|
|
516
|
+
principal,
|
|
517
|
+
maxRequestBytes,
|
|
518
|
+
requiredScopes,
|
|
519
|
+
scoped,
|
|
520
|
+
routed,
|
|
521
|
+
scopeMap,
|
|
522
|
+
resolveCapabilityScopes: (route) => {
|
|
523
|
+
if (route.method !== "resources/read" || !route.name || !scopeMap) return void 0;
|
|
524
|
+
const registered = createServer.routeNameFor?.("resource", route.name);
|
|
525
|
+
return registered === void 0 ? void 0 : scopesForCapability(route.method, registered, scopeMap, requiredScopes[0] ?? "mcp");
|
|
526
|
+
},
|
|
527
|
+
scopesForRequest,
|
|
528
|
+
resolvePermission: (route) => permissionForRoute(createServer.permissionForRoute, createServer.routePermissions, route),
|
|
529
|
+
onDecision: options.onDecision,
|
|
530
|
+
...emitter ? { emitter } : {},
|
|
455
531
|
resourceMetadataUrl
|
|
456
532
|
});
|
|
533
|
+
if (!gateResult.ok) return gateResult.response;
|
|
534
|
+
const preflight = gateResult.preflight;
|
|
457
535
|
let context;
|
|
458
536
|
try {
|
|
459
537
|
context = await enrichIdentity(identity, principal);
|
|
@@ -461,7 +539,7 @@ function createMcpFetch(options) {
|
|
|
461
539
|
if (error instanceof AccessDeniedError) return policyDenied(error);
|
|
462
540
|
throw error;
|
|
463
541
|
}
|
|
464
|
-
|
|
542
|
+
const answer = await mcp.fetch(request, {
|
|
465
543
|
authInfo: {
|
|
466
544
|
...auth,
|
|
467
545
|
extra: {
|
|
@@ -471,261 +549,56 @@ function createMcpFetch(options) {
|
|
|
471
549
|
},
|
|
472
550
|
...preflight ? { parsedBody: preflight.body } : {}
|
|
473
551
|
});
|
|
552
|
+
return legacy === "reject" ? explainLegacyRefusal(answer) : answer;
|
|
474
553
|
};
|
|
475
554
|
}
|
|
476
|
-
function policyDenied(error) {
|
|
477
|
-
return Response.json({
|
|
478
|
-
error: "forbidden",
|
|
479
|
-
reason: "policy_denied",
|
|
480
|
-
error_description: error.message
|
|
481
|
-
}, { status: 403 });
|
|
482
|
-
}
|
|
483
|
-
function permissionForRoute(resolver, permissions, route) {
|
|
484
|
-
if (!route.name || !resolver && !permissions) return void 0;
|
|
485
|
-
const kind = route.method === "tools/call" ? "tool" : route.method === "prompts/get" ? "prompt" : route.method === "resources/read" ? "resource" : void 0;
|
|
486
|
-
return kind ? resolver?.(kind, route.name) ?? permissions?.get(`${kind}:${route.name}`) : void 0;
|
|
487
|
-
}
|
|
488
|
-
async function emitDecision(sink, principal, decision, reason) {
|
|
489
|
-
await sink?.({
|
|
490
|
-
issuer: principal.issuer,
|
|
491
|
-
sub: principal.sub,
|
|
492
|
-
email: principal.email,
|
|
493
|
-
decision,
|
|
494
|
-
roles: principal.roles,
|
|
495
|
-
permissions: principal.permissions,
|
|
496
|
-
...reason ? { reason } : {},
|
|
497
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
function principalLabel(principal) {
|
|
501
|
-
return principal.email ?? `${principal.issuer}#${principal.sub}`;
|
|
502
|
-
}
|
|
503
|
-
function isPrincipal(value) {
|
|
504
|
-
if (typeof value !== "object" || value === null) return false;
|
|
505
|
-
const candidate = value;
|
|
506
|
-
return typeof candidate.sub === "string" && typeof candidate.email === "string" && Array.isArray(candidate.roles) && candidate.roles.every((role) => typeof role === "string") && Array.isArray(candidate.permissions) && candidate.permissions.every((permission) => typeof permission === "string") && typeof candidate.can === "function";
|
|
507
|
-
}
|
|
508
|
-
async function preflightScopedRequest(request, maxBytes) {
|
|
509
|
-
if (!isJsonContentType(request.headers.get("content-type"))) return protocolError(415, -32e3, "Per-capability scopes require an application/json body.");
|
|
510
|
-
const raw = await readCapped(request, maxBytes);
|
|
511
|
-
if (raw === void 0) return protocolError(413, -32e3, `Request body exceeds the ${maxBytes} byte limit.`);
|
|
512
|
-
let body;
|
|
513
|
-
try {
|
|
514
|
-
body = JSON.parse(raw);
|
|
515
|
-
} catch {
|
|
516
|
-
return protocolError(400, -32700, "Parse error: the request body is not valid JSON");
|
|
517
|
-
}
|
|
518
|
-
const route = classifyScopedRequest(request, body);
|
|
519
|
-
if (route.kind === "reject") return protocolError(route.httpStatus, route.code, route.message, route.data, route.id);
|
|
520
|
-
if (route.kind !== "modern") return protocolError(400, -32020, "Per-capability scopes require a 2026-07-28 request with matching MCP routing headers.", void 0, requestId(body));
|
|
521
|
-
return {
|
|
522
|
-
body,
|
|
523
|
-
route
|
|
524
|
-
};
|
|
525
|
-
}
|
|
526
|
-
function requestId(body) {
|
|
527
|
-
if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
|
|
528
|
-
const id = body.id;
|
|
529
|
-
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
530
|
-
}
|
|
531
|
-
function protocolError(status, code, message, data, id = null) {
|
|
532
|
-
return Response.json({
|
|
533
|
-
jsonrpc: "2.0",
|
|
534
|
-
error: {
|
|
535
|
-
code,
|
|
536
|
-
message,
|
|
537
|
-
...data === void 0 ? {} : { data }
|
|
538
|
-
},
|
|
539
|
-
id
|
|
540
|
-
}, { status });
|
|
541
|
-
}
|
|
542
555
|
/**
|
|
543
|
-
*
|
|
556
|
+
* Say what to do about the refusal every new deployment meets first.
|
|
544
557
|
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
558
|
+
* `legacy: 'reject'` is strict on purpose, but no MCP client shipping today can
|
|
559
|
+
* satisfy it: they all still open with the `initialize` handshake that 2026-07-28
|
|
560
|
+
* removed. So the first connection anybody makes fails with a bare protocol
|
|
561
|
+
* error that does not mention that a setting exists, let alone which one. The
|
|
562
|
+
* refusal stands; it just stops being a riddle.
|
|
548
563
|
*/
|
|
549
|
-
async function
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const { done, value } = await reader.read();
|
|
559
|
-
if (done) break;
|
|
560
|
-
total += value.byteLength;
|
|
561
|
-
if (total > maxBytes) {
|
|
562
|
-
reader.cancel().catch(() => {});
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
chunks.push(value);
|
|
566
|
-
}
|
|
567
|
-
const joined = new Uint8Array(total);
|
|
568
|
-
let at = 0;
|
|
569
|
-
for (const chunk of chunks) {
|
|
570
|
-
joined.set(chunk, at);
|
|
571
|
-
at += chunk.byteLength;
|
|
564
|
+
async function explainLegacyRefusal(response) {
|
|
565
|
+
if (response.ok) return response;
|
|
566
|
+
const body = await response.clone().text();
|
|
567
|
+
if (!body.includes("Unsupported protocol version")) return response;
|
|
568
|
+
let payload;
|
|
569
|
+
try {
|
|
570
|
+
payload = JSON.parse(body);
|
|
571
|
+
} catch {
|
|
572
|
+
return response;
|
|
572
573
|
}
|
|
573
|
-
|
|
574
|
+
if (typeof payload.error?.message !== "string") return response;
|
|
575
|
+
payload.error.message += ". No MCP client currently ships without the initialize handshake, so this refuses every client available today. Set legacy: 'stateless' to serve them.";
|
|
576
|
+
return Response.json(payload, {
|
|
577
|
+
status: response.status,
|
|
578
|
+
headers: response.headers
|
|
579
|
+
});
|
|
574
580
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
issuer: principal.issuer,
|
|
584
|
-
sub: principal.sub,
|
|
585
|
-
email: principal.email,
|
|
581
|
+
function routeFromScopeKey(key) {
|
|
582
|
+
for (const kind of [
|
|
583
|
+
"tool",
|
|
584
|
+
"prompt",
|
|
585
|
+
"resource"
|
|
586
|
+
]) {
|
|
587
|
+
const prefix = `${kind}:`;
|
|
588
|
+
if (key.startsWith(prefix)) return {
|
|
586
589
|
kind,
|
|
587
|
-
name
|
|
588
|
-
permission,
|
|
589
|
-
resource: audit?.(args),
|
|
590
|
-
decision: "allow"
|
|
591
|
-
};
|
|
592
|
-
await onAudit({
|
|
593
|
-
...base,
|
|
594
|
-
phase: "attempt",
|
|
595
|
-
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
596
|
-
});
|
|
597
|
-
try {
|
|
598
|
-
const result = await run();
|
|
599
|
-
await onAudit({
|
|
600
|
-
...base,
|
|
601
|
-
phase: "success",
|
|
602
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
603
|
-
durationMs: performance.now() - started
|
|
604
|
-
});
|
|
605
|
-
return result;
|
|
606
|
-
} catch (error) {
|
|
607
|
-
await onAudit({
|
|
608
|
-
...base,
|
|
609
|
-
phase: "failure",
|
|
610
|
-
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
611
|
-
durationMs: performance.now() - started,
|
|
612
|
-
error: error instanceof Error ? error.message : String(error)
|
|
613
|
-
});
|
|
614
|
-
throw error;
|
|
615
|
-
}
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
function toolsFor(handlerContext) {
|
|
619
|
-
return function tool(name, config, handler) {
|
|
620
|
-
const { permission, audit, ...toolConfig } = config;
|
|
621
|
-
return {
|
|
622
|
-
label: name,
|
|
623
|
-
kind: "tool",
|
|
624
|
-
routeName: name,
|
|
625
|
-
permission,
|
|
626
|
-
register(server, principal, context, onAudit) {
|
|
627
|
-
if (!principal.can(permission)) return;
|
|
628
|
-
const record = auditing("tool", name, permission, audit, principal, onAudit);
|
|
629
|
-
server.registerTool(name, toolConfig, (async (args) => {
|
|
630
|
-
return record(args, () => handler(args, handlerContext(context, principal)));
|
|
631
|
-
}));
|
|
632
|
-
}
|
|
633
|
-
};
|
|
634
|
-
};
|
|
635
|
-
}
|
|
636
|
-
function promptsFor(handlerContext) {
|
|
637
|
-
return function prompt(name, config, handler) {
|
|
638
|
-
const { permission, audit, ...promptConfig } = config;
|
|
639
|
-
return {
|
|
640
|
-
label: `prompt:${name}`,
|
|
641
|
-
kind: "prompt",
|
|
642
|
-
routeName: name,
|
|
643
|
-
permission,
|
|
644
|
-
register(server, principal, context, onAudit) {
|
|
645
|
-
if (!principal.can(permission)) return;
|
|
646
|
-
const record = auditing("prompt", name, permission, audit, principal, onAudit);
|
|
647
|
-
server.registerPrompt(name, promptConfig, (async (args) => {
|
|
648
|
-
return record(args, () => handler(args, handlerContext(context, principal)));
|
|
649
|
-
}));
|
|
650
|
-
}
|
|
651
|
-
};
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
function resourcesFor(handlerContext) {
|
|
655
|
-
return function resource(name, config, handler) {
|
|
656
|
-
const { permission, audit, uri, ...metadata } = config;
|
|
657
|
-
const routeTemplate = typeof uri === "string" ? new UriTemplate(uri) : uri.uriTemplate;
|
|
658
|
-
return {
|
|
659
|
-
label: `resource:${name}`,
|
|
660
|
-
kind: "resource",
|
|
661
|
-
...typeof uri === "string" ? { routeName: uri } : {},
|
|
662
|
-
routeMatches: (target) => routeTemplate.match(target) !== null,
|
|
663
|
-
permission,
|
|
664
|
-
register(server, principal, context, onAudit) {
|
|
665
|
-
if (!principal.can(permission)) return;
|
|
666
|
-
const record = auditing("resource", name, permission, audit, principal, onAudit);
|
|
667
|
-
server.registerResource(name, uri, metadata, (async (target) => {
|
|
668
|
-
return record(target, () => handler(target, handlerContext(context, principal)));
|
|
669
|
-
}));
|
|
670
|
-
}
|
|
590
|
+
name: key.slice(prefix.length)
|
|
671
591
|
};
|
|
672
|
-
};
|
|
673
|
-
}
|
|
674
|
-
function buildServer(definitions, options, principalOf) {
|
|
675
|
-
const permissions = /* @__PURE__ */ new Map();
|
|
676
|
-
const routePermissions = /* @__PURE__ */ new Map();
|
|
677
|
-
for (const definition of definitions) {
|
|
678
|
-
if (permissions.has(definition.label)) throw new Error(`'${definition.label}' is registered twice.`);
|
|
679
|
-
permissions.set(definition.label, definition.permission);
|
|
680
|
-
if (definition.routeName !== void 0) routePermissions.set(`${definition.kind}:${definition.routeName}`, definition.permission);
|
|
681
592
|
}
|
|
682
|
-
const factory = (context) => {
|
|
683
|
-
const principal = principalOf(context);
|
|
684
|
-
const enabled = definitions.filter((definition) => principal.can(definition.permission));
|
|
685
|
-
const declared = {
|
|
686
|
-
...enabled.some((definition) => definition.kind === "tool") ? { tools: {} } : {},
|
|
687
|
-
...enabled.some((definition) => definition.kind === "prompt") ? { prompts: {} } : {},
|
|
688
|
-
...enabled.some((definition) => definition.kind === "resource") ? { resources: {} } : {}
|
|
689
|
-
};
|
|
690
|
-
const { onAudit, mcp: mcpOptions = {}, ...serverInfo } = options;
|
|
691
|
-
const server = new McpServer(serverInfo, {
|
|
692
|
-
...mcpOptions,
|
|
693
|
-
capabilities: {
|
|
694
|
-
...mcpOptions.capabilities,
|
|
695
|
-
...declared
|
|
696
|
-
}
|
|
697
|
-
});
|
|
698
|
-
for (const definition of enabled) definition.register(server, principal, context, onAudit);
|
|
699
|
-
return server;
|
|
700
|
-
};
|
|
701
|
-
return Object.assign(factory, {
|
|
702
|
-
permissions,
|
|
703
|
-
routePermissions,
|
|
704
|
-
permissionForRoute: (kind, name) => definitions.find((definition) => definition.kind === kind && (definition.routeMatches?.(name) ?? definition.routeName === name))?.permission
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* Everything bound to one policy: `permission` accepts only what that policy can
|
|
709
|
-
* grant, in all four places, from one call.
|
|
710
|
-
*
|
|
711
|
-
* The policy is a type carrier here and is never invoked. Authorization happens
|
|
712
|
-
* once per request when the principal is resolved, not once per definition.
|
|
713
|
-
*
|
|
714
|
-
* ```ts
|
|
715
|
-
* const { tool, prompt, resource, server } = authz(policy);
|
|
716
|
-
* ```
|
|
717
|
-
*/
|
|
718
|
-
function bindAuthz(_permissions, principalOf, handlerContext) {
|
|
719
593
|
return {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
resource: resourcesFor(handlerContext),
|
|
723
|
-
server: (definitions, options) => buildServer(definitions, options, principalOf)
|
|
594
|
+
kind: "tool",
|
|
595
|
+
name: key
|
|
724
596
|
};
|
|
725
597
|
}
|
|
726
|
-
function
|
|
727
|
-
if (
|
|
728
|
-
|
|
598
|
+
function isPrincipal(value) {
|
|
599
|
+
if (typeof value !== "object" || value === null) return false;
|
|
600
|
+
const candidate = value;
|
|
601
|
+
return typeof candidate.issuer === "string" && typeof candidate.sub === "string" && (candidate.email === void 0 || typeof candidate.email === "string") && Array.isArray(candidate.roles) && candidate.roles.every((role) => typeof role === "string") && Array.isArray(candidate.permissions) && candidate.permissions.every((permission) => typeof permission === "string") && typeof candidate.can === "function";
|
|
729
602
|
}
|
|
730
603
|
//#endregion
|
|
731
|
-
export { AccessDeniedError, authz, createMcpFetch, createPrincipal, decodeMcpNameHeader, definePermissions, definePolicy, discoverOAuth, gate, identityFromAuth, jwksVerifier, reconcile, scopesForCapability, scopesFromMcpHeaders };
|
|
604
|
+
export { AccessDeniedError, ApprovalRefusedError, authz, createMcpFetch, createPrincipal, decodeMcpNameHeader, definePermissions, definePolicy, discoverOAuth, gate, identityFromAuth, jwksVerifier, reconcile, scopesForCapability, scopesFromMcpHeaders };
|