mcp-authz 0.4.0 → 0.5.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.
@@ -0,0 +1,5 @@
1
+ import { Client } from "@modelcontextprotocol/client";
2
+ //#region src/definitions.d.ts
3
+ type Definition = Record<string, unknown>;
4
+ //#endregion
5
+ export { Definition as t };
@@ -0,0 +1,132 @@
1
+ //#region src/definitions.ts
2
+ /**
3
+ * Left out of a definition: servers stamp these per build, the model does not
4
+ * read them, and a record that churns teaches people to ignore its diff. Every
5
+ * other field is kept, so the next field the spec adds is covered by default.
6
+ */
7
+ const UNRECORDED = /* @__PURE__ */ new Set(["_meta", "icons"]);
8
+ /**
9
+ * Where a server's own `instructions` are recorded: beside its capabilities,
10
+ * because they reach the model the same way a description does, and a server
11
+ * that keeps every tool identical could otherwise change what it tells the
12
+ * model there instead.
13
+ */
14
+ const INSTRUCTIONS = "server:instructions";
15
+ /** The labels a record must cover and does not, so a gap fails loudly rather than passing unchecked. */
16
+ function missingDefinitions(labels, definitions) {
17
+ return [...labels].filter((label) => {
18
+ const definition = definitions.get(label);
19
+ return typeof definition !== "object" || definition === null || Array.isArray(definition);
20
+ });
21
+ }
22
+ /** The parts of a listed tool, prompt or resource that are held to the record. */
23
+ function definitionOf(item) {
24
+ return canonical(Object.fromEntries(Object.entries(item).filter(([field]) => !UNRECORDED.has(field))));
25
+ }
26
+ /** The fields that differ, compared regardless of key order. */
27
+ function changedFields(recorded, live) {
28
+ const fields = new Set([...Object.keys(recorded), ...Object.keys(live)].filter((f) => !UNRECORDED.has(f)));
29
+ const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
30
+ return [...fields].sort().filter((field) => !same(recorded[field], live[field]));
31
+ }
32
+ /**
33
+ * Key order is an accident of how a value was built, so sort it away. Without
34
+ * this an SDK that emitted the same definition in a different order would churn
35
+ * every record, and teach people to ignore the diff.
36
+ */
37
+ function canonical(value) {
38
+ if (Array.isArray(value)) return value.map(canonical);
39
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, inner]) => [key, canonical(inner)]));
40
+ return value;
41
+ }
42
+ /**
43
+ * Characters a person reviewing the text cannot see but a model reads:
44
+ * zero-width characters, bidirectional overrides that reorder what is
45
+ * displayed, and the Unicode tag block, which can spell out a whole hidden
46
+ * sentence. Not U+200D, the joiner inside every family or flag emoji: flagging
47
+ * it would flag honest output until nobody reads the flag.
48
+ */
49
+ const INVISIBLE = /[\u200B\u200C\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u;
50
+ /**
51
+ * Wording seen in published tool-poisoning attacks: text addressed to the model
52
+ * rather than describing the tool. A short list on purpose. It flags a
53
+ * definition for a closer read and decides nothing, and a long list would flag
54
+ * honest tools until nobody reads the flag.
55
+ */
56
+ const ADDRESSED_TO_MODEL = [
57
+ /ignore (all |any )?(previous|prior|above) (instructions|prompts)/i,
58
+ /<\/?(important|system|instructions?)>/i,
59
+ /do not (tell|inform|mention|alert|notify) the user/i,
60
+ /without (telling|informing|asking) the user/i,
61
+ /\bid_rsa\b|~\/\.ssh|\.aws\/credentials/i
62
+ ];
63
+ /**
64
+ * Text with each invisible character spelled out as `\u{200B}`, so a person
65
+ * reading a listing or a diff sees what the model will read.
66
+ */
67
+ function reveal(text) {
68
+ return text.replace(new RegExp(INVISIBLE.source, "gu"), (char) => {
69
+ return `\\u{${char.codePointAt(0).toString(16).toUpperCase()}}`;
70
+ });
71
+ }
72
+ /** Why a definition deserves a closer read before it is approved, if it does. */
73
+ function suspicious(definition) {
74
+ const text = JSON.stringify(definition);
75
+ const reasons = [];
76
+ if (INVISIBLE.test(text)) reasons.push("contains invisible characters");
77
+ if (ADDRESSED_TO_MODEL.some((pattern) => pattern.test(text))) reasons.push("contains text addressed to the model");
78
+ return reasons;
79
+ }
80
+ const LISTED = [
81
+ ["tools", ""],
82
+ ["prompts", "prompt:"],
83
+ ["resources", "resource:"],
84
+ ["resourceTemplates", "resource:"]
85
+ ];
86
+ /**
87
+ * Every listing that arrives on a client transport, as the server wrote it and
88
+ * keyed by the label `gate()` gives it. A record is compared against what the
89
+ * proxy and `wrap` read off the wire, so it is taken from the wire too, not from
90
+ * what the SDK parsed it into. Install after `client.connect`, which sets the
91
+ * handler this wraps.
92
+ */
93
+ function captureListings(transport) {
94
+ const listed = /* @__PURE__ */ new Map();
95
+ const deliver = transport.onmessage;
96
+ transport.onmessage = (message, ...rest) => {
97
+ const result = message.result;
98
+ for (const [field, prefix] of LISTED) {
99
+ const items = result?.[field];
100
+ if (!Array.isArray(items)) continue;
101
+ for (const item of items) listed.set(`${prefix}${String(item.name)}`, item);
102
+ }
103
+ deliver?.(message, ...rest);
104
+ };
105
+ return listed;
106
+ }
107
+ /**
108
+ * Connect a client and list everything the server advertises, returning each
109
+ * item as the server wrote it, keyed by label, with its instructions under
110
+ * `server:instructions`. One function for recording a catalogue and for
111
+ * checking it later, so the two cannot read a server differently.
112
+ *
113
+ * Only what the server says it has is asked for: the SDK answers an
114
+ * unadvertised list with a warning on stdout, which is the generated module
115
+ * when a caller redirects it. The SDK follows `nextCursor` itself.
116
+ */
117
+ async function listCatalogue(client, transport) {
118
+ const listed = captureListings(transport);
119
+ await client.connect(transport);
120
+ const instructions = client.getInstructions();
121
+ if (instructions !== void 0) listed.set(INSTRUCTIONS, { instructions });
122
+ const advertised = client.getServerCapabilities() ?? {};
123
+ await Promise.all([
124
+ advertised.tools && client.listTools(),
125
+ advertised.prompts && client.listPrompts(),
126
+ advertised.resources && client.listResources(),
127
+ advertised.resources && client.listResourceTemplates()
128
+ ]);
129
+ return listed;
130
+ }
131
+ //#endregion
132
+ export { listCatalogue as a, suspicious as c, definitionOf as i, captureListings as n, missingDefinitions as o, changedFields as r, reveal as s, INSTRUCTIONS as t };
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ as Identity, a as PermissionCatalog, c as PolicySpec, d as createPrincipal, f as definePermissions, g as DenialReason, h as AccessDeniedError, i as MatchedRule, l as Principal, m as reconcile, n as Explanation, o as PermissionOf, p as definePolicy, r as Match, s as Policy, u as Rule } from "./policy-DuZbwrKf.js";
2
- import { a as AuditDeliveryFailure, c as AuditSink, d as PromptConfig, f as ResourceConfig, h as authz, i as ApprovalSink, l as Capability, m as ToolConfig, n as ApprovalRefusedError, o as AuditErrorSink, p as ServerOptions, r as ApprovalRequest, s as AuditEvent, t as ApprovalDecision, u as Definition } from "./tools-BQE1O-7P.js";
2
+ import { a as AuditDeliveryFailure, c as AuditSink, d as PromptConfig, f as ResourceConfig, h as authz, i as ApprovalSink, l as Capability, m as ToolConfig, n as ApprovalRefusedError, o as AuditErrorSink, p as ServerOptions, r as ApprovalRequest, s as AuditEvent, t as ApprovalDecision, u as Definition } from "./tools-C1dZESYK.js";
3
3
  import { a as AuthorizationDecisionSink, i as AuthorizationDecisionEvent, n as identityFromAuth, r as jwksVerifier, t as VerifierOptions } from "./verifier-DF6gUMQ6.js";
