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/README.md +69 -20
- package/dist/cli.js +340 -106
- package/dist/definitions-CZVk-j1C.d.ts +5 -0
- package/dist/definitions-CyIy4YSZ.js +132 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +16 -7
- package/dist/{ladder-CUzOKudC.js → ladder-6TnD3hTJ.js} +3 -1
- package/dist/openapi.d.ts +2 -2
- package/dist/{permissions-module-Cr0K1a7x.d.ts → permissions-module-TOpt20D4.d.ts} +2 -2
- package/dist/proxy.d.ts +13 -0
- package/dist/proxy.js +287 -34
- package/dist/screen-DxoujEpO.js +79 -0
- package/dist/strict-json-DLKOgsGE.js +100 -0
- package/dist/testing.d.ts +8 -3
- package/dist/testing.js +54 -62
- package/dist/{tools-BQE1O-7P.d.ts → tools-C1dZESYK.d.ts} +9 -2
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
315
|
-
permissionForRoute: (kind, name) =>
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
return
|
|
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) =>
|
|
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 =
|
|
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-
|
|
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-
|
|
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
|
-
/**
|
|
14
|
-
|
|
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
|
*
|