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.
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
+ import { a as listCatalogue, i as definitionOf, t as INSTRUCTIONS } from "./definitions-CyIy4YSZ.js";
1
2
  import { InMemoryTransport } from "@modelcontextprotocol/server";
2
- import { createHash } from "node:crypto";
3
3
  import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
4
4
  //#region src/permissions-module.ts
5
5
  /**
@@ -22,29 +22,44 @@ function toPermissionsModule(record) {
22
22
  "} as const;",
23
23
  "",
24
24
  "export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];",
25
- ...fingerprintLines(record),
25
+ ...definitionLines(record),
26
26
  ...resourceUriLines(record.resourceUris ?? {}),
27
27
  ""
28
28
  ].join("\n");
29
29
  }
30
30
  /**
31
- * Only emitted when the recorder could produce digests. An OpenAPI document is
31
+ * Only emitted when the recorder could read definitions. An OpenAPI document is
32
32
  * already a file in the repository, diffed on the pull request by whoever
33
- * changed it, so there is nothing for a second baseline to catch.
33
+ * changed it, so there is nothing for a second record to catch.
34
+ *
35
+ * Written out in full rather than as digests, so re-recording shows in review
36
+ * the exact words a server changed, and so `createMcpProxy` can compare without
37
+ * hashing on every listing.
34
38
  */
35
- function fingerprintLines(record) {
36
- const fingerprints = record.fingerprints ?? {};
37
- if (Object.keys(fingerprints).length === 0) return [];
39
+ function definitionLines(record) {
40
+ const definitions = record.definitions ?? {};
41
+ if (Object.keys(definitions).length === 0) return [];
38
42
  return [
39
43
  "",
40
- "// What each capability looked like when this was recorded. A separate export",
41
- "// because gate() takes the flat map above; this is the baseline CI compares.",
42
- "export const FINGERPRINTS = {",
43
- ...record.names.map((label) => ` ${quote(label)}: ${literal(fingerprints[label] ?? "")},`),
44
- "} as const;"
44
+ "// What each capability said to the model when this was recorded. createMcpProxy()",
45
+ "// hides one that no longer matches, so a server cannot keep an approved name and",
46
+ "// change what it tells the model. Re-record to approve a change.",
47
+ "export const DEFINITIONS = {",
48
+ ...record.names.map((label) => ` ${quote(label)}: ${value(definitions[label] ?? {})},`),
49
+ ...definitions["server:instructions"] ? [` ${quote(INSTRUCTIONS)}: ${value(definitions[INSTRUCTIONS])},`] : [],
50
+ "};"
45
51
  ];
46
52
  }
47
53
  /**
54
+ * JSON is a valid object literal except for one key: `"__proto__"` sets the
55
+ * prototype there, dropping that part of a schema. A definition carrying one is
56
+ * written as a parse of its JSON instead, which keeps it.
57
+ */
58
+ function value(definition) {
59
+ const json = JSON.stringify(definition, null, 2).replace(/\n/g, "\n ");
60
+ return json.includes("\"__proto__\"") ? `JSON.parse(${literal(JSON.stringify(definition))})` : json;
61
+ }
62
+ /**
48
63
  * Only emitted when the server has resources, so a tools-only map stays a map.
49
64
  * `createMcpProxy` needs this to price a read, which names a URI and never a
50
65
  * label; `gate()` never sees it.
@@ -85,19 +100,14 @@ function literal(value) {
85
100
  }
86
101
  //#endregion
87
102
  //#region src/testing.ts
88
- function digest(parts) {
89
- return createHash("sha256").update(JSON.stringify(canonical(parts))).digest("hex").slice(0, 16);
90
- }
91
103
  /**
92
- * Key order is an accident of how a value was built, so sort it away. Without
93
- * this an SDK that emitted the same definition in a different order would churn
94
- * every fingerprint in a snapshot and teach people to ignore the diff.
104
+ * An upstream is recorded in 2026-07-28 and nothing older: the proxy that
105
+ * enforces the record speaks only that, and a record read through another
106
+ * dialect could differ from what the proxy later compares it against. Your own
107
+ * server, connected in process, is recorded in whichever era it offers.
95
108
  */
96
- function canonical(value) {
97
- if (Array.isArray(value)) return value.map(canonical);
98
- 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)]));
99
- return value;
100
- }
109
+ const MODERN = { versionNegotiation: { mode: { pin: "2026-07-28" } } };
110
+ const EITHER = { versionNegotiation: { mode: "auto" } };
101
111
  async function recordCapabilities(factory) {
102
112
  const server = await factory();
103
113
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -105,10 +115,9 @@ async function recordCapabilities(factory) {
105
115
  const client = new Client({
106
116
  name: "mcp-authz-record-capabilities",
107
117
  version: "1.0.0"
108
- });
109
- await client.connect(clientTransport);
118
+ }, EITHER);
110
119
  try {
111
- return await listFrom(client);
120
+ return recordFrom(await listCatalogue(client, clientTransport));
112
121
  } finally {
113
122
  await client.close();
114
123
  await server.close();
@@ -130,47 +139,30 @@ async function recordUpstream(url, options = {}) {
130
139
  const client = new Client({
131
140
  name: "mcp-authz-record-capabilities",
132
141
  version: "1.0.0"
133
- });
134
- await client.connect(transport);
142
+ }, MODERN);
135
143
  try {
136
- return await listFrom(client);
144
+ return recordFrom(await listCatalogue(client, transport));
137
145
  } finally {
138
146
  await client.close();
139
147
  }
