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/dist/index.js ADDED
@@ -0,0 +1,731 @@
1
+ import { createPrincipal, definePermissions, definePolicy, reconcile } from "./policy.js";
2
+ import { McpServer, OAuthError, OAuthErrorCode, UriTemplate, bearerAuthChallengeResponse, classifyInboundRequest, createMcpHandler, getOAuthProtectedResourceMetadataUrl, isJsonContentType, oauthMetadataResponse, requireBearerAuth } from "@modelcontextprotocol/server";
3
+ import { createRemoteJWKSet, jwtVerify } from "jose";
4
+ //#region src/discovery.ts
5
+ /**
6
+ * Fetch the authorization server's own metadata instead of hand-copying it.
7
+ *
8
+ * The five endpoints in `oauthMetadata` are published by every AS worth using,
9
+ * and copying them by hand is where a deployment breaks in a way no error
10
+ * explains: a stale token endpoint looks like a client bug for an afternoon.
11
+ *
12
+ * This is opt-in and never called for you, because it trades a fetch at boot
13
+ * for the config. An AS that is down now stops your deploy rather than only
14
+ * your logins.
15
+ */
16
+ /** Where an AS publishes metadata, most specific first (RFC 8414, then OIDC). */
17
+ function candidates(issuer) {
18
+ const url = new URL(issuer);
19
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
20
+ return [`${url.origin}/.well-known/oauth-authorization-server${path}`, `${url.origin}${path}/.well-known/openid-configuration`];
21
+ }
22
+ async function discoverOAuth(issuer, options = {}) {
23
+ const get = options.fetch ?? globalThis.fetch;
24
+ const tried = [];
25
+ for (const url of candidates(issuer)) {
26
+ tried.push(url);
27
+ const response = await get(url).catch(() => void 0);
28
+ if (!response?.ok) continue;
29
+ const metadata = await response.json().catch(() => void 0);
30
+ if (!metadata?.authorization_endpoint || !metadata.token_endpoint) continue;
31
+ if (metadata.issuer !== issuer) throw new Error(`${url} declares issuer '${metadata.issuer}', not '${issuer}'. Use the issuer exactly as the authorization server writes it.`);
32
+ return metadata;
33
+ }
34
+ 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
+ //#endregion
37
+ //#region src/gate.ts
38
+ function gate(server, principal, permissions, options = {}) {
39
+ const required = isMapLike(permissions) ? new Map(permissions) : new Map(Object.entries(permissions));
40
+ const wrap = (kind, register) => function gated(...args) {
41
+ const name = String(args[0]);
42
+ const label = kind === "tool" ? name : `${kind}:${name}`;
43
+ const permission = required.get(label);
44
+ if (permission === void 0) throw new Error(`gate(): no permission declared for ${kind} '${name}'. Add '${label}' to the permission map, or stop registering it.`);
45
+ const last = args.length - 1;
46
+ const handler = args[last];
47
+ if (options.onAudit && typeof handler === "function") {
48
+ const resource = options.audit?.[label];
49
+ const inner = handler;
50
+ args[last] = async function audited(...called) {
51
+ const started = performance.now();
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
+ }
86
+ };
87
+ }
88
+ const registered = register.apply(server, args);
89
+ if (!principal.can(permission)) {
90
+ if (typeof registered?.disable !== "function") throw new Error(`gate(): SDK registration for ${kind} '${name}' cannot be disabled safely.`);
91
+ registered.disable();
92
+ }
93
+ return registered;
94
+ };
95
+ const gatedMethods = {
96
+ registerTool: wrap("tool", server.registerTool),
97
+ registerPrompt: wrap("prompt", server.registerPrompt),
98
+ registerResource: wrap("resource", server.registerResource)
99
+ };
100
+ return new Proxy(server, { get(target, property) {
101
+ const gatedMethod = gatedMethods[property];
102
+ if (gatedMethod) return gatedMethod;
103
+ const value = target[property];
104
+ return typeof value === "function" ? value.bind(target) : value;
105
+ } });
106
+ }
107
+ function isMapLike(value) {
108
+ return typeof value === "object" && value !== null && typeof value.get === "function" && Symbol.iterator in value;
109
+ }
110
+ //#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
+ //#region src/handler.ts
338
+ /** Where the resolved per-request context rides from the gate to the factory. */
339
+ const CONTEXT_KEY = "mcp-authz.context";
340
+ function createMcpFetch(options) {
341
+ const { resourceServerUrl, oauthMetadata, requiredScopes = ["mcp"], supportedScopes, toolScopes, capabilityScopes, policy, createServer, contextExtraKey = CONTEXT_KEY, healthPath = "/health", legacy = "reject", maxRequestBytes = 1048576 } = options;
342
+ if (toolScopes && capabilityScopes) throw new Error("Pass either `toolScopes` or `capabilityScopes`, not both.");
343
+ const scopeMap = capabilityScopes ?? toolScopes;
344
+ if (scopeMap && options.scopesForRequest) throw new Error("Pass a declarative scope map or `scopesForRequest`, not both.");
345
+ const scopesForRequest = options.scopesForRequest;
346
+ const advertisedScopes = [.../* @__PURE__ */ new Set([
347
+ ...requiredScopes,
348
+ ...supportedScopes ?? [],
349
+ ...Object.values(scopeMap ?? {}).flatMap((scope) => typeof scope === "string" ? [scope] : [...scope])
350
+ ])];
351
+ if (policy && options.authorize) throw new Error("Pass either `policy` or `authorize`, not both.");
352
+ if (!policy && !options.authorize && !options.resolve) throw new Error("createMcpFetch needs a `policy`, `authorize`, or `resolve`.");
353
+ const required = options.permissions ?? createServer.permissions;
354
+ const granted = new Set([...policy?.roles.values() ?? []].flat());
355
+ if (policy && required) {
356
+ const { error, warning } = reconcile(policy.roles, required);
357
+ if (warning) console.warn(warning);
358
+ if (error) throw new Error(error);
359
+ }
360
+ const enrich = options.resolve;
361
+ const authorize = async (identity) => {
362
+ if (!policy && !options.authorize) return void 0;
363
+ const principal = policy ? policy(identity) : await options.authorize(identity);
364
+ if (!isPrincipal(principal)) throw new Error("The authorizer returned an invalid principal. Use `createPrincipal` to construct one.");
365
+ if (principal.permissions.length === 0) {
366
+ await emitDecision(options.onDecision, principal, "deny", "not_permitted");
367
+ throw AccessDeniedError.notPermitted(principalLabel(principal));
368
+ }
369
+ return principal;
370
+ };
371
+ const enrichIdentity = async (identity, principal) => {
372
+ if (!policy && !options.authorize) return enrich(identity, void 0);
373
+ try {
374
+ const context = enrich ? await enrich(identity, principal) : principal;
375
+ await emitDecision(options.onDecision, principal, "allow");
376
+ return context;
377
+ } catch (error) {
378
+ if (error instanceof AccessDeniedError) await emitDecision(options.onDecision, principal, "deny", error.reason);
379
+ throw error;
380
+ }
381
+ };
382
+ if (options.tokenVerifier && options.verifier) throw new Error("Pass either `tokenVerifier` or built-in `verifier` options, not both.");
383
+ let tokenVerifier;
384
+ let mapIdentity;
385
+ if (options.tokenVerifier) {
386
+ tokenVerifier = options.tokenVerifier;
387
+ mapIdentity = options.identityFromAuth ?? identityFromAuth;
388
+ } else {
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
+ }
401
+ const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
402
+ const metadataOptions = {
403
+ oauthMetadata,
404
+ resourceServerUrl,
405
+ scopesSupported: advertisedScopes
406
+ };
407
+ const mcp = createMcpHandler((ctx) => {
408
+ const context = ctx.authInfo?.extra?.[contextExtraKey];
409
+ if (context === void 0) throw new Error("Request reached the MCP factory with no resolved context.");
410
+ return createServer(context);
411
+ }, { legacy });
412
+ return async function mcpFetch(request) {
413
+ const metadata = oauthMetadataResponse(request, metadataOptions);
414
+ if (metadata) return metadata;
415
+ const { pathname } = new URL(request.url);
416
+ if (pathname === healthPath) return Response.json({
417
+ ok: true,
418
+ resource: resourceServerUrl.href,
419
+ authorization: policy ? {
420
+ mode: "policy",
421
+ roles: policy.roles.size,
422
+ permissions: granted.size
423
+ } : { mode: options.authorize ? "authorizer" : "resolver" },
424
+ capabilities: required?.size ?? "not declared"
425
+ });
426
+ if (pathname !== resourceServerUrl.pathname) return new Response(`No MCP endpoint at ${pathname}. This server answers on ${resourceServerUrl.pathname}, which is also the audience its tokens must carry.\n`, {
427
+ status: 404,
428
+ headers: { "Content-Type": "text/plain" }
429
+ });
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
+ const auth = await requireBearerAuth({
434
+ verifier: tokenVerifier,
435
+ requiredScopes,
436
+ resourceMetadataUrl
437
+ })(request);
438
+ if (auth instanceof Response) return auth;
439
+ let identity;
440
+ let principal;
441
+ try {
442
+ identity = mapIdentity(auth);
443
+ principal = await authorize(identity);
444
+ } catch (error) {
445
+ if (error instanceof AccessDeniedError) return policyDenied(error);
446
+ throw error;
447
+ }
448
+ const routePermission = preflight ? permissionForRoute(createServer.permissionForRoute, createServer.routePermissions, preflight.route) : void 0;
449
+ if (routePermission && principal && !principal.can(routePermission)) {
450
+ await emitDecision(options.onDecision, principal, "deny", "policy_denied");
451
+ return policyDenied(AccessDeniedError.notPermitted(principalLabel(principal), `the permission '${routePermission}'`));
452
+ }
453
+ if (scopes.filter((scope) => !auth.scopes.includes(scope)).length > 0) return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InsufficientScope, "Insufficient scope"), {
454
+ requiredScopes: scopes,
455
+ resourceMetadataUrl
456
+ });
457
+ let context;
458
+ try {
459
+ context = await enrichIdentity(identity, principal);
460
+ } catch (error) {
461
+ if (error instanceof AccessDeniedError) return policyDenied(error);
462
+ throw error;
463
+ }
464
+ return mcp.fetch(request, {
465
+ authInfo: {
466
+ ...auth,
467
+ extra: {
468
+ ...auth.extra,
469
+ [contextExtraKey]: context
470
+ }
471
+ },
472
+ ...preflight ? { parsedBody: preflight.body } : {}
473
+ });
474
+ };
475
+ }
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
+ /**
543
+ * The body as text, or `undefined` when it is over the cap.
544
+ *
545
+ * Reads the clone chunk by chunk and stops at the limit rather than buffering
546
+ * first and measuring after, because measuring after is how an unauthenticated
547
+ * caller decides how much memory this process spends.
548
+ */
549
+ async function readCapped(request, maxBytes) {
550
+ const declared = Number(request.headers.get("content-length"));
551
+ if (Number.isFinite(declared) && declared > maxBytes) return void 0;
552
+ const body = request.clone().body;
553
+ if (!body) return "";
554
+ const reader = body.getReader();
555
+ const chunks = [];
556
+ let total = 0;
557
+ for (;;) {
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;
572
+ }
573
+ return new TextDecoder().decode(joined);
574
+ }
575
+ //#endregion
576
+ //#region src/tools.ts
577
+ /** The audit call every permitted invocation makes, whatever kind it is. */
578
+ function auditing(kind, name, permission, audit, principal, onAudit) {
579
+ return async (args, run) => {
580
+ if (!onAudit) return run();
581
+ const started = performance.now();
582
+ const base = {
583
+ issuer: principal.issuer,
584
+ sub: principal.sub,
585
+ email: principal.email,
586
+ 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
+ }
671
+ };
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
+ }
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
+ return {
720
+ tool: toolsFor(handlerContext),
721
+ prompt: promptsFor(handlerContext),
722
+ resource: resourcesFor(handlerContext),
723
+ server: (definitions, options) => buildServer(definitions, options, principalOf)
724
+ };
725
+ }
726
+ function authz(policy, options) {
727
+ if (options) return bindAuthz(policy, options.principal, (context) => context);
728
+ return bindAuthz(policy, (principal) => principal, (_context, principal) => ({ principal }));
729
+ }
730
+ //#endregion
731
+ export { AccessDeniedError, authz, createMcpFetch, createPrincipal, decodeMcpNameHeader, definePermissions, definePolicy, discoverOAuth, gate, identityFromAuth, jwksVerifier, reconcile, scopesForCapability, scopesFromMcpHeaders };