4
4
  import { a as scopesForCapability, i as decodeMcpNameHeader, n as ScopeRequirement, o as scopesFromMcpHeaders, r as ToolScopeMap, s as TrustedMcpRoute, t as CapabilityScopeMap } from "./ladder-D18eJ7tD.js";
5
5
  import { AuthInfo, McpServer, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
@@ -161,7 +161,11 @@ type McpFetchOptions<TContext, P extends string = string> = {
161
161
  permissions?: ReadonlyMap<string, string>;
162
162
  /** Protocol route to permission, used to distinguish policy denial from scope step-up. */
163
163
  routePermissions?: ReadonlyMap<string, string>;
164
- routeNameFor?: (kind: 'tool' | 'prompt' | 'resource', name: string) => string | undefined;
164
+ /** Every registration a concrete request name reaches, so overlapping ones are all enforced. */
165
+ routesFor?: (kind: 'tool' | 'prompt' | 'resource', name: string) => readonly {
166
+ routeName: string;
167
+ permission: string;
168
+ }[];
165
169
  /** Resolve exact and templated protocol routes to their declared permission. */
166
170
  permissionForRoute?: (kind: 'tool' | 'prompt' | 'resource', name: string) => string | undefined;
167
171
  };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
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";
2
+ import { d as scopesForCapability, f as scopesFromMcpHeaders, i as runScopedGate, r as permissionForRoute, u as decodeMcpNameHeader } from "./ladder-6TnD3hTJ.js";
3
3
  import { i as reconcile, n as definePermissions, r as definePolicy, t as createPrincipal } from "./policy-BBp3Jq6G.js";
4
4
  import { McpServer, UriTemplate, createMcpHandler, getOAuthProtectedResourceMetadataUrl, oauthMetadataResponse, requireBearerAuth } from "@modelcontextprotocol/server";
5
5
  //#region src/discovery.ts
@@ -308,11 +308,15 @@ function buildServer(definitions, options, principalOf) {
308
308
  for (const definition of enabled) definition.register(server, principal, context, guards);
309
309
  return server;
310
310
  };
311
+ const routesFor = (kind, name) => definitions.filter((definition) => definition.kind === kind && (definition.routeMatches?.(name) ?? definition.routeName === name)).map((definition) => ({
312
+ routeName: definition.routeName,
313
+ permission: definition.permission
314
+ }));
311
315
  return Object.assign(factory, {
312
316
  permissions,
313
317
  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
318
+ routesFor,
319
+ permissionForRoute: (kind, name) => routesFor(kind, name)[0]?.permission
316
320
  });
317
321
  }
