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/README.md +553 -26
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +228 -0
- package/dist/index.d.ts +31 -209
- package/dist/index.js +417 -544
- package/dist/ladder-CUzOKudC.js +407 -0
- package/dist/ladder-D18eJ7tD.d.ts +32 -0
- package/dist/openapi.d.ts +112 -0
- package/dist/openapi.js +254 -0
- package/dist/permissions-module-DxCHuE-N.d.ts +31 -0
- package/dist/policy-BBp3Jq6G.js +226 -0
- package/dist/{policy-CnQj53Hq.d.ts → policy-DuZbwrKf.d.ts} +29 -1
- package/dist/policy.d.ts +2 -2
- package/dist/policy.js +1 -202
- package/dist/proxy.d.ts +46 -0
- package/dist/proxy.js +492 -0
- package/dist/testing.d.ts +44 -0
- package/dist/testing.js +176 -0
- package/dist/tools-BQE1O-7P.d.ts +271 -0
- package/dist/verifier-D6VIAYuT.js +152 -0
- package/dist/verifier-DF6gUMQ6.d.ts +84 -0
- package/package.json +35 -11
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { a as policyDenied, i as emitDecision, o as principalLabel, s as AccessDeniedError } from "./verifier-D6VIAYuT.js";
|
|
2
|
+
import { OAuthError, OAuthErrorCode, bearerAuthChallengeResponse, classifyInboundRequest, isJsonContentType } from "@modelcontextprotocol/server";
|
|
3
|
+
//#region src/scopes.ts
|
|
4
|
+
const BASE64_SENTINEL = /^=\?base64\?([A-Za-z0-9+/]*(?:={0,2}))\?=$/;
|
|
5
|
+
const CANONICAL_BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
6
|
+
/**
|
|
7
|
+
* Tools use their bare name (or `tool:name`); prompts use `prompt:name`; and
|
|
8
|
+
* resources use `resource:<uri>`. Everything else needs only the baseline.
|
|
9
|
+
*/
|
|
10
|
+
function scopesFromMcpHeaders(request, toolScopes, baseline = "mcp") {
|
|
11
|
+
const method = request.headers.get("mcp-method");
|
|
12
|
+
const rawName = request.headers.get("mcp-name");
|
|
13
|
+
const name = rawName === null ? void 0 : decodeMcpNameHeader(rawName);
|
|
14
|
+
return scopesForCapability(method ?? void 0, name, toolScopes, baseline);
|
|
15
|
+
}
|
|
16
|
+
/** Select scopes from an already validated MCP method/name pair. */
|
|
17
|
+
function scopesForCapability(method, name, capabilityScopes, baseline = "mcp") {
|
|
18
|
+
if (!name) return [baseline];
|
|
19
|
+
const key = method === "tools/call" ? configured(capabilityScopes, name) === void 0 ? `tool:${name}` : name : method === "prompts/get" ? `prompt:${name}` : method === "resources/read" ? `resource:${name}` : void 0;
|
|
20
|
+
const required = (key === void 0 ? void 0 : configured(capabilityScopes, key)) ?? baseline;
|
|
21
|
+
return typeof required === "string" ? [required] : [...required];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* What the map actually says about this capability, and nothing it inherited.
|
|
25
|
+
*
|
|
26
|
+
* A capability may be named anything the server likes, `__proto__` included,
|
|
27
|
+
* and a plain `map[name]` answers that one with `Object.prototype` — which is
|
|
28
|
+
* not `undefined`, so it reads as a configured scope, and then is not a string
|
|
29
|
+
* or a list either. Own properties only, and only values that are one of the
|
|
30
|
+
* two shapes a scope can take.
|
|
31
|
+
*/
|
|
32
|
+
function configured(capabilityScopes, key) {
|
|
33
|
+
if (!Object.hasOwn(capabilityScopes, key)) return void 0;
|
|
34
|
+
const value = capabilityScopes[key];
|
|
35
|
+
if (typeof value === "string" || Array.isArray(value)) return value;
|
|
36
|
+
}
|
|
37
|
+
/** Decode SEP-2243's optional Base64 sentinel without accepting non-canonical input. */
|
|
38
|
+
function decodeMcpNameHeader(value) {
|
|
39
|
+
const normalized = value.trim();
|
|
40
|
+
if (!normalized.startsWith("=?base64?") || !normalized.endsWith("?=")) return normalized;
|
|
41
|
+
const encoded = BASE64_SENTINEL.exec(normalized)?.[1];
|
|
42
|
+
if (encoded === void 0 || !CANONICAL_BASE64.test(encoded)) return void 0;
|
|
43
|
+
try {
|
|
44
|
+
const binary = atob(encoded);
|
|
45
|
+
const bytes = Uint8Array.from(binary, (character) => character.codePointAt(0) ?? 0);
|
|
46
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
//#region src/routing.ts
|
|
53
|
+
const NAME_SOURCE = {
|
|
54
|
+
"tools/call": "name",
|
|
55
|
+
"prompts/get": "name",
|
|
56
|
+
"resources/read": "uri"
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Parse and validate every routing input before it can influence OAuth scopes.
|
|
60
|
+
* This mirrors the SDK's standard-header rung, including Base64 sentinel
|
|
61
|
+
* decoding, but runs before bearer authorization rather than during dispatch.
|
|
62
|
+
*/
|
|
63
|
+
function classifyScopedRequest(request, body) {
|
|
64
|
+
if (request.method.toUpperCase() !== "POST") return {
|
|
65
|
+
kind: "legacy",
|
|
66
|
+
body: void 0,
|
|
67
|
+
outcome: {
|
|
68
|
+
kind: "legacy",
|
|
69
|
+
reason: "http-method"
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const outcome = classifyInboundRequest({
|
|
73
|
+
httpMethod: request.method,
|
|
74
|
+
...header(request, "mcp-protocol-version", "protocolVersionHeader"),
|
|
75
|
+
...header(request, "mcp-method", "mcpMethodHeader"),
|
|
76
|
+
...header(request, "mcp-name", "mcpNameHeader"),
|
|
77
|
+
body
|
|
78
|
+
});
|
|
79
|
+
if (outcome.kind === "reject") return rejected(outcome.httpStatus, outcome.code, outcome.message, outcome.data, requestId$1(body));
|
|
80
|
+
if (outcome.kind === "legacy") return {
|
|
81
|
+
kind: "legacy",
|
|
82
|
+
body,
|
|
83
|
+
outcome
|
|
84
|
+
};
|
|
85
|
+
const method = outcome.message.method;
|
|
86
|
+
if (outcome.messageKind !== "request") return {
|
|
87
|
+
kind: "modern",
|
|
88
|
+
body,
|
|
89
|
+
outcome,
|
|
90
|
+
method
|
|
91
|
+
};
|
|
92
|
+
if (request.headers.get("mcp-method") === null) return mismatch("(missing)", `the body names method ${method} but the required Mcp-Method header is absent`, body);
|
|
93
|
+
const source = Object.hasOwn(NAME_SOURCE, method) ? NAME_SOURCE[method] : void 0;
|
|
94
|
+
if (source === void 0) return {
|
|
95
|
+
kind: "modern",
|
|
96
|
+
body,
|
|
97
|
+
outcome,
|
|
98
|
+
method
|
|
99
|
+
};
|
|
100
|
+
const params = isRecord(outcome.message.params) ? outcome.message.params : void 0;
|
|
101
|
+
const bodyName = typeof params?.[source] === "string" ? params[source] : void 0;
|
|
102
|
+
const rawName = request.headers.get("mcp-name");
|
|
103
|
+
if (rawName === null) {
|
|
104
|
+
if (bodyName === void 0) return {
|
|
105
|
+
kind: "modern",
|
|
106
|
+
body,
|
|
107
|
+
outcome,
|
|
108
|
+
method
|
|
109
|
+
};
|
|
110
|
+
return mismatch("(missing)", `the body carries params.${source}="${bodyName}" but the required Mcp-Name header is absent`, body);
|
|
111
|
+
}
|
|
112
|
+
const decodedName = decodeMcpNameHeader(rawName);
|
|
113
|
+
if (decodedName === void 0) return mismatch(rawName.trim(), "the Mcp-Name header carries an invalid Base64 sentinel value", body);
|
|
114
|
+
if (bodyName !== void 0 && decodedName !== bodyName) return mismatch(rawName.trim(), `the body carries params.${source}="${bodyName}" but the Mcp-Name header names "${decodedName}"`, body);
|
|
115
|
+
return {
|
|
116
|
+
kind: "modern",
|
|
117
|
+
body,
|
|
118
|
+
outcome,
|
|
119
|
+
method,
|
|
120
|
+
...bodyName === void 0 ? {} : { name: bodyName }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function header(request, name, property) {
|
|
124
|
+
const value = request.headers.get(name);
|
|
125
|
+
return value === null ? {} : { [property]: value };
|
|
126
|
+
}
|
|
127
|
+
function mismatch(headerValue, bodyDescription, body) {
|
|
128
|
+
return rejected(400, -32020, `Bad Request: the request headers and body disagree: ${bodyDescription}`, { mismatch: {
|
|
129
|
+
header: headerValue,
|
|
130
|
+
body: bodyDescription
|
|
131
|
+
} }, requestId$1(body));
|
|
132
|
+
}
|
|
133
|
+
function rejected(httpStatus, code, message, data, id) {
|
|
134
|
+
return {
|
|
135
|
+
kind: "reject",
|
|
136
|
+
httpStatus,
|
|
137
|
+
code,
|
|
138
|
+
message,
|
|
139
|
+
...data === void 0 ? {} : { data },
|
|
140
|
+
id
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function requestId$1(body) {
|
|
144
|
+
if (!isRecord(body)) return null;
|
|
145
|
+
const id = body.id;
|
|
146
|
+
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
147
|
+
}
|
|
148
|
+
function isRecord(value) {
|
|
149
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The route a JSON-RPC body names, ignoring headers entirely.
|
|
153
|
+
*
|
|
154
|
+
* For a request that carries no trustworthy 2026 routing headers there is
|
|
155
|
+
* nothing to cross-check, and the choice is between reading the body and
|
|
156
|
+
* refusing to say what the request does. A caller that has to price every
|
|
157
|
+
* capability it forwards needs the former: the body is the only claim the
|
|
158
|
+
* upstream will act on, so it is the claim to authorize against.
|
|
159
|
+
*/
|
|
160
|
+
function routeFromBody(body) {
|
|
161
|
+
if (!isRecord(body)) return void 0;
|
|
162
|
+
const method = body.method;
|
|
163
|
+
if (typeof method !== "string") return void 0;
|
|
164
|
+
const params = isRecord(body.params) ? body.params : void 0;
|
|
165
|
+
const source = Object.hasOwn(NAME_SOURCE, method) ? NAME_SOURCE[method] : void 0;
|
|
166
|
+
const name = source !== void 0 && typeof params?.[source] === "string" ? params[source] : void 0;
|
|
167
|
+
return {
|
|
168
|
+
kind: "modern",
|
|
169
|
+
body,
|
|
170
|
+
method,
|
|
171
|
+
...name === void 0 ? {} : { name }
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/upstream.ts
|
|
176
|
+
/**
|
|
177
|
+
* Headers that must not survive a hop.
|
|
178
|
+
*
|
|
179
|
+
* `authorization` and `cookie` are the caller's credentials for *this* proxy;
|
|
180
|
+
* forwarding either would hand a third-party upstream a token it was never the
|
|
181
|
+
* audience for. The rest are hop-by-hop per RFC 9110 §7.6.1 — they describe the
|
|
182
|
+
* connection that just ended, not the one about to be opened — plus `host`,
|
|
183
|
+
* which the new URL decides.
|
|
184
|
+
*/
|
|
185
|
+
const DROPPED_ON_FORWARD = /* @__PURE__ */ new Set([
|
|
186
|
+
"authorization",
|
|
187
|
+
"cookie",
|
|
188
|
+
"host",
|
|
189
|
+
"connection",
|
|
190
|
+
"keep-alive",
|
|
191
|
+
"proxy-authenticate",
|
|
192
|
+
"proxy-authorization",
|
|
193
|
+
"te",
|
|
194
|
+
"trailer",
|
|
195
|
+
"transfer-encoding",
|
|
196
|
+
"upgrade"
|
|
197
|
+
]);
|
|
198
|
+
async function forwardToUpstream(request, config) {
|
|
199
|
+
const fetchFn = config.fetch ?? fetch;
|
|
200
|
+
const bearer = typeof config.bearer === "function" ? await config.bearer() : config.bearer;
|
|
201
|
+
const target = new URL(config.url);
|
|
202
|
+
const dropped = new Set(DROPPED_ON_FORWARD);
|
|
203
|
+
for (const named of request.headers.get("connection")?.split(",") ?? []) {
|
|
204
|
+
const field = named.trim().toLowerCase();
|
|
205
|
+
if (field) dropped.add(field);
|
|
206
|
+
}
|
|
207
|
+
const headers = new Headers();
|
|
208
|
+
for (const [name, value] of request.headers) if (!dropped.has(name.toLowerCase())) headers.set(name, value);
|
|
209
|
+
headers.set("authorization", `Bearer ${bearer}`);
|
|
210
|
+
return fetchFn(new Request(target, {
|
|
211
|
+
method: request.method,
|
|
212
|
+
headers,
|
|
213
|
+
body: request.body,
|
|
214
|
+
duplex: "half"
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The upstream's headers minus the framing that described a body we replaced.
|
|
219
|
+
*
|
|
220
|
+
* A filtered listing is shorter than what arrived, and `fetch` has already
|
|
221
|
+
* decoded any `content-encoding`, so copying either header across would
|
|
222
|
+
* describe the old body: a client would read a truncated response, or try to
|
|
223
|
+
* gunzip plain JSON.
|
|
224
|
+
*/
|
|
225
|
+
function headersForRewrittenBody(response) {
|
|
226
|
+
const headers = new Headers(response.headers);
|
|
227
|
+
headers.delete("content-length");
|
|
228
|
+
headers.delete("content-encoding");
|
|
229
|
+
return headers;
|
|
230
|
+
}
|
|
231
|
+
function isEventStream(response) {
|
|
232
|
+
return (response.headers.get("content-type") ?? "").includes("text/event-stream");
|
|
233
|
+
}
|
|
234
|
+
function isJsonResponse(response) {
|
|
235
|
+
const type = response.headers.get("content-type") ?? "";
|
|
236
|
+
return type.includes("application/json") || type.includes("+json");
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* A response body as text, or `undefined` when it is over the cap.
|
|
240
|
+
*
|
|
241
|
+
* Reads chunk by chunk and stops at the limit rather than buffering first and
|
|
242
|
+
* measuring after, because measuring after is how an upstream decides how much
|
|
243
|
+
* memory this process spends. Consumes the body rather than cloning it, so
|
|
244
|
+
* there is no second buffered branch left behind holding the same bytes.
|
|
245
|
+
*/
|
|
246
|
+
async function readCappedBody(body, maxBytes) {
|
|
247
|
+
if (!body) return "";
|
|
248
|
+
const reader = body.getReader();
|
|
249
|
+
const chunks = [];
|
|
250
|
+
let total = 0;
|
|
251
|
+
try {
|
|
252
|
+
for (;;) {
|
|
253
|
+
const { done, value } = await reader.read();
|
|
254
|
+
if (done) break;
|
|
255
|
+
total += value.byteLength;
|
|
256
|
+
if (total > maxBytes) {
|
|
257
|
+
reader.cancel().catch(() => {});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
chunks.push(value);
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
reader.releaseLock();
|
|
264
|
+
}
|
|
265
|
+
const joined = new Uint8Array(total);
|
|
266
|
+
let at = 0;
|
|
267
|
+
for (const chunk of chunks) {
|
|
268
|
+
joined.set(chunk, at);
|
|
269
|
+
at += chunk.byteLength;
|
|
270
|
+
}
|
|
271
|
+
return new TextDecoder().decode(joined);
|
|
272
|
+
}
|
|
273
|
+
//#endregion
|
|
274
|
+
//#region src/ladder.ts
|
|
275
|
+
/**
|
|
276
|
+
* The rungs both enforcement locations share: classify a request, price the
|
|
277
|
+
* capability it names, then step up its scope.
|
|
278
|
+
*
|
|
279
|
+
* **Every rung here has to say which side's assumptions it encodes.** The two
|
|
280
|
+
* callers are not symmetric, and where they differ is where the security bugs
|
|
281
|
+
* live. `createMcpFetch` has the SDK downstream of it, so it can be lenient
|
|
282
|
+
* with a request it cannot classify and let the SDK refuse it a second way.
|
|
283
|
+
* `createMcpProxy` has nothing downstream that re-checks anything, and forwards
|
|
284
|
+
* on a service credential that outranks the caller, so the same leniency hands
|
|
285
|
+
* an upstream a call nobody priced. That difference is `strictClassification`,
|
|
286
|
+
* and it was a header-smuggling hole before it was an option.
|
|
287
|
+
*
|
|
288
|
+
* The same shape has surfaced twice more: a resource is named by label in a
|
|
289
|
+
* permission map and by URI on the wire, and a listing filter that cannot read
|
|
290
|
+
* a response has to withhold it rather than pass it on. A shared rung that
|
|
291
|
+
* silently picks one caller's default is the bug, not the sharing.
|
|
292
|
+
*/
|
|
293
|
+
/** Bare name for a tool, `prompt:`/`resource:` prefixed for the rest — same as `gate()`. */
|
|
294
|
+
function capabilityLabel(kind, name) {
|
|
295
|
+
return kind === "tool" ? name : `${kind}:${name}`;
|
|
296
|
+
}
|
|
297
|
+
function permissionForRoute(resolver, permissions, route) {
|
|
298
|
+
if (!route.name || !resolver && !permissions) return void 0;
|
|
299
|
+
const kind = route.method === "tools/call" ? "tool" : route.method === "prompts/get" ? "prompt" : route.method === "resources/read" ? "resource" : void 0;
|
|
300
|
+
return kind ? resolver?.(kind, route.name) ?? permissions?.get(`${kind}:${route.name}`) : void 0;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Resolve a permission from a flat map whose tool keys are bare names.
|
|
304
|
+
*
|
|
305
|
+
* `tools/call` and `prompts/get` carry the capability's name, which is what the
|
|
306
|
+
* map is keyed by. `resources/read` carries a URI instead — `cases://case/C1`,
|
|
307
|
+
* never `case` — so it is answered from the resource index rather than by
|
|
308
|
+
* looking up a name the request never sent.
|
|
309
|
+
*/
|
|
310
|
+
function permissionForFlatMap(permissions, route, resources = [], can = () => true) {
|
|
311
|
+
if (!route.name) return void 0;
|
|
312
|
+
if (route.method === "tools/call") return permissions.get(route.name) ?? permissions.get(`tool:${route.name}`);
|
|
313
|
+
if (route.method === "prompts/get") return permissions.get(`prompt:${route.name}`);
|
|
314
|
+
if (route.method !== "resources/read") return void 0;
|
|
315
|
+
const matches = resources.filter((entry) => entry.matches(route.name));
|
|
316
|
+
if (matches.length === 0) return void 0;
|
|
317
|
+
return (matches.find((entry) => !can(entry.permission)) ?? matches[0])?.permission;
|
|
318
|
+
}
|
|
319
|
+
async function runScopedGate(options) {
|
|
320
|
+
const { request, auth, principal, maxRequestBytes, requiredScopes, scoped, routed, strictClassification = false, scopeMap, resolveCapabilityScopes, scopesForRequest, resolvePermission, onDecision, emitter, resourceMetadataUrl } = options;
|
|
321
|
+
const classified = (scoped || routed) && request.method.toUpperCase() === "POST" ? await preflightScopedRequest(request, maxRequestBytes) : void 0;
|
|
322
|
+
if (classified instanceof Response && (scoped || strictClassification)) return {
|
|
323
|
+
ok: false,
|
|
324
|
+
response: classified
|
|
325
|
+
};
|
|
326
|
+
const preflight = classified instanceof Response ? void 0 : classified;
|
|
327
|
+
if (scoped && preflight && !preflight.headersValidated) return {
|
|
328
|
+
ok: false,
|
|
329
|
+
response: protocolError(400, -32020, "Per-capability scopes require a 2026-07-28 request with matching MCP routing headers.", void 0, requestId(preflight.body))
|
|
330
|
+
};
|
|
331
|
+
const routePermission = preflight ? resolvePermission(preflight.route) : void 0;
|
|
332
|
+
if (routePermission && principal && !principal.can(routePermission)) {
|
|
333
|
+
await emitDecision(onDecision, principal, "deny", "policy_denied", emitter);
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
response: policyDenied(AccessDeniedError.notPermitted(principalLabel(principal), `the permission '${routePermission}'`))
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
const scopes = preflight && scoped ? scopeMap ? [.../* @__PURE__ */ new Set([...requiredScopes, ...resolveCapabilityScopes?.(preflight.route) ?? scopesForCapability(preflight.route.method, preflight.route.name, scopeMap, requiredScopes[0] ?? "mcp")])] : scopesForRequest(request, preflight.route) : requiredScopes;
|
|
340
|
+
if (scopes.filter((scope) => !auth.scopes.includes(scope)).length > 0) return {
|
|
341
|
+
ok: false,
|
|
342
|
+
response: bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InsufficientScope, "Insufficient scope"), {
|
|
343
|
+
requiredScopes: scopes,
|
|
344
|
+
resourceMetadataUrl
|
|
345
|
+
})
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
ok: true,
|
|
349
|
+
...preflight ? { preflight } : {}
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
async function preflightScopedRequest(request, maxBytes) {
|
|
353
|
+
if (!isJsonContentType(request.headers.get("content-type"))) return protocolError(415, -32e3, "Per-capability scopes require an application/json body.");
|
|
354
|
+
const raw = await readCapped(request, maxBytes);
|
|
355
|
+
if (raw === void 0) return protocolError(413, -32e3, `Request body exceeds the ${maxBytes} byte limit.`);
|
|
356
|
+
let body;
|
|
357
|
+
try {
|
|
358
|
+
body = JSON.parse(raw);
|
|
359
|
+
} catch {
|
|
360
|
+
return protocolError(400, -32700, "Parse error: the request body is not valid JSON");
|
|
361
|
+
}
|
|
362
|
+
const route = classifyScopedRequest(request, body);
|
|
363
|
+
if (route.kind === "reject") return protocolError(route.httpStatus, route.code, route.message, route.data, route.id);
|
|
364
|
+
if (route.kind !== "modern") {
|
|
365
|
+
const derived = routeFromBody(body);
|
|
366
|
+
return derived === void 0 ? protocolError(400, -32600, "Invalid Request: no JSON-RPC method to route on.", void 0, requestId(body)) : {
|
|
367
|
+
body,
|
|
368
|
+
route: derived,
|
|
369
|
+
headersValidated: false
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
body,
|
|
374
|
+
route,
|
|
375
|
+
headersValidated: true
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
function requestId(body) {
|
|
379
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
|
|
380
|
+
const id = body.id;
|
|
381
|
+
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
382
|
+
}
|
|
383
|
+
function protocolError(status, code, message, data, id = null) {
|
|
384
|
+
return Response.json({
|
|
385
|
+
jsonrpc: "2.0",
|
|
386
|
+
error: {
|
|
387
|
+
code,
|
|
388
|
+
message,
|
|
389
|
+
...data === void 0 ? {} : { data }
|
|
390
|
+
},
|
|
391
|
+
id
|
|
392
|
+
}, { status });
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* The request body as text, or `undefined` when it is over the cap.
|
|
396
|
+
*
|
|
397
|
+
* A declared `Content-Length` over the limit is refused without reading
|
|
398
|
+
* anything; the stream itself is then held to the same limit, because the
|
|
399
|
+
* header is the caller's claim rather than a measurement.
|
|
400
|
+
*/
|
|
401
|
+
async function readCapped(request, maxBytes) {
|
|
402
|
+
const declared = Number(request.headers.get("content-length"));
|
|
403
|
+
if (Number.isFinite(declared) && declared > maxBytes) return void 0;
|
|
404
|
+
return readCappedBody(request.clone().body, maxBytes);
|
|
405
|
+
}
|
|
406
|
+
//#endregion
|
|
407
|
+
export { forwardToUpstream as a, isJsonResponse as c, scopesForCapability as d, scopesFromMcpHeaders as f, runScopedGate as i, readCappedBody as l, permissionForFlatMap as n, headersForRewrittenBody as o, permissionForRoute as r, isEventStream as s, capabilityLabel as t, decodeMcpNameHeader as u };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { AuthInfo, InboundClassificationOutcome } from "@modelcontextprotocol/server";
|
|
2
|
+
//#region src/routing.d.ts
|
|
3
|
+
type TrustedMcpRoute = {
|
|
4
|
+
kind: 'modern';
|
|
5
|
+
body: unknown;
|
|
6
|
+
/** Absent on a route derived from the body alone; nothing downstream reads it. */
|
|
7
|
+
outcome?: Extract<InboundClassificationOutcome, {
|
|
8
|
+
kind: 'modern';
|
|
9
|
+
}>;
|
|
10
|
+
method: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
};
|
|
13
|
+
//#endregion
|
|
14
|
+
//#region src/scopes.d.ts
|
|
15
|
+
/**
|
|
16
|
+
* Helpers for deriving required OAuth scopes from Streamable HTTP headers
|
|
17
|
+
* (SEP-2243). Use with `createMcpFetch({ scopesForRequest })`.
|
|
18
|
+
*/
|
|
19
|
+
type ScopeRequirement = string | readonly string[];
|
|
20
|
+
type ToolScopeMap = Readonly<Record<string, ScopeRequirement>>;
|
|
21
|
+
type CapabilityScopeMap = ToolScopeMap;
|
|
22
|
+
/**
|
|
23
|
+
* Tools use their bare name (or `tool:name`); prompts use `prompt:name`; and
|
|
24
|
+
* resources use `resource:<uri>`. Everything else needs only the baseline.
|
|
25
|
+
*/
|
|
26
|
+
declare function scopesFromMcpHeaders(request: Request, toolScopes: ToolScopeMap, baseline?: string): string[];
|
|
27
|
+
/** Select scopes from an already validated MCP method/name pair. */
|
|
28
|
+
declare function scopesForCapability(method: string | undefined, name: string | undefined, capabilityScopes: CapabilityScopeMap, baseline?: string): string[];
|
|
29
|
+
/** Decode SEP-2243's optional Base64 sentinel without accepting non-canonical input. */
|
|
30
|
+
declare function decodeMcpNameHeader(value: string): string | undefined;
|
|
31
|
+
//#endregion
|
|
32
|
+
export { scopesForCapability as a, decodeMcpNameHeader as i, ScopeRequirement as n, scopesFromMcpHeaders as o, ToolScopeMap as r, TrustedMcpRoute as s, CapabilityScopeMap as t };
|
|
@@ -0,0 +1,112 @@
|
|
|
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";
|
|
3
|
+
import { a as AuthorizationDecisionSink, t as VerifierOptions } from "./verifier-DF6gUMQ6.js";
|
|
4
|
+
import { t as PermissionMapRecord } from "./permissions-module-DxCHuE-N.js";
|
|
5
|
+
import { AuthInfo, OAuthMetadata, OAuthTokenVerifier } from "@modelcontextprotocol/server";
|
|
6
|
+
//#region src/openapi.d.ts
|
|
7
|
+
/**
|
|
8
|
+
* The same bet as the MCP side, on the other catalogue an agent reads.
|
|
9
|
+
*
|
|
10
|
+
* `gate()` earns its keep because a tool that is never registered is a tool the
|
|
11
|
+
* model never sees, so it never tries. An HTTP API has one thing shaped like
|
|
12
|
+
* that list: its OpenAPI document. Filtering the served document per caller is
|
|
13
|
+
* the same move — a smaller prompt, and no confident calls into a 403.
|
|
14
|
+
*
|
|
15
|
+
* For a hand-written client it changes nothing, because that client was coded
|
|
16
|
+
* against the spec months ago. Hiding an operation is not the boundary; the
|
|
17
|
+
* check on the request is, and it runs here whether or not the caller ever read
|
|
18
|
+
* the document.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* As much of an OpenAPI document as this needs to know about. Everything else
|
|
22
|
+
* rides along untouched, so a document with `components`, `webhooks` or a
|
|
23
|
+
* vendor extension comes back out the way it went in.
|
|
24
|
+
*/
|
|
25
|
+
type OpenApiDocument = {
|
|
26
|
+
paths?: Record<string, Record<string, unknown> | undefined>;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
};
|
|
29
|
+
type OpenApiOperation = {
|
|
30
|
+
/** The document's own `operationId`, which is what the permission map is keyed by. */
|
|
31
|
+
operationId: string;
|
|
32
|
+
/** Lowercased HTTP method. */
|
|
33
|
+
method: string;
|
|
34
|
+
/** The templated path, e.g. `/cases/{id}`. */
|
|
35
|
+
path: string;
|
|
36
|
+
};
|
|
37
|
+
type OperationRecord = PermissionMapRecord & {
|
|
38
|
+
/** Every operation the document describes, in document order. */
|
|
39
|
+
operations: OpenApiOperation[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* What the document says this API can do, read off the document.
|
|
43
|
+
*
|
|
44
|
+
* The mirror of `recordCapabilities`, and it needs no running server: an
|
|
45
|
+
* OpenAPI file is the catalogue, already sitting in your repository. Feed the
|
|
46
|
+
* result to `toPermissionsModule` for a map priced `TODO:unassigned`, which no
|
|
47
|
+
* role grants and the boot refuses until somebody decides what each operation
|
|
48
|
+
* costs.
|
|
49
|
+
*/
|
|
50
|
+
declare function recordOperations(spec: OpenApiDocument): OperationRecord;
|
|
51
|
+
/**
|
|
52
|
+
* The document as this caller should see it: their operations, and nothing else.
|
|
53
|
+
*
|
|
54
|
+
* A path item left with no operations is dropped, so the reader is not offered
|
|
55
|
+
* a route with no verbs. `components` is deliberately left whole — pruning it
|
|
56
|
+
* means walking the `$ref` graph, and a schema nobody references costs a few
|
|
57
|
+
* hundred tokens where a wrongly-pruned one breaks the document.
|
|
58
|
+
*/
|
|
59
|
+
declare function filterSpec<P extends string>(spec: OpenApiDocument, principal: Principal<P>, permissions: Readonly<Record<string, string>>): OpenApiDocument;
|
|
60
|
+
type OpenApiFetchOptions<P extends string = string> = {
|
|
61
|
+
/** The document describing this API. Also the catalogue served to callers. */
|
|
62
|
+
spec: OpenApiDocument;
|
|
63
|
+
/** `operationId` to the permission it costs. Every operation needs an entry. */
|
|
64
|
+
permissions: Readonly<Record<string, string>>;
|
|
65
|
+
/** This API's public base URL, e.g. `https://api.acme.com`. Tokens must carry it. */
|
|
66
|
+
resourceServerUrl: URL;
|
|
67
|
+
/** RFC 8414 metadata for the authorization server in front of us. */
|
|
68
|
+
oauthMetadata: OAuthMetadata;
|
|
69
|
+
/** Token verification. Defaults come from `oauthMetadata` and `resourceServerUrl`. */
|
|
70
|
+
verifier?: Partial<VerifierOptions>;
|
|
71
|
+
/** Bring any SDK-compatible verifier. Mutually exclusive with `verifier`. */
|
|
72
|
+
tokenVerifier?: OAuthTokenVerifier;
|
|
73
|
+
/** Map a custom verifier's AuthInfo into the identity consumed by policy. */
|
|
74
|
+
identityFromAuth?: (auth: AuthInfo) => Identity;
|
|
75
|
+
/** Baseline scopes every request must carry. */
|
|
76
|
+
requiredScopes?: string[];
|
|
77
|
+
/** All scopes advertised in protected-resource metadata. */
|
|
78
|
+
supportedScopes?: string[];
|
|
79
|
+
/** Who may call and what they may do. */
|
|
80
|
+
policy?: Policy<P>;
|
|
81
|
+
/** Async alternative to `policy`, for roles held elsewhere. */
|
|
82
|
+
authorize?: (identity: Identity) => Promise<Principal<P>> | Principal<P>;
|
|
83
|
+
/** Awaited access-decision sink. Sees the refusals, including probes. */
|
|
84
|
+
onDecision?: AuthorizationDecisionSink;
|
|
85
|
+
/** Awaited audit sink. `attempt` then `success` or `failure`, per call. */
|
|
86
|
+
onAudit?: AuditSink;
|
|
87
|
+
/** Where a failed terminal audit write goes. */
|
|
88
|
+
onAuditError?: AuditErrorSink;
|
|
89
|
+
/** Where the filtered document is served. Defaults to `/openapi.json`. */
|
|
90
|
+
specPath?: string;
|
|
91
|
+
/**
|
|
92
|
+
* Names this deployment on every event it emits.
|
|
93
|
+
*
|
|
94
|
+
* Set the same value in every entry point of one deployment: a dashboard
|
|
95
|
+
* reading several of them cannot otherwise tell which server refused a call.
|
|
96
|
+
*/
|
|
97
|
+
emitter?: string;
|
|
98
|
+
/** Your API. Reached only for a request this caller is permitted to make. */
|
|
99
|
+
upstream: (request: Request, principal: Principal<P>) => Promise<Response> | Response;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* An OAuth 2.1 resource server in front of an API you already have.
|
|
103
|
+
*
|
|
104
|
+
* Anything the document does not describe is refused. That is the same rule as
|
|
105
|
+
* the MCP side — a capability nobody priced is reachable by everyone or by
|
|
106
|
+
* nobody, with no error to read — and it means routes you deliberately leave
|
|
107
|
+
* out of the spec (a health check, static files) belong outside this wrapper
|
|
108
|
+
* rather than behind it.
|
|
109
|
+
*/
|
|
110
|
+
declare function createOpenApiFetch<P extends string = string>(options: OpenApiFetchOptions<P>): (request: Request) => Promise<Response>;
|
|
111
|
+
//#endregion
|
|
112
|
+
export { OpenApiDocument, OpenApiFetchOptions, OpenApiOperation, OperationRecord, createOpenApiFetch, filterSpec, recordOperations };
|