140
148
  }
141
- async function listFrom(client) {
142
- {
143
- const advertised = client.getServerCapabilities() ?? {};
144
- const none = {
145
- tools: [],
146
- prompts: [],
147
- resources: [],
148
- resourceTemplates: []
149
- };
150
- const [tools, prompts, resources, templates] = await Promise.all([
151
- advertised.tools ? client.listTools() : none,
152
- advertised.prompts ? client.listPrompts() : none,
153
- advertised.resources ? client.listResources() : none,
154
- advertised.resources ? client.listResourceTemplates() : none
155
- ]);
156
- const labelled = [
157
- ...tools.tools.map((tool) => [tool.name, tool]),
158
- ...prompts.prompts.map((prompt) => [`prompt:${prompt.name}`, prompt]),
159
- ...resources.resources.map((resource) => [`resource:${resource.name}`, resource]),
160
- ...templates.resourceTemplates.map((template) => [`resource:${template.name}`, template])
161
- ];
162
- const resourceUris = Object.fromEntries([...resources.resources.map((resource) => [`resource:${resource.name}`, resource.uri]), ...templates.resourceTemplates.map((template) => [`resource:${template.name}`, template.uriTemplate])]);
163
- const names = labelled.map(([label]) => label).sort();
164
- const byLabel = new Map(labelled);
165
- return {
166
- names,
167
- fingerprints: Object.fromEntries(names.map((label) => [label, digest({
168
- label,
169
- definition: byLabel.get(label)
170
- })])),
171
- resourceUris
172
- };
173
- }
149
+ /**
150
+ * The record, built from the listings as the server wrote them, which is what
151
+ * the proxy will compare against later.
152
+ *
153
+ * Built from entries rather than by assignment. A server may advertise a
154
+ * capability called `__proto__`, and `dict['__proto__'] = definition` runs the
155
+ * inherited setter instead of storing anything — losing exactly the record that
156
+ * would have caught that capability changing under you. `fromEntries` defines
157
+ * own properties, while the result stays an ordinary object.
158
+ */
159
+ function recordFrom(listed) {
160
+ const names = [...listed.keys()].filter((label) => label !== INSTRUCTIONS).sort();
161
+ return {
162
+ names,
163
+ definitions: Object.fromEntries([...listed].map(([label, item]) => [label, definitionOf(item)])),
164
+ resourceUris: Object.fromEntries(names.filter((label) => label.startsWith("resource:")).map((label) => [label, String(listed.get(label).uri ?? listed.get(label).uriTemplate)]))
165
+ };
174
166
  }
175
167
  //#endregion
176
168
  export { UNASSIGNED, recordCapabilities, recordUpstream, toPermissionsModule };
@@ -241,8 +241,15 @@ type ServerFactory<C> = ((context: C) => McpServer) & {
241
241
  permissions: ReadonlyMap<string, string>;
242
242
  routePermissions: ReadonlyMap<string, string>;
243
243
  permissionForRoute: (kind: Capability, name: string) => string | undefined;
244
- /** The registered route name a concrete request name resolves to, template included. */
245
- routeNameFor: (kind: Capability, name: string) => string | undefined;
244
+ /**
245
+ * Every registration a concrete request name reaches, templates included, in
246
+ * registration order. More than one when an exact resource and a template
247
+ * cover the same URI.
248
+ */
249
+ routesFor: (kind: Capability, name: string) => {
250
+ routeName: string;
251
+ permission: string;
252
+ }[];
246
253
  };
247
254
  /**
248
255
  * Everything bound to one policy: `permission` accepts only what that policy can
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-authz",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Authorization for MCP servers: OAuth 2.1 resource server, roles/permissions policy, permission-gated tools (2026-07-28)",
5
5
  "repository": {
6
6
  "type": "git",