318
322
  /**
@@ -509,6 +513,7 @@ function createMcpFetch(options) {
509
513
  throw error;
510
514
  }
511
515
  const scoped = Boolean(scopeMap || scopesForRequest);
516
+ const resourceRoutes = (route) => route.method === "resources/read" && route.name ? createServer.routesFor?.("resource", route.name) ?? [] : [];
512
517
  const routed = Boolean(createServer.permissionForRoute ?? createServer.routePermissions);
513
518
  const gateResult = await runScopedGate({
514
519
  request,
@@ -520,12 +525,16 @@ function createMcpFetch(options) {
520
525
  routed,
521
526
  scopeMap,
522
527
  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");
528
+ const matches = resourceRoutes(route);
529
+ if (matches.length === 0 || !scopeMap) return void 0;
530
+ return [...new Set(matches.flatMap((match) => scopesForCapability(route.method, match.routeName, scopeMap, requiredScopes[0] ?? "mcp")))];
526
531
  },
527
532
  scopesForRequest,
528
- resolvePermission: (route) => permissionForRoute(createServer.permissionForRoute, createServer.routePermissions, route),
533
+ resolvePermission: (route) => {
534
+ const matches = resourceRoutes(route);
535
+ if (matches.length === 0) return permissionForRoute(createServer.permissionForRoute, createServer.routePermissions, route);
536
+ return (matches.find((match) => principal && !principal.can(match.permission)) ?? matches[0])?.permission;
537
+ },
529
538
  onDecision: options.onDecision,
530
539
  ...emitter ? { emitter } : {},
531
540
  resourceMetadataUrl
@@ -1,4 +1,5 @@
1
1
  import { a as policyDenied, i as emitDecision, o as principalLabel, s as AccessDeniedError } from "./verifier-D6VIAYuT.js";
2
+ import { r as parseChecked, t as hasDuplicateKey } from "./strict-json-DLKOgsGE.js";
2
3
  import { OAuthError, OAuthErrorCode, bearerAuthChallengeResponse, classifyInboundRequest, isJsonContentType } from "@modelcontextprotocol/server";
3
4
  //#region src/scopes.ts
4
5
  const BASE64_SENTINEL = /^=\?base64\?([A-Za-z0-9+/]*(?:={0,2}))\?=$/;
@@ -355,10 +356,11 @@ async function preflightScopedRequest(request, maxBytes) {
355
356
  if (raw === void 0) return protocolError(413, -32e3, `Request body exceeds the ${maxBytes} byte limit.`);
356
357
  let body;
357
358
  try {
358
- body = JSON.parse(raw);
359
+ body = parseChecked(raw);
359
360
  } catch {
360
361
  return protocolError(400, -32700, "Parse error: the request body is not valid JSON");
361
362
  }
363
+ if (hasDuplicateKey(raw)) return protocolError(400, -32600, "Invalid Request: the body repeats a key, which parsers resolve differently.", void 0, requestId(body));
362
364
  const route = classifyScopedRequest(request, body);
363
365
  if (route.kind === "reject") return protocolError(route.httpStatus, route.code, route.message, route.data, route.id);
364
366
  if (route.kind !== "modern") {
package/dist/openapi.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { _ as Identity, l as Principal, s as Policy } from "./policy-DuZbwrKf.js";
2
- import { c as AuditSink, o as AuditErrorSink } from "./tools-BQE1O-7P.js";
2
+ import { c as AuditSink, o as AuditErrorSink } from "./tools-C1dZESYK.js";
3
3
  import { a as AuthorizationDecisionSink, t as VerifierOptions } from "./verifier-DF6gUMQ6.js";
4
- import { t as PermissionMapRecord } from "./permissions-module-Cr0K1a7x.js";
4
+ import { t as PermissionMapRecord } from "./permissions-module-TOpt20D4.js";
5
5
  import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
6
6
  //#region src/openapi.d.ts
7
7
  /**
@@ -10,8 +10,8 @@
10
10
  type PermissionMapRecord = {
11
11
  /** Every capability, sorted, labelled the way the gate labels it. */
