machine-bridge-mcp 0.14.0 → 0.16.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/CHANGELOG.md +39 -0
- package/README.md +18 -8
- package/SECURITY.md +10 -3
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +17 -2
- package/browser-extension/manifest.json +2 -2
- package/browser-extension/page-automation.js +245 -60
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +151 -354
- package/docs/ARCHITECTURE.md +6 -4
- package/docs/AUDIT.md +24 -1
- package/docs/LOCAL_AUTOMATION.md +10 -10
- package/docs/LOGGING.md +7 -1
- package/docs/OPERATIONS.md +13 -5
- package/docs/POLICY_REFERENCE.md +88 -0
- package/docs/TESTING.md +9 -2
- package/package.json +16 -3
- package/scripts/coverage-check.mjs +108 -0
- package/scripts/generate-policy-reference.mjs +95 -0
- package/src/local/agent-context.mjs +1 -103
- package/src/local/app-automation.mjs +7 -9
- package/src/local/browser-bridge.mjs +69 -143
- package/src/local/browser-extension-protocol.mjs +75 -0
- package/src/local/browser-pairing-store.mjs +83 -0
- package/src/local/call-registry.mjs +113 -0
- package/src/local/capability-ranking.mjs +103 -0
- package/src/local/cli-local-admin.mjs +308 -0
- package/src/local/cli-options.mjs +151 -0
- package/src/local/cli-policy.mjs +77 -0
- package/src/local/cli.mjs +16 -507
- package/src/local/errors.mjs +122 -0
- package/src/local/git-service.mjs +88 -0
- package/src/local/lifecycle.mjs +64 -0
- package/src/local/log.mjs +50 -19
- package/src/local/managed-job-plan.mjs +235 -0
- package/src/local/managed-jobs.mjs +16 -220
- package/src/local/observability.mjs +83 -0
- package/src/local/policy.mjs +148 -0
- package/src/local/process-execution.mjs +153 -0
- package/src/local/process-sessions.mjs +10 -20
- package/src/local/process-tracker.mjs +55 -0
- package/src/local/runtime.mjs +154 -672
- package/src/local/service.mjs +8 -3
- package/src/local/stdio.mjs +3 -11
- package/src/local/tool-executor.mjs +102 -0
- package/src/local/tools.mjs +21 -104
- package/src/local/workspace-file-service.mjs +451 -0
- package/src/shared/policy-contract.json +54 -0
- package/src/shared/tool-catalog.json +11 -11
- package/src/worker/index.ts +69 -524
package/src/worker/index.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
2
|
import toolCatalog from "../shared/tool-catalog.json";
|
|
3
3
|
import serverMetadata from "../shared/server-metadata.json";
|
|
4
|
+
import { PendingCallRegistry } from "./pending-calls";
|
|
5
|
+
import { WorkerObservability } from "./observability";
|
|
6
|
+
import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors";
|
|
7
|
+
import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./policy";
|
|
8
|
+
import {
|
|
9
|
+
AUTH_BLOCK_SECONDS, authorizationIdentity, pkceS256, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry,
|
|
10
|
+
randomToken, recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest,
|
|
11
|
+
type OAuthClient, type OAuthStore, type ValidatedAuthorization,
|
|
12
|
+
} from "./oauth-state";
|
|
13
|
+
import {
|
|
14
|
+
HttpError, applyCors, authorizationRedirectLocation, baseUrl, bearerToken, corsPreflight, escapeHtml,
|
|
15
|
+
html, json, methodNotAllowed, normalizeDisplayText, normalizeRedirectUri, oauthRedirect, sanitizeMetadataText,
|
|
16
|
+
parseJsonRequest, parseRequestBody, searchParamsEntries, searchParamsObject, validateOrigin, workerErrorClass,
|
|
17
|
+
} from "./http";
|
|
4
18
|
|
|
5
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
6
|
-
const SERVER_VERSION = "0.
|
|
20
|
+
const SERVER_VERSION = "0.16.0";
|
|
7
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
8
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
9
23
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -23,11 +37,7 @@ const MAX_CODES_PER_CLIENT = 10;
|
|
|
23
37
|
const MAX_OAUTH_CODES = 200;
|
|
24
38
|
const MAX_OAUTH_TOKENS = 500;
|
|
25
39
|
const MAX_AUTH_FAILURE_IDENTITIES = 200;
|
|
26
|
-
const AUTH_FAILURE_WINDOW_SECONDS = 10 * 60;
|
|
27
|
-
const AUTH_BLOCK_SECONDS = 15 * 60;
|
|
28
|
-
const AUTH_FAILURE_LIMIT = 10;
|
|
29
40
|
const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
|
|
30
|
-
const UNSAFE_DISPLAY_CONTROLS = /[\u0000-\u001f\u007f\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g;
|
|
31
41
|
|
|
32
42
|
interface BridgeEnv {
|
|
33
43
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -47,70 +57,14 @@ interface JsonRpcRequest {
|
|
|
47
57
|
params?: unknown;
|
|
48
58
|
}
|
|
49
59
|
|
|
50
|
-
interface OAuthClient {
|
|
51
|
-
client_id: string;
|
|
52
|
-
client_name: string;
|
|
53
|
-
redirect_uris: string[];
|
|
54
|
-
created_at: number;
|
|
55
|
-
last_used_at: number;
|
|
56
|
-
registration_identity?: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface OAuthCode {
|
|
60
|
-
client_id: string;
|
|
61
|
-
redirect_uri: string;
|
|
62
|
-
code_challenge: string;
|
|
63
|
-
scope: string;
|
|
64
|
-
resource: string;
|
|
65
|
-
expires_at: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
interface OAuthToken {
|
|
69
|
-
client_id: string;
|
|
70
|
-
scope: string;
|
|
71
|
-
resource: string;
|
|
72
|
-
version: string;
|
|
73
|
-
expires_at: number;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
interface OAuthFailure {
|
|
77
|
-
count: number;
|
|
78
|
-
window_started: number;
|
|
79
|
-
blocked_until: number;
|
|
80
|
-
last_attempt: number;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
interface OAuthStore {
|
|
84
|
-
clients: Record<string, OAuthClient>;
|
|
85
|
-
codes: Record<string, OAuthCode>;
|
|
86
|
-
tokens: Record<string, OAuthToken>;
|
|
87
|
-
auth_failures: Record<string, OAuthFailure>;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
interface ValidatedAuthorization {
|
|
91
|
-
client: OAuthClient;
|
|
92
|
-
clientId: string;
|
|
93
|
-
redirectUri: string;
|
|
94
|
-
codeChallenge: string;
|
|
95
|
-
requestedResource: string;
|
|
96
|
-
scope: string;
|
|
97
|
-
state: string;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
60
|
interface DaemonAttachment {
|
|
101
61
|
role: "candidate" | "expired" | "daemon";
|
|
102
62
|
connectedAt: string;
|
|
103
|
-
policy?:
|
|
63
|
+
policy?: DaemonPolicy;
|
|
104
64
|
tools?: string[];
|
|
105
65
|
}
|
|
106
66
|
|
|
107
|
-
|
|
108
|
-
resolve: (value: unknown) => void;
|
|
109
|
-
reject: (error: Error) => void;
|
|
110
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
111
|
-
socket: WebSocket;
|
|
112
|
-
clientRequestKey?: string;
|
|
113
|
-
}
|
|
67
|
+
|
|
114
68
|
|
|
115
69
|
interface AuthorizedToken {
|
|
116
70
|
tokenKey: string;
|
|
@@ -132,7 +86,8 @@ function publicTool(tool: ToolDefinition): ToolDefinition {
|
|
|
132
86
|
}
|
|
133
87
|
|
|
134
88
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
135
|
-
private readonly pending = new
|
|
89
|
+
private readonly pending = new PendingCallRegistry(MAX_PENDING_CALLS);
|
|
90
|
+
private readonly observability = new WorkerObservability();
|
|
136
91
|
private oauthQueue: Promise<void> = Promise.resolve();
|
|
137
92
|
|
|
138
93
|
constructor(ctx: DurableObjectState, env: BridgeEnv) {
|
|
@@ -142,12 +97,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
142
97
|
async fetch(request: Request): Promise<Response> {
|
|
143
98
|
const base = baseUrl(request);
|
|
144
99
|
const configuredOrigins = this.env.MBM_ALLOWED_ORIGINS ?? "";
|
|
145
|
-
|
|
146
|
-
if (request
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return
|
|
100
|
+
let response: Response;
|
|
101
|
+
if (!validateOrigin(request, base, configuredOrigins)) response = json({ error: "origin_not_allowed" }, 403);
|
|
102
|
+
else if (request.method === "OPTIONS" && request.headers.has("Origin")) response = corsPreflight(request, base, configuredOrigins);
|
|
103
|
+
else response = applyCors(await this.handleRequest(request, base), request, base, configuredOrigins);
|
|
104
|
+
this.observability.requestFinished(response.status);
|
|
105
|
+
return response;
|
|
151
106
|
}
|
|
152
107
|
|
|
153
108
|
private async handleRequest(request: Request, base: string): Promise<Response> {
|
|
@@ -199,7 +154,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
199
154
|
return json({ error: "not_found" }, 404);
|
|
200
155
|
} catch (error) {
|
|
201
156
|
if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
|
|
202
|
-
|
|
157
|
+
this.observability.event("error", "http.request.failed", { path: url.pathname, error_class: workerErrorClass(error) });
|
|
203
158
|
return json({ error: "internal_server_error" }, 500);
|
|
204
159
|
}
|
|
205
160
|
}
|
|
@@ -261,6 +216,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
261
216
|
policy: daemonPolicy,
|
|
262
217
|
tools: sanitizeDaemonTools(body.tools, daemonPolicy),
|
|
263
218
|
} satisfies DaemonAttachment);
|
|
219
|
+
this.observability.socketAuthenticated();
|
|
264
220
|
await this.scheduleCandidateAlarm();
|
|
265
221
|
try {
|
|
266
222
|
ws.send(JSON.stringify({ type: "hello_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
@@ -293,12 +249,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
293
249
|
return;
|
|
294
250
|
}
|
|
295
251
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
clearTimeout(pending.timeout);
|
|
299
|
-
this.pending.delete(body.id);
|
|
300
|
-
if (body.ok === false) pending.reject(new Error(errorMessage(body.error)));
|
|
301
|
-
else pending.resolve(body.result);
|
|
252
|
+
if (body.ok === false) this.pending.reject(body.id, daemonToolError(body.error), ws);
|
|
253
|
+
else this.pending.resolve(body.id, ws, body.result);
|
|
302
254
|
}
|
|
303
255
|
|
|
304
256
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
@@ -306,18 +258,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
306
258
|
}
|
|
307
259
|
|
|
308
260
|
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
|
309
|
-
|
|
261
|
+
this.observability.event("warn", "daemon.websocket.error", { error_class: workerErrorClass(error) });
|
|
310
262
|
await this.cleanupDaemonSocket(ws, "daemon transport error");
|
|
311
263
|
}
|
|
312
264
|
|
|
313
265
|
private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
|
|
266
|
+
this.observability.socketDisconnected();
|
|
314
267
|
await this.scheduleCandidateAlarm();
|
|
315
|
-
|
|
316
|
-
if (pending.socket !== ws) continue;
|
|
317
|
-
clearTimeout(pending.timeout);
|
|
318
|
-
pending.reject(new Error(message));
|
|
319
|
-
this.pending.delete(id);
|
|
320
|
-
}
|
|
268
|
+
this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
321
269
|
}
|
|
322
270
|
|
|
323
271
|
private async handleMcp(request: Request, base: string): Promise<Response> {
|
|
@@ -390,7 +338,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
390
338
|
const result = await this.callTool(name, args, base, clientRequestKey(authorized, request.id));
|
|
391
339
|
return rpcResult(request.id, textToolResult(result));
|
|
392
340
|
} catch (error) {
|
|
393
|
-
return rpcResult(request.id, textToolResult({ error:
|
|
341
|
+
return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
|
|
394
342
|
}
|
|
395
343
|
}
|
|
396
344
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
@@ -406,6 +354,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
406
354
|
mcp_url: `${base}/mcp`,
|
|
407
355
|
oauth: this.authorizationServerMetadata(base),
|
|
408
356
|
daemon,
|
|
357
|
+
worker: {
|
|
358
|
+
pending_calls: this.pending.snapshot(),
|
|
359
|
+
daemon_candidates: this.candidateSockets().length,
|
|
360
|
+
observability: this.observability.snapshot(),
|
|
361
|
+
},
|
|
409
362
|
tools,
|
|
410
363
|
tool_delivery: {
|
|
411
364
|
full_profile_scope: "local-daemon-and-relay-advertisement",
|
|
@@ -425,39 +378,41 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
425
378
|
|
|
426
379
|
private async callDaemonTool(name: string, args: Record<string, unknown>, requestKey?: string): Promise<unknown> {
|
|
427
380
|
const socket = this.daemonSockets()[0];
|
|
428
|
-
if (!socket) throw new
|
|
429
|
-
if (this.pending.size >= MAX_PENDING_CALLS) throw new Error("too many concurrent daemon tool calls");
|
|
430
|
-
if (requestKey && [...this.pending.values()].some((pending) => pending.clientRequestKey === requestKey)) {
|
|
431
|
-
throw new Error("duplicate in-flight JSON-RPC request id for this access token");
|
|
432
|
-
}
|
|
381
|
+
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
433
382
|
const id = randomToken("call");
|
|
434
383
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
} catch (error) {
|
|
445
|
-
clearTimeout(timeout);
|
|
446
|
-
this.pending.delete(id);
|
|
447
|
-
reject(new Error(`failed to send daemon tool call: ${errorMessage(error)}`));
|
|
448
|
-
}
|
|
384
|
+
const result = this.pending.register({
|
|
385
|
+
id,
|
|
386
|
+
socket,
|
|
387
|
+
clientRequestKey: requestKey,
|
|
388
|
+
timeoutMs,
|
|
389
|
+
onTimeout: (record) => {
|
|
390
|
+
try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
|
|
391
|
+
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
392
|
+
},
|
|
449
393
|
});
|
|
394
|
+
this.observability.callStarted(name);
|
|
395
|
+
try {
|
|
396
|
+
socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
|
|
397
|
+
} catch {
|
|
398
|
+
this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
|
|
399
|
+
}
|
|
400
|
+
try {
|
|
401
|
+
const value = await result;
|
|
402
|
+
this.observability.callFinished(name);
|
|
403
|
+
return value;
|
|
404
|
+
} catch (error) {
|
|
405
|
+
this.observability.callFinished(name, publicWorkerToolError(error).code);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
450
408
|
}
|
|
451
409
|
|
|
452
410
|
private cancelClientRequest(requestKey?: string): void {
|
|
453
411
|
if (!requestKey) return;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
try { pending.socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
|
|
459
|
-
pending.reject(new Error("tool call cancelled by client"));
|
|
460
|
-
}
|
|
412
|
+
this.pending.cancelRequest(requestKey, (record) => {
|
|
413
|
+
try { record.socket.send(JSON.stringify({ type: "cancel_call", id: record.id })); } catch {}
|
|
414
|
+
return new WorkerToolError("cancelled", "tool call cancelled by client");
|
|
415
|
+
});
|
|
461
416
|
}
|
|
462
417
|
|
|
463
418
|
private async acceptDaemonWebSocket(request: Request): Promise<Response> {
|
|
@@ -473,6 +428,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
473
428
|
const pair = new WebSocketPair();
|
|
474
429
|
const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
|
|
475
430
|
this.ctx.acceptWebSocket(server);
|
|
431
|
+
this.observability.socketCandidate();
|
|
476
432
|
server.serializeAttachment({
|
|
477
433
|
role: "candidate",
|
|
478
434
|
connectedAt: new Date().toISOString(),
|
|
@@ -735,7 +691,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
735
691
|
const body = searchParamsObject(new URL(request.url).searchParams);
|
|
736
692
|
return this.withOAuthLock(async () => {
|
|
737
693
|
const store = await this.oauthStore();
|
|
738
|
-
const validation = validateAuthorizationRequest(body, base, store);
|
|
694
|
+
const validation = validateAuthorizationRequest(body, base, SERVER_NAME, store);
|
|
739
695
|
if ("error" in validation) {
|
|
740
696
|
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
741
697
|
}
|
|
@@ -793,7 +749,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
793
749
|
const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
|
|
794
750
|
return this.withOAuthLock(async () => {
|
|
795
751
|
const store = await this.oauthStore();
|
|
796
|
-
const validation = validateAuthorizationRequest(body, base, store);
|
|
752
|
+
const validation = validateAuthorizationRequest(body, base, SERVER_NAME, store);
|
|
797
753
|
if ("error" in validation) {
|
|
798
754
|
return this.authorizePage(request, base, validation.error, body, validation.status, undefined, false);
|
|
799
755
|
}
|
|
@@ -983,37 +939,6 @@ function textToolResult(value: unknown, isError = false): Record<string, unknown
|
|
|
983
939
|
return result;
|
|
984
940
|
}
|
|
985
941
|
|
|
986
|
-
function methodNotAllowed(allow: string): Response {
|
|
987
|
-
return new Response(JSON.stringify({ error: "method_not_allowed" }), {
|
|
988
|
-
status: 405,
|
|
989
|
-
headers: {
|
|
990
|
-
allow,
|
|
991
|
-
"cache-control": "no-store",
|
|
992
|
-
"content-type": "application/json; charset=utf-8",
|
|
993
|
-
"x-content-type-options": "nosniff",
|
|
994
|
-
},
|
|
995
|
-
});
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
function oauthRedirect(location: URL): Response {
|
|
999
|
-
return new Response(null, {
|
|
1000
|
-
status: 303,
|
|
1001
|
-
headers: {
|
|
1002
|
-
location: location.href,
|
|
1003
|
-
"cache-control": "no-store",
|
|
1004
|
-
"referrer-policy": "no-referrer",
|
|
1005
|
-
"x-content-type-options": "nosniff",
|
|
1006
|
-
},
|
|
1007
|
-
});
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
function authorizationRedirectLocation(redirectUri: string, code: string, state: string): URL {
|
|
1011
|
-
const location = new URL(redirectUri);
|
|
1012
|
-
location.searchParams.append("code", code);
|
|
1013
|
-
if (state) location.searchParams.append("state", state);
|
|
1014
|
-
return location;
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
942
|
function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
1018
943
|
const timestamp = Date.parse(connectedAt);
|
|
1019
944
|
if (!Number.isFinite(timestamp)) return false;
|
|
@@ -1021,152 +946,6 @@ function isFreshDaemonCandidate(connectedAt: string): boolean {
|
|
|
1021
946
|
return age >= 0 && age <= DAEMON_HELLO_TIMEOUT_MS;
|
|
1022
947
|
}
|
|
1023
948
|
|
|
1024
|
-
function sanitizeMetadataText(value: unknown, maxLength: number): string | undefined {
|
|
1025
|
-
if (typeof value !== "string") return undefined;
|
|
1026
|
-
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
1027
|
-
return normalized ? normalized.slice(0, maxLength) : undefined;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
function sanitizeDaemonTools(value: unknown, policy: Record<string, unknown>): string[] {
|
|
1032
|
-
if (!Array.isArray(value)) return [];
|
|
1033
|
-
const definitions = new Map(allCatalogTools.map((tool) => [tool.name, tool]));
|
|
1034
|
-
return [...new Set(value.filter((item): item is string => {
|
|
1035
|
-
if (typeof item !== "string" || item === "server_info") return false;
|
|
1036
|
-
const definition = definitions.get(item);
|
|
1037
|
-
return Boolean(definition && daemonPolicyAllows(definition.availability, policy));
|
|
1038
|
-
}))];
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
function daemonPolicyAllows(availability: unknown, policy: Record<string, unknown>): boolean {
|
|
1042
|
-
if (availability === "always") return true;
|
|
1043
|
-
if (availability === "write") return policy.allowWrite === true;
|
|
1044
|
-
if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
|
|
1045
|
-
if (availability === "shell-exec") return policy.execMode === "shell";
|
|
1046
|
-
if (availability === "full") return policy.profile === "full"
|
|
1047
|
-
&& policy.allowWrite === true
|
|
1048
|
-
&& policy.execMode === "shell"
|
|
1049
|
-
&& policy.unrestrictedPaths === true
|
|
1050
|
-
&& policy.minimalEnv === false
|
|
1051
|
-
&& policy.exposeAbsolutePaths === true;
|
|
1052
|
-
return false;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
function sanitizeDaemonPolicy(value: unknown): Record<string, unknown> {
|
|
1056
|
-
const policy = asObject(value);
|
|
1057
|
-
const execMode = policy.execMode === "shell" || policy.execMode === "direct" ? policy.execMode : "off";
|
|
1058
|
-
const origin = sanitizeMetadataText(policy.origin, 32);
|
|
1059
|
-
const revision = Number.isInteger(policy.revision) && Number(policy.revision) > 0
|
|
1060
|
-
? Math.min(Number(policy.revision), 1_000_000)
|
|
1061
|
-
: 1;
|
|
1062
|
-
return {
|
|
1063
|
-
profile: sanitizeMetadataText(policy.profile, 32) ?? "custom",
|
|
1064
|
-
origin: ["default", "explicit", "custom", "migrated", "legacy-preserved"].includes(origin ?? "") ? origin : "custom",
|
|
1065
|
-
revision,
|
|
1066
|
-
allowWrite: policy.allowWrite === true,
|
|
1067
|
-
allowExec: execMode !== "off",
|
|
1068
|
-
execMode,
|
|
1069
|
-
unrestrictedPaths: policy.unrestrictedPaths === true,
|
|
1070
|
-
minimalEnv: policy.minimalEnv !== false,
|
|
1071
|
-
exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
|
|
1072
|
-
};
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
function json(value: unknown, status = 200): Response {
|
|
1076
|
-
return new Response(JSON.stringify(value), {
|
|
1077
|
-
status,
|
|
1078
|
-
headers: {
|
|
1079
|
-
"content-type": "application/json; charset=utf-8",
|
|
1080
|
-
"cache-control": "no-store",
|
|
1081
|
-
"x-content-type-options": "nosniff",
|
|
1082
|
-
},
|
|
1083
|
-
});
|
|
1084
|
-
}
|
|
1085
|
-
|
|
1086
|
-
function html(value: string, status = 200): Response {
|
|
1087
|
-
return new Response(value, {
|
|
1088
|
-
status,
|
|
1089
|
-
headers: {
|
|
1090
|
-
"content-type": "text/html; charset=utf-8",
|
|
1091
|
-
"cache-control": "no-store",
|
|
1092
|
-
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
1093
|
-
"referrer-policy": "no-referrer",
|
|
1094
|
-
"x-content-type-options": "nosniff",
|
|
1095
|
-
"x-frame-options": "DENY",
|
|
1096
|
-
},
|
|
1097
|
-
});
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function baseUrl(request: Request): string {
|
|
1101
|
-
return new URL(request.url).origin;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
function bearerToken(request: Request): string {
|
|
1105
|
-
const match = (request.headers.get("Authorization") ?? "").match(/^Bearer\s+(.+)$/i);
|
|
1106
|
-
return match?.[1]?.trim() ?? "";
|
|
1107
|
-
}
|
|
1108
|
-
|
|
1109
|
-
async function parseJsonRequest(request: Request, limit: number): Promise<unknown> {
|
|
1110
|
-
const text = await readBoundedText(request, limit);
|
|
1111
|
-
try {
|
|
1112
|
-
return JSON.parse(text);
|
|
1113
|
-
} catch {
|
|
1114
|
-
throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
async function parseRequestBody(request: Request, limit: number): Promise<Record<string, unknown>> {
|
|
1119
|
-
const contentType = (request.headers.get("content-type") ?? "").toLowerCase();
|
|
1120
|
-
const text = await readBoundedText(request, limit);
|
|
1121
|
-
if (contentType.includes("application/json") || text.trim().startsWith("{")) {
|
|
1122
|
-
let parsed: unknown;
|
|
1123
|
-
try {
|
|
1124
|
-
parsed = text.trim() ? JSON.parse(text) : {};
|
|
1125
|
-
} catch {
|
|
1126
|
-
throw new HttpError(400, "invalid_json", "Request body is not valid JSON");
|
|
1127
|
-
}
|
|
1128
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new HttpError(400, "bad_request", "JSON body must be an object");
|
|
1129
|
-
return parsed as Record<string, unknown>;
|
|
1130
|
-
}
|
|
1131
|
-
return searchParamsObject(new URLSearchParams(text));
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
async function readBoundedText(request: Request, limit: number): Promise<string> {
|
|
1136
|
-
const length = Number(request.headers.get("content-length") ?? "0");
|
|
1137
|
-
if (Number.isFinite(length) && length > limit) throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
|
|
1138
|
-
if (!request.body) return "";
|
|
1139
|
-
const reader = request.body.getReader();
|
|
1140
|
-
const chunks: Uint8Array[] = [];
|
|
1141
|
-
let total = 0;
|
|
1142
|
-
try {
|
|
1143
|
-
for (;;) {
|
|
1144
|
-
const { done, value } = await reader.read();
|
|
1145
|
-
if (done) break;
|
|
1146
|
-
if (!value) continue;
|
|
1147
|
-
total += value.byteLength;
|
|
1148
|
-
if (total > limit) {
|
|
1149
|
-
await reader.cancel();
|
|
1150
|
-
throw new HttpError(413, "request_body_too_large", `request body exceeds ${limit} bytes`);
|
|
1151
|
-
}
|
|
1152
|
-
chunks.push(value);
|
|
1153
|
-
}
|
|
1154
|
-
} finally {
|
|
1155
|
-
reader.releaseLock();
|
|
1156
|
-
}
|
|
1157
|
-
const combined = new Uint8Array(total);
|
|
1158
|
-
let offset = 0;
|
|
1159
|
-
for (const chunk of chunks) {
|
|
1160
|
-
combined.set(chunk, offset);
|
|
1161
|
-
offset += chunk.byteLength;
|
|
1162
|
-
}
|
|
1163
|
-
try {
|
|
1164
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(combined);
|
|
1165
|
-
} catch {
|
|
1166
|
-
throw new HttpError(400, "invalid_encoding", "Request body must be valid UTF-8");
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
|
|
1170
949
|
function asObject(value: unknown): Record<string, unknown> {
|
|
1171
950
|
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
1172
951
|
return value as Record<string, unknown>;
|
|
@@ -1225,237 +1004,3 @@ function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): stri
|
|
|
1225
1004
|
if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
|
|
1226
1005
|
return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
|
|
1227
1006
|
}
|
|
1228
|
-
|
|
1229
|
-
function validateAuthorizationRequest(
|
|
1230
|
-
body: Record<string, unknown>,
|
|
1231
|
-
base: string,
|
|
1232
|
-
store: OAuthStore,
|
|
1233
|
-
): { value: ValidatedAuthorization } | { error: string; status: number } {
|
|
1234
|
-
const responseType = String(body.response_type ?? "");
|
|
1235
|
-
const clientId = String(body.client_id ?? "");
|
|
1236
|
-
const redirectUri = String(body.redirect_uri ?? "");
|
|
1237
|
-
const codeChallenge = String(body.code_challenge ?? "");
|
|
1238
|
-
const codeChallengeMethod = String(body.code_challenge_method ?? "");
|
|
1239
|
-
const requestedResource = String(body.resource ?? `${base}/mcp`);
|
|
1240
|
-
const scope = String(body.scope ?? SERVER_NAME).trim();
|
|
1241
|
-
const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
|
|
1242
|
-
|
|
1243
|
-
if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
|
|
1244
|
-
if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
|
|
1245
|
-
if (scope !== SERVER_NAME) return { error: "unsupported scope.", status: 400 };
|
|
1246
|
-
if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
|
|
1247
|
-
if (state.length > 1024) return { error: "state is too long.", status: 400 };
|
|
1248
|
-
if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {
|
|
1249
|
-
return { error: "A valid PKCE S256 challenge is required.", status: 400 };
|
|
1250
|
-
}
|
|
1251
|
-
const client = store.clients[clientId];
|
|
1252
|
-
if (!client) return { error: "Unknown OAuth client.", status: 400 };
|
|
1253
|
-
if (!client.redirect_uris.includes(redirectUri)) return { error: "redirect_uri is not registered.", status: 400 };
|
|
1254
|
-
return { value: { client, clientId, redirectUri, codeChallenge, requestedResource, scope, state } };
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
function pruneClientRecordByExpiry<T extends { client_id: string; expires_at: number }>(record: Record<string, T>, clientId: string, keep: number): void {
|
|
1258
|
-
const allowed = new Set(Object.entries(record)
|
|
1259
|
-
.filter(([, value]) => value.client_id === clientId)
|
|
1260
|
-
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
1261
|
-
.slice(0, keep)
|
|
1262
|
-
.map(([key]) => key));
|
|
1263
|
-
for (const [key, value] of Object.entries(record)) {
|
|
1264
|
-
if (value.client_id === clientId && !allowed.has(key)) delete record[key];
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
|
|
1268
|
-
function pruneRecordByExpiry<T extends { expires_at: number }>(record: Record<string, T>, keep: number): void {
|
|
1269
|
-
const allowed = new Set(Object.entries(record)
|
|
1270
|
-
.sort((left, right) => right[1].expires_at - left[1].expires_at)
|
|
1271
|
-
.slice(0, keep)
|
|
1272
|
-
.map(([key]) => key));
|
|
1273
|
-
for (const key of Object.keys(record)) if (!allowed.has(key)) delete record[key];
|
|
1274
|
-
}
|
|
1275
|
-
|
|
1276
|
-
async function authorizationIdentity(request: Request, keyMaterial: string): Promise<string> {
|
|
1277
|
-
const source = (request.headers.get("CF-Connecting-IP") || "unknown").slice(0, 128);
|
|
1278
|
-
const encoder = new TextEncoder();
|
|
1279
|
-
const key = await crypto.subtle.importKey(
|
|
1280
|
-
"raw",
|
|
1281
|
-
encoder.encode(keyMaterial),
|
|
1282
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
1283
|
-
false,
|
|
1284
|
-
["sign"],
|
|
1285
|
-
);
|
|
1286
|
-
const digest = await crypto.subtle.sign("HMAC", key, encoder.encode(source));
|
|
1287
|
-
return `hmac-sha256:${[...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
function recordAuthorizationFailure(store: OAuthStore, identity: string, now: number): void {
|
|
1291
|
-
const current = store.auth_failures[identity];
|
|
1292
|
-
const activeWindow = current && current.window_started + AUTH_FAILURE_WINDOW_SECONDS > now;
|
|
1293
|
-
const count = activeWindow ? current.count + 1 : 1;
|
|
1294
|
-
store.auth_failures[identity] = {
|
|
1295
|
-
count,
|
|
1296
|
-
window_started: activeWindow ? current.window_started : now,
|
|
1297
|
-
blocked_until: count >= AUTH_FAILURE_LIMIT ? now + AUTH_BLOCK_SECONDS : 0,
|
|
1298
|
-
last_attempt: now,
|
|
1299
|
-
};
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
function pruneAuthFailures(store: OAuthStore, keep: number): void {
|
|
1303
|
-
const allowed = new Set(Object.entries(store.auth_failures)
|
|
1304
|
-
.sort((left, right) => right[1].last_attempt - left[1].last_attempt)
|
|
1305
|
-
.slice(0, keep)
|
|
1306
|
-
.map(([key]) => key));
|
|
1307
|
-
for (const key of Object.keys(store.auth_failures)) if (!allowed.has(key)) delete store.auth_failures[key];
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
function randomToken(prefix: string): string {
|
|
1311
|
-
const bytes = new Uint8Array(32);
|
|
1312
|
-
crypto.getRandomValues(bytes);
|
|
1313
|
-
return `${prefix}_${base64Url(bytes)}`;
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function base64Url(bytes: Uint8Array): string {
|
|
1317
|
-
return btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
|
1318
|
-
}
|
|
1319
|
-
|
|
1320
|
-
async function sha256Hex(value: string): Promise<string> {
|
|
1321
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
1322
|
-
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
1323
|
-
}
|
|
1324
|
-
|
|
1325
|
-
async function safeEqual(left: string, right: string): Promise<boolean> {
|
|
1326
|
-
const encoder = new TextEncoder();
|
|
1327
|
-
const [leftHash, rightHash] = await Promise.all([
|
|
1328
|
-
crypto.subtle.digest("SHA-256", encoder.encode(left)),
|
|
1329
|
-
crypto.subtle.digest("SHA-256", encoder.encode(right)),
|
|
1330
|
-
]);
|
|
1331
|
-
const leftBytes = new Uint8Array(leftHash);
|
|
1332
|
-
const rightBytes = new Uint8Array(rightHash);
|
|
1333
|
-
let diff = leftBytes.length ^ rightBytes.length;
|
|
1334
|
-
for (let index = 0; index < Math.max(leftBytes.length, rightBytes.length); index += 1) diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0);
|
|
1335
|
-
return diff === 0;
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
async function pkceS256(verifier: string): Promise<string> {
|
|
1339
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
1340
|
-
return base64Url(new Uint8Array(digest));
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
function normalizeRedirectUri(value: string): string | null {
|
|
1344
|
-
try {
|
|
1345
|
-
const url = new URL(value);
|
|
1346
|
-
if (url.username || url.password || url.hash) return null;
|
|
1347
|
-
if (url.protocol === "https:" && url.hostname) return url.toString();
|
|
1348
|
-
if (url.protocol === "http:" && isLoopbackHost(url.hostname)) return url.toString();
|
|
1349
|
-
return null;
|
|
1350
|
-
} catch {
|
|
1351
|
-
return null;
|
|
1352
|
-
}
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
function corsPreflight(request: Request, base: string, configured: string): Response {
|
|
1356
|
-
const origin = request.headers.get("Origin") ?? "";
|
|
1357
|
-
if (!isConfiguredOrSameOrigin(origin, base, configured)) return json({ error: "origin_not_allowed" }, 403);
|
|
1358
|
-
const requestedMethod = (request.headers.get("Access-Control-Request-Method") ?? "").toUpperCase();
|
|
1359
|
-
if (requestedMethod && !["GET", "POST"].includes(requestedMethod)) return methodNotAllowed("GET, POST, OPTIONS");
|
|
1360
|
-
return new Response(null, {
|
|
1361
|
-
status: 204,
|
|
1362
|
-
headers: {
|
|
1363
|
-
"access-control-allow-origin": origin,
|
|
1364
|
-
"access-control-allow-methods": "GET, POST, OPTIONS",
|
|
1365
|
-
"access-control-allow-headers": "authorization, content-type, mcp-protocol-version, mcp-session-id",
|
|
1366
|
-
"access-control-max-age": "600",
|
|
1367
|
-
"cache-control": "no-store",
|
|
1368
|
-
"vary": "Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
|
|
1369
|
-
},
|
|
1370
|
-
});
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
function applyCors(response: Response, request: Request, base: string, configured: string): Response {
|
|
1374
|
-
if (response.status === 101) return response;
|
|
1375
|
-
const origin = request.headers.get("Origin") ?? "";
|
|
1376
|
-
if (!origin || !isConfiguredOrSameOrigin(origin, base, configured)) return response;
|
|
1377
|
-
const headers = new Headers(response.headers);
|
|
1378
|
-
headers.set("access-control-allow-origin", origin);
|
|
1379
|
-
headers.set("access-control-expose-headers", "www-authenticate, mcp-session-id");
|
|
1380
|
-
appendVary(headers, "Origin");
|
|
1381
|
-
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
function appendVary(headers: Headers, value: string): void {
|
|
1385
|
-
const existing = headers.get("vary");
|
|
1386
|
-
const values = new Set((existing ?? "").split(",").map((item) => item.trim()).filter(Boolean));
|
|
1387
|
-
values.add(value);
|
|
1388
|
-
headers.set("vary", [...values].join(", "));
|
|
1389
|
-
}
|
|
1390
|
-
|
|
1391
|
-
function isConfiguredOrSameOrigin(origin: string, base: string, configured: string): boolean {
|
|
1392
|
-
if (isDefaultAllowedOrigin(origin, base)) return true;
|
|
1393
|
-
const allowed = configured.split(",").map((item) => item.trim()).filter((item) => item && item !== "null");
|
|
1394
|
-
return allowed.includes(origin);
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
function validateOrigin(request: Request, base: string, configured = ""): boolean {
|
|
1398
|
-
const origin = request.headers.get("Origin");
|
|
1399
|
-
return !origin || isConfiguredOrSameOrigin(origin, base, configured);
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
function isDefaultAllowedOrigin(origin: string, base: string): boolean {
|
|
1403
|
-
try {
|
|
1404
|
-
return new URL(origin).origin === new URL(base).origin;
|
|
1405
|
-
} catch {
|
|
1406
|
-
return false;
|
|
1407
|
-
}
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
function isLoopbackHost(hostname: string): boolean {
|
|
1411
|
-
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
function searchParamsEntries(params: URLSearchParams): Array<[string, string]> {
|
|
1415
|
-
const entries: Array<[string, string]> = [];
|
|
1416
|
-
params.forEach((value, key) => entries.push([key, value]));
|
|
1417
|
-
return entries;
|
|
1418
|
-
}
|
|
1419
|
-
|
|
1420
|
-
function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
1421
|
-
const out: Record<string, unknown> = {};
|
|
1422
|
-
params.forEach((value, key) => {
|
|
1423
|
-
if (out[key] === undefined) out[key] = value;
|
|
1424
|
-
else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
|
|
1425
|
-
else out[key] = [out[key] as string, value];
|
|
1426
|
-
});
|
|
1427
|
-
return out;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
function normalizeDisplayText(value: string, maxLength: number, fallback = "MCP Client"): string {
|
|
1431
|
-
const normalized = value.replace(UNSAFE_DISPLAY_CONTROLS, " ").replace(/\s+/g, " ").trim();
|
|
1432
|
-
return (normalized || fallback).slice(0, maxLength);
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
function escapeHtml(value: string): string {
|
|
1436
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
function workerErrorClass(error: unknown): string {
|
|
1440
|
-
if (error instanceof HttpError) return error.code;
|
|
1441
|
-
if (error instanceof TypeError) return "type_error";
|
|
1442
|
-
if (error instanceof RangeError) return "range_error";
|
|
1443
|
-
if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
|
|
1444
|
-
return "unknown_error";
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
function errorMessage(error: unknown): string {
|
|
1448
|
-
if (error instanceof Error) return error.message;
|
|
1449
|
-
if (typeof error === "string") return error;
|
|
1450
|
-
try {
|
|
1451
|
-
return JSON.stringify(error);
|
|
1452
|
-
} catch {
|
|
1453
|
-
return String(error);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
class HttpError extends Error {
|
|
1458
|
-
constructor(readonly status: number, readonly code: string, message: string) {
|
|
1459
|
-
super(message);
|
|
1460
|
-
}
|
|
1461
|
-
}
|