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
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { a as policyDenied, i as emitDecision, n as jwksVerifier, o as principalLabel, s as AccessDeniedError, t as identityFromAuth } from "./verifier-D6VIAYuT.js";
|
|
2
|
+
import { a as forwardToUpstream, c as isJsonResponse, d as scopesForCapability, i as runScopedGate, l as readCappedBody, n as permissionForFlatMap, o as headersForRewrittenBody, s as isEventStream, t as capabilityLabel } from "./ladder-CUzOKudC.js";
|
|
3
|
+
import { i as reconcile } from "./policy-BBp3Jq6G.js";
|
|
4
|
+
import { UriTemplate, getOAuthProtectedResourceMetadataUrl, oauthMetadataResponse, requireBearerAuth } from "@modelcontextprotocol/server";
|
|
5
|
+
import { err, isUnexpectedError, ok, run } from "awaitly";
|
|
6
|
+
//#region src/catalogue.ts
|
|
7
|
+
function filterByPermission(items, kind, principal, permissions) {
|
|
8
|
+
return items.filter((item) => {
|
|
9
|
+
const label = capabilityLabel(kind, item.name);
|
|
10
|
+
const permission = permissions.get(label);
|
|
11
|
+
if (permission === void 0) return false;
|
|
12
|
+
return principal.can(permission);
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
function filterToolsList(tools, principal, permissions) {
|
|
16
|
+
return filterByPermission(tools, "tool", principal, permissions);
|
|
17
|
+
}
|
|
18
|
+
function filterPromptsList(prompts, principal, permissions) {
|
|
19
|
+
return filterByPermission(prompts, "prompt", principal, permissions);
|
|
20
|
+
}
|
|
21
|
+
function filterResourcesList(resources, principal, permissions) {
|
|
22
|
+
return filterByPermission(resources, "resource", principal, permissions);
|
|
23
|
+
}
|
|
24
|
+
function filterResourceTemplatesList(templates, principal, permissions) {
|
|
25
|
+
return filterByPermission(templates, "resource", principal, permissions);
|
|
26
|
+
}
|
|
27
|
+
const LISTING_FIELDS = {
|
|
28
|
+
"tools/list": "tools",
|
|
29
|
+
"prompts/list": "prompts",
|
|
30
|
+
"resources/list": "resources",
|
|
31
|
+
"resources/templates/list": "resourceTemplates"
|
|
32
|
+
};
|
|
33
|
+
function isListingMethod(method) {
|
|
34
|
+
return method !== void 0 && Object.hasOwn(LISTING_FIELDS, method);
|
|
35
|
+
}
|
|
36
|
+
function isInvocationMethod(method) {
|
|
37
|
+
return method === "tools/call" || method === "prompts/get" || method === "resources/read";
|
|
38
|
+
}
|
|
39
|
+
function filterListingResult(method, result, principal, permissions) {
|
|
40
|
+
const field = LISTING_FIELDS[method];
|
|
41
|
+
const items = result[field];
|
|
42
|
+
if (!Array.isArray(items)) return result;
|
|
43
|
+
const filtered = field === "tools" ? filterToolsList(items, principal, permissions) : field === "prompts" ? filterPromptsList(items, principal, permissions) : field === "resources" ? filterResourcesList(items, principal, permissions) : filterResourceTemplatesList(items, principal, permissions);
|
|
44
|
+
return {
|
|
45
|
+
...result,
|
|
46
|
+
[field]: filtered
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/proxy.ts
|
|
51
|
+
const OAUTH_SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
52
|
+
function createMcpProxy(options) {
|
|
53
|
+
const { resourceServerUrl, oauthMetadata, upstream, policy, permissions: permissionsInput, requiredScopes = ["mcp"], supportedScopes, capabilityScopes, resourceUris, emitter, healthPath = "/health", maxRequestBytes = 1048576 } = options;
|
|
54
|
+
if (options.tokenVerifier && options.verifier) throw new Error("Pass either `tokenVerifier` or built-in `verifier` options, not both.");
|
|
55
|
+
const permissions = toPermissionMap(permissionsInput);
|
|
56
|
+
validatePermissions(permissions);
|
|
57
|
+
validateScopes(capabilityScopes, permissions);
|
|
58
|
+
const resources = buildResourceIndex(permissions, resourceUris);
|
|
59
|
+
const invalidRequiredScope = requiredScopes.find((scope) => !OAUTH_SCOPE_TOKEN.test(scope));
|
|
60
|
+
if (invalidRequiredScope !== void 0) throw new Error(`requiredScopes has an invalid scope: '${invalidRequiredScope}'.`);
|
|
61
|
+
const invalidSupportedScope = supportedScopes?.find((scope) => !OAUTH_SCOPE_TOKEN.test(scope));
|
|
62
|
+
if (invalidSupportedScope !== void 0) throw new Error(`supportedScopes has an invalid scope: '${invalidSupportedScope}'.`);
|
|
63
|
+
if (capabilityScopes && requiredScopes.length === 0) throw new Error("A declarative scope map needs at least one baseline scope.");
|
|
64
|
+
for (const [key, scopes] of Object.entries(capabilityScopes ?? {})) {
|
|
65
|
+
if (Array.isArray(scopes) && scopes.length === 0) throw new Error(`Scope map key '${key}' needs at least one scope.`);
|
|
66
|
+
if ((typeof scopes === "string" ? [scopes] : scopes).some((scope) => !OAUTH_SCOPE_TOKEN.test(scope))) throw new Error(`Scope map key '${key}' has an invalid scope.`);
|
|
67
|
+
}
|
|
68
|
+
const { error, warning } = reconcile(policy.roles, permissions);
|
|
69
|
+
if (warning) console.warn(warning);
|
|
70
|
+
if (error) throw new Error(error);
|
|
71
|
+
const advertisedScopes = [.../* @__PURE__ */ new Set([
|
|
72
|
+
...requiredScopes,
|
|
73
|
+
...supportedScopes ?? [],
|
|
74
|
+
...Object.values(capabilityScopes ?? {}).flatMap((scope) => typeof scope === "string" ? [scope] : [...scope])
|
|
75
|
+
])];
|
|
76
|
+
let tokenVerifier;
|
|
77
|
+
let mapIdentity;
|
|
78
|
+
if (options.tokenVerifier) {
|
|
79
|
+
tokenVerifier = options.tokenVerifier;
|
|
80
|
+
mapIdentity = options.identityFromAuth ?? identityFromAuth;
|
|
81
|
+
} else {
|
|
82
|
+
const published = typeof oauthMetadata.jwks_uri === "string" ? oauthMetadata.jwks_uri : void 0;
|
|
83
|
+
const jwksUri = options.verifier?.jwksUri ?? published;
|
|
84
|
+
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`.");
|
|
85
|
+
const builtIn = jwksVerifier({
|
|
86
|
+
...options.verifier,
|
|
87
|
+
jwksUri,
|
|
88
|
+
issuer: options.verifier?.issuer ?? oauthMetadata.issuer,
|
|
89
|
+
resource: options.verifier?.resource ?? resourceServerUrl
|
|
90
|
+
});
|
|
91
|
+
tokenVerifier = builtIn;
|
|
92
|
+
mapIdentity = options.identityFromAuth ?? builtIn.identityOf;
|
|
93
|
+
}
|
|
94
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceServerUrl);
|
|
95
|
+
const metadataOptions = {
|
|
96
|
+
oauthMetadata,
|
|
97
|
+
resourceServerUrl,
|
|
98
|
+
scopesSupported: advertisedScopes
|
|
99
|
+
};
|
|
100
|
+
const granted = new Set([...policy.roles.values()].flat());
|
|
101
|
+
const steps = {
|
|
102
|
+
/** Baseline OAuth: a valid token for this resource, carrying the baseline scopes. */
|
|
103
|
+
verify: async (request) => {
|
|
104
|
+
const answer = await requireBearerAuth({
|
|
105
|
+
verifier: tokenVerifier,
|
|
106
|
+
requiredScopes,
|
|
107
|
+
resourceMetadataUrl
|
|
108
|
+
})(request);
|
|
109
|
+
return answer instanceof Response ? err(answer) : ok(answer);
|
|
110
|
+
},
|
|
111
|
+
/** Identity to principal. Nothing granted is a refusal, not an empty pass. */
|
|
112
|
+
authorize: async (auth) => {
|
|
113
|
+
let principal;
|
|
114
|
+
try {
|
|
115
|
+
principal = policy(mapIdentity(auth));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof AccessDeniedError) return err(policyDenied(error));
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (principal.permissions.length === 0) {
|
|
121
|
+
await emitDecision(options.onDecision, principal, "deny", "not_permitted", emitter);
|
|
122
|
+
return err(policyDenied(AccessDeniedError.notPermitted(principalLabel(principal))));
|
|
123
|
+
}
|
|
124
|
+
await emitDecision(options.onDecision, principal, "allow", void 0, emitter);
|
|
125
|
+
return ok(principal);
|
|
126
|
+
},
|
|
127
|
+
/** Classify, price the named capability, then step up the scope if one is configured. */
|
|
128
|
+
gate: async (request, auth, principal) => {
|
|
129
|
+
const can = (permission) => principal.can(permission);
|
|
130
|
+
const result = await runScopedGate({
|
|
131
|
+
request,
|
|
132
|
+
auth,
|
|
133
|
+
principal,
|
|
134
|
+
maxRequestBytes,
|
|
135
|
+
requiredScopes,
|
|
136
|
+
scoped: Boolean(capabilityScopes),
|
|
137
|
+
routed: true,
|
|
138
|
+
strictClassification: true,
|
|
139
|
+
scopeMap: capabilityScopes,
|
|
140
|
+
resolveCapabilityScopes: (route) => {
|
|
141
|
+
if (route.method !== "resources/read" || !route.name || !capabilityScopes) return void 0;
|
|
142
|
+
const labels = resources.filter((entry) => entry.matches(route.name)).map((e) => e.label);
|
|
143
|
+
if (labels.length === 0) return void 0;
|
|
144
|
+
return [...new Set(labels.flatMap((label) => scopesForCapability(route.method, label.slice(9), capabilityScopes, requiredScopes[0] ?? "mcp")))];
|
|
145
|
+
},
|
|
146
|
+
resolvePermission: (route) => permissionForFlatMap(permissions, route, resources, can),
|
|
147
|
+
onDecision: options.onDecision,
|
|
148
|
+
resourceMetadataUrl
|
|
149
|
+
});
|
|
150
|
+
return result.ok ? ok(result.preflight?.route) : err(result.response);
|
|
151
|
+
},
|
|
152
|
+
/** Refuse an invocation nobody priced, so a new upstream tool inherits nothing. */
|
|
153
|
+
price: async (principal, route) => {
|
|
154
|
+
const denied = await denyUnpricedInvocation({
|
|
155
|
+
principal,
|
|
156
|
+
permissions,
|
|
157
|
+
resources,
|
|
158
|
+
route,
|
|
159
|
+
onDecision: options.onDecision,
|
|
160
|
+
...emitter ? { emitter } : {}
|
|
161
|
+
});
|
|
162
|
+
return denied ? err(denied) : ok(void 0);
|
|
163
|
+
},
|
|
164
|
+
/** Swap the caller's credential for the service one and forward. */
|
|
165
|
+
forward: async (request) => ok(await forwardToUpstream(request, upstream)),
|
|
166
|
+
/** Hide from a listing what the caller could not have called anyway. */
|
|
167
|
+
filter: async (response, route, principal) => {
|
|
168
|
+
const method = route?.method;
|
|
169
|
+
if (!isListingMethod(method)) return ok(response);
|
|
170
|
+
if (isEventStream(response)) return ok(filterEventStreamListing(response, method, principal, permissions, maxRequestBytes, idOf(route)));
|
|
171
|
+
if (isJsonResponse(response)) {
|
|
172
|
+
const filtered = await filterJsonListing(response, method, principal, permissions, maxRequestBytes);
|
|
173
|
+
return filtered ? ok(filtered) : err(unfilterable(`${method} was over ${maxRequestBytes} bytes or not readable as JSON-RPC`, idOf(route)));
|
|
174
|
+
}
|
|
175
|
+
return err(unfilterable(`${method} came back as '${response.headers.get("content-type") ?? "no content type"}'`, idOf(route)));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
return async function mcpProxy(request) {
|
|
179
|
+
const metadata = oauthMetadataResponse(request, metadataOptions);
|
|
180
|
+
if (metadata) return metadata;
|
|
181
|
+
const { pathname } = new URL(request.url);
|
|
182
|
+
if (pathname === healthPath) return Response.json({
|
|
183
|
+
ok: true,
|
|
184
|
+
mode: "proxy",
|
|
185
|
+
resource: resourceServerUrl.href,
|
|
186
|
+
authorization: {
|
|
187
|
+
mode: "policy",
|
|
188
|
+
roles: policy.roles.size,
|
|
189
|
+
permissions: granted.size
|
|
190
|
+
},
|
|
191
|
+
capabilities: permissions.size
|
|
192
|
+
});
|
|
193
|
+
if (pathname !== resourceServerUrl.pathname) return new Response(`No MCP endpoint at ${pathname}. This proxy answers on ${resourceServerUrl.pathname}, which is also the audience its tokens must carry.\n`, {
|
|
194
|
+
status: 404,
|
|
195
|
+
headers: { "Content-Type": "text/plain" }
|
|
196
|
+
});
|
|
197
|
+
const result = await run(steps, async (s) => {
|
|
198
|
+
const auth = await s.verify(request);
|
|
199
|
+
const principal = await s.authorize(auth);
|
|
200
|
+
const route = await s.gate(request, auth, principal);
|
|
201
|
+
await s.price(principal, route);
|
|
202
|
+
const upstreamResponse = await s.forward(request);
|
|
203
|
+
return s.filter(upstreamResponse, route, principal);
|
|
204
|
+
});
|
|
205
|
+
if (result.ok) return result.value;
|
|
206
|
+
if (isUnexpectedError(result.error)) throw result.error.cause;
|
|
207
|
+
return result.error;
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Refuse any invocation the permission map does not price.
|
|
212
|
+
*
|
|
213
|
+
* `runScopedGate` refuses a capability the caller may not reach. This refuses
|
|
214
|
+
* one nobody priced at all, which in a proxy is the more common shape: the
|
|
215
|
+
* upstream grew a tool since the map was recorded, and an unpriced tool must
|
|
216
|
+
* fail closed rather than inherit the service credential.
|
|
217
|
+
*/
|
|
218
|
+
async function denyUnpricedInvocation(options) {
|
|
219
|
+
const { route, principal, emitter } = options;
|
|
220
|
+
if (!route || !isInvocationMethod(route.method)) return void 0;
|
|
221
|
+
const refuse = async (because) => {
|
|
222
|
+
await emitDecision(options.onDecision, principal, "deny", "policy_denied", emitter);
|
|
223
|
+
return policyDenied(AccessDeniedError.notPermitted(principalLabel(principal), because));
|
|
224
|
+
};
|
|
225
|
+
if (!route.name) return refuse(`a ${route.method} that names no capability`);
|
|
226
|
+
const permission = permissionForFlatMap(options.permissions, route, options.resources, (candidate) => principal.can(candidate));
|
|
227
|
+
if (permission === void 0) return refuse(`the capability '${route.name}' is not priced in the permission map`);
|
|
228
|
+
if (!principal.can(permission)) return refuse(`the permission '${permission}'`);
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Match each priced `resource:` label to the URIs it covers.
|
|
232
|
+
*
|
|
233
|
+
* Exact URIs are tried before templates, so a resource registered at its own
|
|
234
|
+
* address is never answered by a template that happens to span it.
|
|
235
|
+
*/
|
|
236
|
+
function buildResourceIndex(permissions, resourceUris) {
|
|
237
|
+
const exact = [];
|
|
238
|
+
const templated = [];
|
|
239
|
+
const unpriced = [];
|
|
240
|
+
for (const [label, permission] of permissions) {
|
|
241
|
+
if (!label.startsWith("resource:")) continue;
|
|
242
|
+
const uri = resourceUris?.[label];
|
|
243
|
+
if (uri === void 0) {
|
|
244
|
+
unpriced.push(label);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (uri.includes("{")) {
|
|
248
|
+
const template = new UriTemplate(uri);
|
|
249
|
+
templated.push({
|
|
250
|
+
label,
|
|
251
|
+
permission,
|
|
252
|
+
matches: (target) => template.match(target) !== null
|
|
253
|
+
});
|
|
254
|
+
} else exact.push({
|
|
255
|
+
label,
|
|
256
|
+
permission,
|
|
257
|
+
matches: (target) => target === uri
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
if (unpriced.length > 0) throw new Error(`A proxy authorizes resources/read by URI, and these priced resources carry none:\n${unpriced.map((label) => ` ${label}`).join("\n")}\n\nPass \`resourceUris\` mapping each label to its uri or uriTemplate. \`recordCapabilities\` writes one for you; see mcp-authz/testing.`);
|
|
261
|
+
for (const label of Object.keys(resourceUris ?? {})) if (!permissions.has(label)) throw new Error(`resourceUris key '${label}' names no priced capability.`);
|
|
262
|
+
return [...exact, ...templated];
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The filtered response, or `undefined` when the body was over the cap or not
|
|
266
|
+
* readable as JSON-RPC.
|
|
267
|
+
*
|
|
268
|
+
* The body is read once, under the same byte cap the SSE path uses: a catalogue
|
|
269
|
+
* has to be held whole to be filtered, so an upstream that sends an unbounded
|
|
270
|
+
* one would otherwise choose how much memory this spends.
|
|
271
|
+
*/
|
|
272
|
+
async function filterJsonListing(response, method, principal, permissions, maxBytes) {
|
|
273
|
+
const raw = await readCappedBody(response.body, maxBytes);
|
|
274
|
+
if (raw === void 0) return void 0;
|
|
275
|
+
let payload;
|
|
276
|
+
try {
|
|
277
|
+
payload = JSON.parse(raw);
|
|
278
|
+
} catch {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const filtered = filterMessage(payload, method, principal, permissions);
|
|
282
|
+
if (filtered === void 0) return void 0;
|
|
283
|
+
return Response.json(filtered, {
|
|
284
|
+
status: response.status,
|
|
285
|
+
headers: headersForRewrittenBody(response)
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* One JSON-RPC message with its listing filtered, or `undefined` when this
|
|
290
|
+
* cannot tell what the message carries.
|
|
291
|
+
*
|
|
292
|
+
* An error reply and a progress notification carry no catalogue and pass
|
|
293
|
+
* through untouched. Anything unrecognisable does not, because the whole point
|
|
294
|
+
* of reading the body is to know whether a capability is hiding in it.
|
|
295
|
+
*/
|
|
296
|
+
function filterMessage(payload, method, principal, permissions) {
|
|
297
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return void 0;
|
|
298
|
+
const message = payload;
|
|
299
|
+
if (!("result" in message)) return "error" in message || "method" in message ? message : void 0;
|
|
300
|
+
const result = message.result;
|
|
301
|
+
if (typeof result !== "object" || result === null) return void 0;
|
|
302
|
+
return {
|
|
303
|
+
...message,
|
|
304
|
+
result: filterListingResult(method, result, principal, permissions)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Filter a listing carried over SSE, event by event as it arrives.
|
|
309
|
+
*
|
|
310
|
+
* Streamed rather than buffered: the body is an upstream's to size, and reading
|
|
311
|
+
* it to the end before answering would both hold a catalogue hostage to a slow
|
|
312
|
+
* server and let that server decide how much memory this process spends.
|
|
313
|
+
*
|
|
314
|
+
* Framing is read properly rather than by prefix. `data:` needs no space after
|
|
315
|
+
* it, and one payload may arrive across several `data:` lines that the client
|
|
316
|
+
* rejoins — a filter that only understands `data: ` passes both straight
|
|
317
|
+
* through, which is the whole catalogue, unfiltered.
|
|
318
|
+
*/
|
|
319
|
+
function filterEventStreamListing(response, method, principal, permissions, maxEventBytes, id) {
|
|
320
|
+
const decoder = new TextDecoder();
|
|
321
|
+
const encoder = new TextEncoder();
|
|
322
|
+
let buffered = "";
|
|
323
|
+
let started = false;
|
|
324
|
+
/**
|
|
325
|
+
* Where the search for a terminator has already looked, and how many bytes
|
|
326
|
+
* the pending event holds.
|
|
327
|
+
*
|
|
328
|
+
* Both are carried rather than recomputed. Rescanning and re-encoding the
|
|
329
|
+
* whole buffer on every chunk is quadratic in the number of chunks, and an
|
|
330
|
+
* upstream chooses the chunk size: one 900 KB event delivered 64 bytes at a
|
|
331
|
+
* time costs seconds of CPU that way, under the cap, on one connection.
|
|
332
|
+
*
|
|
333
|
+
* Bytes rather than `String.length`, which counts UTF-16 units — for anything
|
|
334
|
+
* outside ASCII that reads a byte cap as up to three times larger.
|
|
335
|
+
*/
|
|
336
|
+
let scanned = 0;
|
|
337
|
+
let bufferedBytes = 0;
|
|
338
|
+
const OVERLAP = 3;
|
|
339
|
+
const filterEvent = (block) => {
|
|
340
|
+
const lines = block.split(/\r\n|\n|\r/);
|
|
341
|
+
const data = [];
|
|
342
|
+
const rest = [];
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
if (line.startsWith(":")) continue;
|
|
345
|
+
const separator = line.indexOf(":");
|
|
346
|
+
if ((separator === -1 ? line : line.slice(0, separator)) !== "data") {
|
|
347
|
+
if (line.length > 0) rest.push(line);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const value = separator === -1 ? "" : line.slice(separator + 1);
|
|
351
|
+
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
352
|
+
}
|
|
353
|
+
if (data.length === 0) return block;
|
|
354
|
+
let payload;
|
|
355
|
+
try {
|
|
356
|
+
payload = JSON.parse(data.join("\n"));
|
|
357
|
+
} catch {
|
|
358
|
+
return `event: message\ndata: ${JSON.stringify(unfilterableBody(`${method} carried an unreadable event`, id))}`;
|
|
359
|
+
}
|
|
360
|
+
const body = filterMessage(payload, method, principal, permissions) ?? unfilterableBody(`${method} carried an unrecognisable event`, id);
|
|
361
|
+
return [...rest, `data: ${JSON.stringify(body)}`].join("\n");
|
|
362
|
+
};
|
|
363
|
+
const drain = (controller) => {
|
|
364
|
+
for (;;) {
|
|
365
|
+
const from = Math.max(0, scanned - OVERLAP);
|
|
366
|
+
const found = EVENT_END.exec(buffered.slice(from));
|
|
367
|
+
const at = found ? from + found.index : -1;
|
|
368
|
+
if (!found || buffered.endsWith("\r") && at + found[0].length === buffered.length) {
|
|
369
|
+
scanned = buffered.length;
|
|
370
|
+
return bufferedBytes <= maxEventBytes;
|
|
371
|
+
}
|
|
372
|
+
const block = buffered.slice(0, at);
|
|
373
|
+
if (encoder.encode(block).length > maxEventBytes) return false;
|
|
374
|
+
const consumed = encoder.encode(buffered.slice(0, at + found[0].length)).length;
|
|
375
|
+
buffered = buffered.slice(at + found[0].length);
|
|
376
|
+
bufferedBytes -= consumed;
|
|
377
|
+
scanned = 0;
|
|
378
|
+
controller.enqueue(encoder.encode(filterEvent(block) + found[0]));
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const stream = new TransformStream({
|
|
382
|
+
transform(chunk, controller) {
|
|
383
|
+
let text = decoder.decode(chunk, { stream: true });
|
|
384
|
+
if (!started) {
|
|
385
|
+
started = true;
|
|
386
|
+
if (text.startsWith("")) text = text.slice(1);
|
|
387
|
+
}
|
|
388
|
+
buffered += text;
|
|
389
|
+
bufferedBytes += encoder.encode(text).length;
|
|
390
|
+
if (drain(controller)) return;
|
|
391
|
+
controller.enqueue(encoder.encode(`event: message\ndata: ${JSON.stringify(unfilterableBody(`${method} sent an event over ${maxEventBytes} bytes`, id))}\n\n`));
|
|
392
|
+
buffered = "";
|
|
393
|
+
bufferedBytes = 0;
|
|
394
|
+
controller.terminate();
|
|
395
|
+
},
|
|
396
|
+
flush(controller) {
|
|
397
|
+
buffered += decoder.decode();
|
|
398
|
+
if (buffered.trim().length === 0) return;
|
|
399
|
+
controller.enqueue(encoder.encode(bufferedBytes > maxEventBytes ? `event: message\ndata: ${JSON.stringify(unfilterableBody(`${method} sent an event over ${maxEventBytes} bytes`, id))}\n\n` : filterEvent(buffered)));
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
return new Response(response.body?.pipeThrough(stream) ?? null, {
|
|
403
|
+
status: response.status,
|
|
404
|
+
headers: headersForRewrittenBody(response)
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Two line terminators, which is what ends an event.
|
|
409
|
+
*
|
|
410
|
+
* Each may independently be CRLF, LF or a bare CR, so the nine combinations all
|
|
411
|
+
* count — an upstream is under no obligation to be consistent between the two.
|
|
412
|
+
* A lone CR only counts when no LF follows, or a single CRLF would decompose
|
|
413
|
+
* into two terminators and every line would look like the end of an event.
|
|
414
|
+
*/
|
|
415
|
+
const LINE_END = String.raw`(?:\r\n|\r(?!\n)|\n)`;
|
|
416
|
+
const EVENT_END = new RegExp(LINE_END + LINE_END);
|
|
417
|
+
/**
|
|
418
|
+
* A refusal the client can match to what it asked.
|
|
419
|
+
*
|
|
420
|
+
* An error carrying `id: null` answers no pending request, so a client is
|
|
421
|
+
* entitled to ignore it — and then waits on a listing that will never arrive for
|
|
422
|
+
* as long as the stream stays open. The id comes from the request body this
|
|
423
|
+
* already validated.
|
|
424
|
+
*/
|
|
425
|
+
function unfilterableBody(because, id = null) {
|
|
426
|
+
return {
|
|
427
|
+
jsonrpc: "2.0",
|
|
428
|
+
id,
|
|
429
|
+
error: {
|
|
430
|
+
code: -32010,
|
|
431
|
+
message: `Bad Gateway: this catalogue could not be filtered — ${because}.`
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/** The JSON-RPC id of the request a route came from, when it carried one. */
|
|
436
|
+
function idOf(route) {
|
|
437
|
+
const body = route?.body;
|
|
438
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) return null;
|
|
439
|
+
const id = body.id;
|
|
440
|
+
return typeof id === "string" || typeof id === "number" ? id : null;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* A catalogue withheld.
|
|
444
|
+
*
|
|
445
|
+
* Absence from a listing is not the security boundary — naming a hidden
|
|
446
|
+
* capability is still refused — but a listing served unfiltered hands every
|
|
447
|
+
* caller the map, so an unreadable one fails closed rather than through.
|
|
448
|
+
*/
|
|
449
|
+
function unfilterable(because, id = null) {
|
|
450
|
+
return Response.json(unfilterableBody(because, id), { status: 502 });
|
|
451
|
+
}
|
|
452
|
+
function toPermissionMap(permissions) {
|
|
453
|
+
if (permissions instanceof Map) return new Map(permissions);
|
|
454
|
+
return new Map(Object.entries(permissions));
|
|
455
|
+
}
|
|
456
|
+
function validatePermissions(permissions) {
|
|
457
|
+
for (const [label, permission] of permissions) {
|
|
458
|
+
if (!label || typeof label !== "string") throw new Error("permissions keys must be non-empty strings.");
|
|
459
|
+
if (!permission || typeof permission !== "string") throw new Error(`permissions['${label}'] must be a non-empty string.`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function validateScopes(capabilityScopes, permissions) {
|
|
463
|
+
if (!capabilityScopes) return;
|
|
464
|
+
const named = /* @__PURE__ */ new Set();
|
|
465
|
+
for (const key of Object.keys(capabilityScopes)) {
|
|
466
|
+
const route = routeFromScopeKey(key);
|
|
467
|
+
const canonical = `${route.kind}:${route.name}`;
|
|
468
|
+
if (named.has(canonical)) throw new Error(`Scope map names ${route.kind} '${route.name}' more than once.`);
|
|
469
|
+
named.add(canonical);
|
|
470
|
+
const label = route.kind === "tool" ? route.name : canonical;
|
|
471
|
+
if (!(permissions.has(label) || route.kind === "tool" && permissions.has(`tool:${route.name}`))) throw new Error(`Scope map key '${key}' names no registered capability.`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function routeFromScopeKey(key) {
|
|
475
|
+
for (const kind of [
|
|
476
|
+
"tool",
|
|
477
|
+
"prompt",
|
|
478
|
+
"resource"
|
|
479
|
+
]) {
|
|
480
|
+
const prefix = `${kind}:`;
|
|
481
|
+
if (key.startsWith(prefix)) return {
|
|
482
|
+
kind,
|
|
483
|
+
name: key.slice(prefix.length)
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
kind: "tool",
|
|
488
|
+
name: key
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
//#endregion
|
|
492
|
+
export { createMcpProxy };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { n as UNASSIGNED, r as toPermissionsModule, t as PermissionMapRecord } from "./permissions-module-DxCHuE-N.js";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
3
|
+
//#region src/testing.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a caller can reach, read from the server rather than from your notes.
|
|
6
|
+
*
|
|
7
|
+
* A permission map has to name every capability a server registers, and nothing
|
|
8
|
+
* generates that list today — you write it by hand and hope. This connects a
|
|
9
|
+
* real client to your own server over an in-memory pair and asks it, so the
|
|
10
|
+
* answer is the one a caller would get.
|
|
11
|
+
*
|
|
12
|
+
* Build the server *ungated* here. A gated server answers per principal, so
|
|
13
|
+
* listing one would hand you a map missing exactly the capabilities that most
|
|
14
|
+
* need a price.
|
|
15
|
+
*/
|
|
16
|
+
type CapabilityRecord = {
|
|
17
|
+
/** Every capability, labelled as `gate()` labels them, sorted. */
|
|
18
|
+
names: string[];
|
|
19
|
+
/** A digest per capability, so a snapshot can catch one changing under you. */
|
|
20
|
+
fingerprints: Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* `resource:` labels to the URI or URI template each answers on.
|
|
23
|
+
*
|
|
24
|
+
* A listing names a resource; a read names a URI, and only the server knows
|
|
25
|
+
* which URIs a label covers. `createMcpProxy` authorizes reads by URI, so it
|
|
26
|
+
* needs this alongside the permission map — and refuses to boot without it.
|
|
27
|
+
*/
|
|
28
|
+
resourceUris: Record<string, string>;
|
|
29
|
+
};
|
|
30
|
+
declare function recordCapabilities(factory: () => McpServer | Promise<McpServer>): Promise<CapabilityRecord>;
|
|
31
|
+
/**
|
|
32
|
+
* Record a server you can only reach by URL.
|
|
33
|
+
*
|
|
34
|
+
* The objection that rules out listing a *gated* server does not apply here: an
|
|
35
|
+
* upstream reached with a service credential answers with everything it has, so
|
|
36
|
+
* the map is complete. Pass `fetch` to drive a handler directly instead of a
|
|
37
|
+
* socket.
|
|
38
|
+
*/
|
|
39
|
+
declare function recordUpstream(url: string | URL, options?: {
|
|
40
|
+
bearer?: string;
|
|
41
|
+
fetch?: typeof fetch;
|
|
42
|
+
}): Promise<CapabilityRecord>;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { CapabilityRecord, type PermissionMapRecord, UNASSIGNED, recordCapabilities, recordUpstream, toPermissionsModule };
|