12
12
  names: string[];
13
- /** A digest per capability, when the source can produce one. */
14
- fingerprints?: Record<string, string>;
13
+ /** What each capability says to the model, when the source can record it. */
14
+ definitions?: Record<string, unknown>;
15
15
  /** `resource:` labels to the URI or template each answers on. */
16
16
  resourceUris?: Record<string, string>;
17
17
  };
package/dist/proxy.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { _ as Identity, s as Policy } from "./policy-DuZbwrKf.js";
2
2
  import { a as AuthorizationDecisionSink, t as VerifierOptions } from "./verifier-DF6gUMQ6.js";
3
3
  import { t as CapabilityScopeMap } from "./ladder-D18eJ7tD.js";
4
+ import { t as Definition } from "./definitions-CZVk-j1C.js";
4
5
  import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
5
6
  //#region src/upstream.d.ts
6
7
  type UpstreamConfig = {
@@ -23,6 +24,18 @@ export type McpProxyOptions<P extends string = string> = {
23
24
  upstream: UpstreamConfig;
24
25
  /** Flat permission map — same labels as `gate()` and `recordCapabilities`. */
25
26
  permissions: Readonly<Record<string, P>> | ReadonlyMap<string, P>;
27
+ /**
28
+ * What each priced capability said to the model when it was recorded: the
29
+ * `DEFINITIONS` that `mcp-authz record` writes beside `PERMISSIONS`.
30
+ *
31
+ * A permission prices a name, and the upstream decides what stands behind
32
+ * it. Without this, an upstream could keep an approved tool's name and
33
+ * rewrite its description to steer the model, or add an argument to carry
34
+ * data out, and every caller would be served the new one. With it, a
35
+ * capability whose definition has changed is left out of listings and its
36
+ * invocations are refused until someone re-records and approves the change.
37
+ */
38
+ definitions: Readonly<Record<string, Definition>>;
26
39
  /**
27
40
  * `resource:<label>` to the URI or URI template it answers on.
28
41
  *