machine-bridge-mcp 0.3.3 → 0.4.2
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 +61 -0
- package/README.md +158 -171
- package/SECURITY.md +92 -40
- package/docs/ARCHITECTURE.md +126 -59
- package/docs/CLIENTS.md +125 -0
- package/docs/OPERATIONS.md +83 -0
- package/docs/RELEASING.md +62 -0
- package/docs/TESTING.md +51 -0
- package/package.json +18 -8
- package/src/local/cli.mjs +152 -51
- package/src/local/daemon.mjs +620 -306
- package/src/local/patch.mjs +140 -0
- package/src/local/process-sessions.mjs +352 -0
- package/src/local/service.mjs +5 -16
- package/src/local/shell.mjs +22 -5
- package/src/local/state.mjs +1 -1
- package/src/local/stdio.mjs +194 -0
- package/src/local/tools.mjs +96 -0
- package/src/shared/tool-catalog.json +638 -0
- package/src/worker/index.ts +151 -162
- package/tsconfig.json +4 -2
- package/src/local/self-test.mjs +0 -227
package/src/worker/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { DurableObject } from "cloudflare:workers";
|
|
2
|
+
import toolCatalog from "../shared/tool-catalog.json";
|
|
2
3
|
|
|
3
4
|
const SERVER_NAME = "machine-bridge-mcp";
|
|
4
|
-
const SERVER_VERSION = "0.
|
|
5
|
-
const MCP_PROTOCOL_VERSION = "2025-
|
|
5
|
+
const SERVER_VERSION = "0.4.2";
|
|
6
|
+
const MCP_PROTOCOL_VERSION = "2025-11-25";
|
|
7
|
+
const MCP_SUPPORTED_PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26"] as const;
|
|
6
8
|
const JSONRPC_VERSION = "2.0";
|
|
7
9
|
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
8
10
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
@@ -105,133 +107,33 @@ interface PendingCall {
|
|
|
105
107
|
reject: (error: Error) => void;
|
|
106
108
|
timeout: ReturnType<typeof setTimeout>;
|
|
107
109
|
socket: WebSocket;
|
|
110
|
+
clientRequestKey?: string;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
} as const;
|
|
113
|
+
interface AuthorizedToken {
|
|
114
|
+
tokenKey: string;
|
|
115
|
+
clientId: string;
|
|
116
|
+
}
|
|
115
117
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
},
|
|
122
|
-
{
|
|
123
|
-
name: "list_roots",
|
|
124
|
-
description: "List workspace roots exposed by the local daemon.",
|
|
125
|
-
inputSchema: { type: "object", additionalProperties: true },
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
name: "list_dir",
|
|
129
|
-
description: "List direct children of a workspace directory.",
|
|
130
|
-
inputSchema: {
|
|
131
|
-
type: "object",
|
|
132
|
-
properties: { path: { type: "string", default: "." } },
|
|
133
|
-
additionalProperties: true,
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
{
|
|
137
|
-
name: "list_files",
|
|
138
|
-
description: "Recursively list files under a workspace path.",
|
|
139
|
-
inputSchema: {
|
|
140
|
-
type: "object",
|
|
141
|
-
properties: {
|
|
142
|
-
path: { type: "string", default: "." },
|
|
143
|
-
max_files: { type: "integer", minimum: 1, maximum: 10000, default: 1000 },
|
|
144
|
-
},
|
|
145
|
-
additionalProperties: true,
|
|
146
|
-
},
|
|
147
|
-
},
|
|
148
|
-
{
|
|
149
|
-
name: "read_file",
|
|
150
|
-
description: "Read a UTF-8 file. Relative paths use the daemon workspace; paths outside the workspace require the daemon to be started with --unrestricted-paths.",
|
|
151
|
-
inputSchema: {
|
|
152
|
-
type: "object",
|
|
153
|
-
properties: {
|
|
154
|
-
path: { type: "string" },
|
|
155
|
-
max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
|
|
156
|
-
},
|
|
157
|
-
required: ["path"],
|
|
158
|
-
additionalProperties: true,
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
name: "write_file",
|
|
163
|
-
description: "Atomically write a UTF-8 file up to 5 MiB. Paths outside the workspace require --unrestricted-paths; symbolic-link destinations are rejected. Enabled by default.",
|
|
164
|
-
inputSchema: {
|
|
165
|
-
type: "object",
|
|
166
|
-
properties: {
|
|
167
|
-
path: { type: "string" },
|
|
168
|
-
content: { type: "string" },
|
|
169
|
-
create_only: { type: "boolean", default: false },
|
|
170
|
-
expected_sha256: { type: "string" },
|
|
171
|
-
},
|
|
172
|
-
required: ["path", "content"],
|
|
173
|
-
additionalProperties: true,
|
|
174
|
-
},
|
|
175
|
-
},
|
|
176
|
-
{
|
|
177
|
-
name: "search_text",
|
|
178
|
-
description: "Search plain text files under a workspace path for a substring.",
|
|
179
|
-
inputSchema: {
|
|
180
|
-
type: "object",
|
|
181
|
-
properties: {
|
|
182
|
-
query: { type: "string" },
|
|
183
|
-
path: { type: "string", default: "." },
|
|
184
|
-
max_matches: { type: "integer", minimum: 1, maximum: 1000, default: 100 },
|
|
185
|
-
max_files: { type: "integer", minimum: 1, maximum: 100000, default: 10000 },
|
|
186
|
-
},
|
|
187
|
-
required: ["query"],
|
|
188
|
-
additionalProperties: true,
|
|
189
|
-
},
|
|
190
|
-
},
|
|
191
|
-
{
|
|
192
|
-
name: "git_status",
|
|
193
|
-
description: "Run git status --short for the repository containing a workspace path.",
|
|
194
|
-
inputSchema: {
|
|
195
|
-
type: "object",
|
|
196
|
-
properties: { path: { type: "string", default: "." } },
|
|
197
|
-
additionalProperties: true,
|
|
198
|
-
},
|
|
199
|
-
},
|
|
200
|
-
{
|
|
201
|
-
name: "git_diff",
|
|
202
|
-
description: "Run git diff for the local workspace and return bounded output.",
|
|
203
|
-
inputSchema: {
|
|
204
|
-
type: "object",
|
|
205
|
-
properties: {
|
|
206
|
-
path: { type: "string", default: "." },
|
|
207
|
-
max_bytes: { type: "integer", minimum: 1, maximum: 5242880, default: 1048576 },
|
|
208
|
-
},
|
|
209
|
-
additionalProperties: true,
|
|
210
|
-
},
|
|
211
|
-
},
|
|
212
|
-
{
|
|
213
|
-
name: "exec_command",
|
|
214
|
-
description: "Execute a shell command with cwd set to the daemon workspace. Enabled by default; environment is intentionally minimal unless the daemon is started with full env.",
|
|
215
|
-
inputSchema: {
|
|
216
|
-
type: "object",
|
|
217
|
-
properties: {
|
|
218
|
-
command: { type: "string" },
|
|
219
|
-
timeout_seconds: { type: "integer", minimum: 1, maximum: 600, default: 120 },
|
|
220
|
-
},
|
|
221
|
-
required: ["command"],
|
|
222
|
-
additionalProperties: true,
|
|
223
|
-
},
|
|
224
|
-
},
|
|
225
|
-
] as const;
|
|
118
|
+
type ToolDefinition = Record<string, unknown> & { name: string; availability?: string };
|
|
119
|
+
|
|
120
|
+
const allCatalogTools = toolCatalog as ToolDefinition[];
|
|
121
|
+
const serverInfoTool = publicTool(allCatalogTools.find((tool) => tool.name === "server_info")!);
|
|
122
|
+
const workspaceTools = allCatalogTools.filter((tool) => tool.name !== "server_info").map(publicTool);
|
|
226
123
|
|
|
227
124
|
const MCP_INSTRUCTIONS = [
|
|
228
125
|
"You are connected to a local workspace through machine-bridge-mcp.",
|
|
229
|
-
"The Worker
|
|
230
|
-
"Relative paths use the configured workspace
|
|
231
|
-
"
|
|
232
|
-
"Prefer
|
|
126
|
+
"The Cloudflare Worker authenticates and relays calls; file and command operations execute on the user's local runtime.",
|
|
127
|
+
"Relative paths use the configured workspace. Direct filesystem tools are workspace-scoped unless unrestricted paths are explicitly enabled.",
|
|
128
|
+
"run_process avoids shell parsing but is not an OS sandbox. exec_command is only exposed in shell mode and has the local user's authority.",
|
|
129
|
+
"Prefer read_file line ranges, edit_file, or apply_patch over whole-file replacement, and report commands that were run.",
|
|
233
130
|
].join("\n");
|
|
234
131
|
|
|
132
|
+
function publicTool(tool: ToolDefinition): ToolDefinition {
|
|
133
|
+
const { availability: _availability, ...definition } = tool;
|
|
134
|
+
return structuredClone(definition) as ToolDefinition;
|
|
135
|
+
}
|
|
136
|
+
|
|
235
137
|
export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
236
138
|
private readonly pending = new Map<string, PendingCall>();
|
|
237
139
|
private oauthQueue: Promise<void> = Promise.resolve();
|
|
@@ -300,7 +202,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
300
202
|
return json({ error: "not_found" }, 404);
|
|
301
203
|
} catch (error) {
|
|
302
204
|
if (error instanceof HttpError) return json({ error: error.code, message: error.message }, error.status);
|
|
303
|
-
console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname,
|
|
205
|
+
console.error(JSON.stringify({ level: "error", message: "request_failed", path: url.pathname, error_class: workerErrorClass(error) }));
|
|
304
206
|
return json({ error: "internal_server_error" }, 500);
|
|
305
207
|
}
|
|
306
208
|
}
|
|
@@ -346,11 +248,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
346
248
|
return;
|
|
347
249
|
}
|
|
348
250
|
}
|
|
251
|
+
const daemonPolicy = sanitizeDaemonPolicy(body.policy);
|
|
349
252
|
ws.serializeAttachment({
|
|
350
253
|
role: "daemon",
|
|
351
254
|
connectedAt: new Date().toISOString(),
|
|
352
|
-
policy:
|
|
353
|
-
tools: sanitizeDaemonTools(body.tools),
|
|
255
|
+
policy: daemonPolicy,
|
|
256
|
+
tools: sanitizeDaemonTools(body.tools, daemonPolicy),
|
|
354
257
|
} satisfies DaemonAttachment);
|
|
355
258
|
await this.scheduleCandidateAlarm();
|
|
356
259
|
try {
|
|
@@ -428,21 +331,37 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
428
331
|
const body = await parseJsonRequest(request, this.bodyLimitBytes());
|
|
429
332
|
if (isJsonRpcResponse(body)) return new Response(null, { status: 202 });
|
|
430
333
|
if (!isJsonRpcRequest(body)) return json(rpcError(null, -32600, "Invalid JSON-RPC request"), 400);
|
|
431
|
-
const
|
|
334
|
+
const protocolError = validateProtocolVersionHeader(request, body);
|
|
335
|
+
if (protocolError) return json(protocolError, 400);
|
|
336
|
+
const response = await this.dispatchJsonRpc(body, base, authorized);
|
|
432
337
|
if (response === null) return new Response(null, { status: 202 });
|
|
433
338
|
return json(response);
|
|
434
339
|
}
|
|
435
340
|
|
|
436
|
-
private async dispatchJsonRpc(request: JsonRpcRequest, base: string): Promise<Record<string, unknown> | null> {
|
|
341
|
+
private async dispatchJsonRpc(request: JsonRpcRequest, base: string, authorized: AuthorizedToken): Promise<Record<string, unknown> | null> {
|
|
437
342
|
if (request.method === "initialize") {
|
|
343
|
+
const requested = asObject(request.params).protocolVersion;
|
|
344
|
+
const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
|
|
345
|
+
? requested
|
|
346
|
+
: MCP_PROTOCOL_VERSION;
|
|
438
347
|
return rpcResult(request.id, {
|
|
439
|
-
protocolVersion
|
|
440
|
-
capabilities: { tools: { listChanged: false } },
|
|
441
|
-
serverInfo: {
|
|
348
|
+
protocolVersion,
|
|
349
|
+
capabilities: { tools: { listChanged: false }, logging: {} },
|
|
350
|
+
serverInfo: {
|
|
351
|
+
name: SERVER_NAME,
|
|
352
|
+
title: "Machine Bridge MCP",
|
|
353
|
+
version: SERVER_VERSION,
|
|
354
|
+
description: "Workspace-scoped local coding tools over authenticated remote relay.",
|
|
355
|
+
},
|
|
442
356
|
instructions: MCP_INSTRUCTIONS,
|
|
443
357
|
});
|
|
444
358
|
}
|
|
445
359
|
if (request.method === "notifications/initialized") return null;
|
|
360
|
+
if (request.method === "notifications/cancelled") {
|
|
361
|
+
this.cancelClientRequest(clientRequestKey(authorized, asObject(request.params).requestId));
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
if (request.method === "logging/setLevel") return rpcResult(request.id, {});
|
|
446
365
|
if (request.method === "ping") return rpcResult(request.id, {});
|
|
447
366
|
if (request.method === "tools/list") return rpcResult(request.id, { tools: this.allTools() });
|
|
448
367
|
if (request.method === "tools/call") {
|
|
@@ -450,7 +369,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
450
369
|
const name = requiredString(params, "name");
|
|
451
370
|
const args = asObject(params.arguments);
|
|
452
371
|
try {
|
|
453
|
-
const result = await this.callTool(name, args, base);
|
|
372
|
+
const result = await this.callTool(name, args, base, clientRequestKey(authorized, request.id));
|
|
454
373
|
return rpcResult(request.id, textToolResult(result));
|
|
455
374
|
} catch (error) {
|
|
456
375
|
return rpcResult(request.id, textToolResult({ error: errorMessage(error) }, true));
|
|
@@ -459,7 +378,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
459
378
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
460
379
|
}
|
|
461
380
|
|
|
462
|
-
private async callTool(name: string, args: Record<string, unknown>, base: string): Promise<unknown> {
|
|
381
|
+
private async callTool(name: string, args: Record<string, unknown>, base: string, requestKey?: string): Promise<unknown> {
|
|
463
382
|
if (name === "server_info") {
|
|
464
383
|
return {
|
|
465
384
|
name: SERVER_NAME,
|
|
@@ -472,23 +391,27 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
472
391
|
}
|
|
473
392
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
474
393
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
475
|
-
return this.callDaemonTool(name, args);
|
|
394
|
+
return this.callDaemonTool(name, args, requestKey);
|
|
476
395
|
}
|
|
477
396
|
throw new Error(`unknown tool: ${name}`);
|
|
478
397
|
}
|
|
479
398
|
|
|
480
|
-
private async callDaemonTool(name: string, args: Record<string, unknown
|
|
399
|
+
private async callDaemonTool(name: string, args: Record<string, unknown>, requestKey?: string): Promise<unknown> {
|
|
481
400
|
const socket = this.daemonSockets()[0];
|
|
482
401
|
if (!socket) throw new Error("local daemon is not connected; keep the CLI start command running");
|
|
483
402
|
if (this.pending.size >= MAX_PENDING_CALLS) throw new Error("too many concurrent daemon tool calls");
|
|
403
|
+
if (requestKey && [...this.pending.values()].some((pending) => pending.clientRequestKey === requestKey)) {
|
|
404
|
+
throw new Error("duplicate in-flight JSON-RPC request id for this access token");
|
|
405
|
+
}
|
|
484
406
|
const id = randomToken("call");
|
|
485
407
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
486
408
|
return await new Promise((resolve, reject) => {
|
|
487
409
|
const timeout = setTimeout(() => {
|
|
488
410
|
this.pending.delete(id);
|
|
411
|
+
try { socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
|
|
489
412
|
reject(new Error(`daemon tool timed out: ${name}`));
|
|
490
413
|
}, timeoutMs);
|
|
491
|
-
this.pending.set(id, { resolve, reject, timeout, socket });
|
|
414
|
+
this.pending.set(id, { resolve, reject, timeout, socket, clientRequestKey: requestKey });
|
|
492
415
|
try {
|
|
493
416
|
socket.send(JSON.stringify({ type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs }));
|
|
494
417
|
} catch (error) {
|
|
@@ -499,6 +422,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
499
422
|
});
|
|
500
423
|
}
|
|
501
424
|
|
|
425
|
+
private cancelClientRequest(requestKey?: string): void {
|
|
426
|
+
if (!requestKey) return;
|
|
427
|
+
for (const [id, pending] of this.pending) {
|
|
428
|
+
if (pending.clientRequestKey !== requestKey) continue;
|
|
429
|
+
clearTimeout(pending.timeout);
|
|
430
|
+
this.pending.delete(id);
|
|
431
|
+
try { pending.socket.send(JSON.stringify({ type: "cancel_call", id })); } catch {}
|
|
432
|
+
pending.reject(new Error("tool call cancelled by client"));
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
502
436
|
private async acceptDaemonWebSocket(request: Request): Promise<Response> {
|
|
503
437
|
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
|
|
504
438
|
const expected = this.env.DAEMON_SHARED_SECRET ?? "";
|
|
@@ -523,20 +457,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
523
457
|
|
|
524
458
|
private allTools(): Array<Record<string, unknown>> {
|
|
525
459
|
const advertised = this.daemonAdvertisedTools();
|
|
526
|
-
const localTools = advertised
|
|
527
|
-
|
|
528
|
-
: workspaceTools;
|
|
529
|
-
return [serverInfoTool, ...localTools].map((tool) => ({ ...tool }));
|
|
460
|
+
const localTools = workspaceTools.filter((tool) => advertised.has(tool.name));
|
|
461
|
+
return [serverInfoTool, ...localTools].map((tool) => structuredClone(tool));
|
|
530
462
|
}
|
|
531
463
|
|
|
532
464
|
private daemonToolEnabled(name: string): boolean {
|
|
533
|
-
|
|
534
|
-
return !advertised || advertised.has(name);
|
|
465
|
+
return this.daemonAdvertisedTools().has(name);
|
|
535
466
|
}
|
|
536
467
|
|
|
537
|
-
private daemonAdvertisedTools(): Set<string>
|
|
468
|
+
private daemonAdvertisedTools(): Set<string> {
|
|
538
469
|
const socket = this.daemonSockets()[0];
|
|
539
|
-
if (!socket) return
|
|
470
|
+
if (!socket) return new Set();
|
|
540
471
|
const attachment = this.daemonAttachment(socket);
|
|
541
472
|
if (!attachment?.tools) return new Set();
|
|
542
473
|
return new Set(attachment.tools);
|
|
@@ -568,11 +499,12 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
568
499
|
if (!raw || typeof raw !== "object") return undefined;
|
|
569
500
|
const candidate = raw as Partial<DaemonAttachment>;
|
|
570
501
|
if (candidate.role !== "candidate" && candidate.role !== "expired" && candidate.role !== "daemon") return undefined;
|
|
502
|
+
const policy = sanitizeDaemonPolicy(candidate.policy);
|
|
571
503
|
return {
|
|
572
504
|
role: candidate.role,
|
|
573
505
|
connectedAt: sanitizeMetadataText(candidate.connectedAt, 64) ?? "",
|
|
574
|
-
policy
|
|
575
|
-
tools: sanitizeDaemonTools(candidate.tools),
|
|
506
|
+
policy,
|
|
507
|
+
tools: sanitizeDaemonTools(candidate.tools, policy),
|
|
576
508
|
};
|
|
577
509
|
}
|
|
578
510
|
|
|
@@ -641,10 +573,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
641
573
|
name: SERVER_NAME,
|
|
642
574
|
version: SERVER_VERSION,
|
|
643
575
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
576
|
+
protocolVersions: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
644
577
|
transport: { type: "streamable-http", url: `${base}/mcp` },
|
|
645
|
-
auth: { type: "oauth" },
|
|
646
|
-
tools: [serverInfoTool, ...workspaceTools].map((tool) => tool.name),
|
|
647
|
-
instructions: MCP_INSTRUCTIONS,
|
|
578
|
+
auth: { type: "oauth", authorization_servers: [base] },
|
|
648
579
|
};
|
|
649
580
|
}
|
|
650
581
|
|
|
@@ -910,22 +841,22 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
910
841
|
});
|
|
911
842
|
}
|
|
912
843
|
|
|
913
|
-
private async verifyAccessToken(token: string, base: string): Promise<
|
|
844
|
+
private async verifyAccessToken(token: string, base: string): Promise<AuthorizedToken | null> {
|
|
914
845
|
return this.withOAuthLock(async () => {
|
|
915
|
-
if (!token) return
|
|
846
|
+
if (!token) return null;
|
|
916
847
|
const store = await this.oauthStore();
|
|
917
848
|
const key = `sha256:${await sha256Hex(token)}`;
|
|
918
849
|
const record = store.tokens[key];
|
|
919
|
-
if (!record) return
|
|
850
|
+
if (!record) return null;
|
|
920
851
|
if (record.expires_at <= Math.floor(Date.now() / 1000)) {
|
|
921
852
|
delete store.tokens[key];
|
|
922
853
|
await this.ctx.storage.put("oauth", store);
|
|
923
|
-
return
|
|
854
|
+
return null;
|
|
924
855
|
}
|
|
925
856
|
const currentVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
|
|
926
|
-
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return
|
|
927
|
-
if (record.resource !== `${base}/mcp`) return
|
|
928
|
-
return
|
|
857
|
+
if (!record.version || !currentVersion || !(await safeEqual(record.version, currentVersion))) return null;
|
|
858
|
+
if (record.resource !== `${base}/mcp`) return null;
|
|
859
|
+
return { tokenKey: key, clientId: record.client_id };
|
|
929
860
|
});
|
|
930
861
|
}
|
|
931
862
|
|
|
@@ -985,12 +916,30 @@ function rpcResult(id: JsonRpcId | undefined, result: unknown): Record<string, u
|
|
|
985
916
|
return { jsonrpc: JSONRPC_VERSION, id, result };
|
|
986
917
|
}
|
|
987
918
|
|
|
988
|
-
function rpcError(id: JsonRpcId | undefined, code: number, message: string): Record<string, unknown> {
|
|
989
|
-
|
|
919
|
+
function rpcError(id: JsonRpcId | undefined, code: number, message: string, data?: unknown): Record<string, unknown> {
|
|
920
|
+
const error: Record<string, unknown> = { code, message };
|
|
921
|
+
if (data !== undefined) error.data = data;
|
|
922
|
+
return { jsonrpc: JSONRPC_VERSION, id: id ?? null, error };
|
|
990
923
|
}
|
|
991
924
|
|
|
992
925
|
function textToolResult(value: unknown, isError = false): Record<string, unknown> {
|
|
993
|
-
|
|
926
|
+
const special = asObject(value).$mcp;
|
|
927
|
+
if (special && typeof special === "object" && !Array.isArray(special)) {
|
|
928
|
+
const specialObject = special as Record<string, unknown>;
|
|
929
|
+
if (Array.isArray(specialObject.content)) {
|
|
930
|
+
const result: Record<string, unknown> = { content: specialObject.content, isError };
|
|
931
|
+
if (specialObject.structuredContent && typeof specialObject.structuredContent === "object" && !Array.isArray(specialObject.structuredContent)) {
|
|
932
|
+
result.structuredContent = specialObject.structuredContent;
|
|
933
|
+
}
|
|
934
|
+
return result;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const result: Record<string, unknown> = {
|
|
938
|
+
content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
|
|
939
|
+
isError,
|
|
940
|
+
};
|
|
941
|
+
if (value && typeof value === "object" && !Array.isArray(value)) result.structuredContent = value;
|
|
942
|
+
return result;
|
|
994
943
|
}
|
|
995
944
|
|
|
996
945
|
function methodNotAllowed(allow: string): Response {
|
|
@@ -1031,19 +980,35 @@ function sanitizeMetadataText(value: unknown, maxLength: number): string | undef
|
|
|
1031
980
|
}
|
|
1032
981
|
|
|
1033
982
|
|
|
1034
|
-
function sanitizeDaemonTools(value: unknown): string[] {
|
|
983
|
+
function sanitizeDaemonTools(value: unknown, policy: Record<string, unknown>): string[] {
|
|
1035
984
|
if (!Array.isArray(value)) return [];
|
|
1036
|
-
const
|
|
1037
|
-
return [...new Set(value.filter((item): item is string =>
|
|
985
|
+
const definitions = new Map(allCatalogTools.map((tool) => [tool.name, tool]));
|
|
986
|
+
return [...new Set(value.filter((item): item is string => {
|
|
987
|
+
if (typeof item !== "string" || item === "server_info") return false;
|
|
988
|
+
const definition = definitions.get(item);
|
|
989
|
+
return Boolean(definition && daemonPolicyAllows(definition.availability, policy));
|
|
990
|
+
}))];
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function daemonPolicyAllows(availability: unknown, policy: Record<string, unknown>): boolean {
|
|
994
|
+
if (availability === "always") return true;
|
|
995
|
+
if (availability === "write") return policy.allowWrite === true;
|
|
996
|
+
if (availability === "direct-exec") return policy.execMode === "direct" || policy.execMode === "shell";
|
|
997
|
+
if (availability === "shell-exec") return policy.execMode === "shell";
|
|
998
|
+
return false;
|
|
1038
999
|
}
|
|
1039
1000
|
|
|
1040
1001
|
function sanitizeDaemonPolicy(value: unknown): Record<string, unknown> {
|
|
1041
1002
|
const policy = asObject(value);
|
|
1003
|
+
const execMode = policy.execMode === "shell" || policy.execMode === "direct" ? policy.execMode : "off";
|
|
1042
1004
|
return {
|
|
1005
|
+
profile: sanitizeMetadataText(policy.profile, 32) ?? "custom",
|
|
1043
1006
|
allowWrite: policy.allowWrite === true,
|
|
1044
|
-
allowExec:
|
|
1007
|
+
allowExec: execMode !== "off",
|
|
1008
|
+
execMode,
|
|
1045
1009
|
unrestrictedPaths: policy.unrestrictedPaths === true,
|
|
1046
1010
|
minimalEnv: policy.minimalEnv !== false,
|
|
1011
|
+
exposeAbsolutePaths: policy.exposeAbsolutePaths === true,
|
|
1047
1012
|
};
|
|
1048
1013
|
}
|
|
1049
1014
|
|
|
@@ -1164,11 +1129,27 @@ function clampNumber(value: unknown, fallback: number, min: number, max: number)
|
|
|
1164
1129
|
}
|
|
1165
1130
|
|
|
1166
1131
|
function daemonToolTimeoutMs(name: string, args: Record<string, unknown>): number {
|
|
1167
|
-
if (name !== "exec_command") return 60_000;
|
|
1132
|
+
if (name !== "exec_command" && name !== "run_process") return 60_000;
|
|
1168
1133
|
const seconds = clampNumber(args.timeout_seconds, 120, 1, 600);
|
|
1169
1134
|
return Math.min((seconds + 5) * 1000, 610_000);
|
|
1170
1135
|
}
|
|
1171
1136
|
|
|
1137
|
+
function validateProtocolVersionHeader(request: Request, body: JsonRpcRequest): Record<string, unknown> | null {
|
|
1138
|
+
if (body.method === "initialize") return null;
|
|
1139
|
+
const version = request.headers.get("MCP-Protocol-Version");
|
|
1140
|
+
if (!version) return null;
|
|
1141
|
+
if (MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(version as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])) return null;
|
|
1142
|
+
return rpcError(body.id, -32602, "Unsupported MCP protocol version", {
|
|
1143
|
+
requested: version,
|
|
1144
|
+
supported: [...MCP_SUPPORTED_PROTOCOL_VERSIONS],
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function clientRequestKey(authorized: AuthorizedToken, requestId: unknown): string | undefined {
|
|
1149
|
+
if (requestId === null || (typeof requestId !== "string" && typeof requestId !== "number")) return undefined;
|
|
1150
|
+
return `${authorized.tokenKey}:${typeof requestId}:${String(requestId)}`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1172
1153
|
function validateAuthorizationRequest(
|
|
1173
1154
|
body: Record<string, unknown>,
|
|
1174
1155
|
base: string,
|
|
@@ -1379,6 +1360,14 @@ function escapeHtml(value: string): string {
|
|
|
1379
1360
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
1380
1361
|
}
|
|
1381
1362
|
|
|
1363
|
+
function workerErrorClass(error: unknown): string {
|
|
1364
|
+
if (error instanceof HttpError) return error.code;
|
|
1365
|
+
if (error instanceof TypeError) return "type_error";
|
|
1366
|
+
if (error instanceof RangeError) return "range_error";
|
|
1367
|
+
if (error instanceof Error) return error.name.replace(/[^A-Za-z0-9_-]/g, "_").toLowerCase().slice(0, 64) || "error";
|
|
1368
|
+
return "unknown_error";
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1382
1371
|
function errorMessage(error: unknown): string {
|
|
1383
1372
|
if (error instanceof Error) return error.message;
|
|
1384
1373
|
if (typeof error === "string") return error;
|
package/tsconfig.json
CHANGED
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
"strict": true,
|
|
11
11
|
"noEmit": true,
|
|
12
12
|
"skipLibCheck": true,
|
|
13
|
-
"forceConsistentCasingInFileNames": true
|
|
13
|
+
"forceConsistentCasingInFileNames": true,
|
|
14
|
+
"resolveJsonModule": true
|
|
14
15
|
},
|
|
15
16
|
"include": [
|
|
16
17
|
"src/worker/**/*.ts",
|
|
17
|
-
"src/worker/**/*.d.ts"
|
|
18
|
+
"src/worker/**/*.d.ts",
|
|
19
|
+
"src/shared/**/*.json"
|
|
18
20
|
]
|
|
19
21
|
}
|