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/dist/index.js CHANGED
@@ -1,6 +1,7 @@
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";
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 resource = options.audit?.[label];
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 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
- }
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
- 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
- }
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 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,
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
- return mcp.fetch(request, {
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
- * The body as text, or `undefined` when it is over the cap.
556
+ * Say what to do about the refusal every new deployment meets first.
544
557
  *
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.
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 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;
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
- return new TextDecoder().decode(joined);
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
- //#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,
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
- tool: toolsFor(handlerContext),
721
- prompt: promptsFor(handlerContext),
722
- resource: resourcesFor(handlerContext),
723
- server: (definitions, options) => buildServer(definitions, options, principalOf)
594
+ kind: "tool",
595
+ name: key
724
596
  };
725
597
  }
726
- function authz(policy, options) {
727
- if (options) return bindAuthz(policy, options.principal, (context) => context);
728
- return bindAuthz(policy, (principal) => principal, (_context, principal) => ({ principal }));
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 };