machine-bridge-mcp 1.2.9 → 1.2.11
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 +17 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +1 -1
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +15 -8
- package/docs/AUDIT.md +22 -0
- package/docs/LOGGING.md +4 -2
- package/docs/MULTI_ACCOUNT.md +2 -2
- package/docs/OPERATIONS.md +5 -4
- package/docs/RELEASING.md +1 -1
- package/docs/TESTING.md +5 -1
- package/docs/TOOL_REFERENCE.md +4 -4
- package/package.json +6 -2
- package/scripts/check-plan.mjs +3 -0
- package/scripts/check-runner.mjs +75 -0
- package/scripts/coverage-check.mjs +1 -1
- package/scripts/github-backlog.mjs +116 -0
- package/scripts/github-push.mjs +4 -0
- package/scripts/release-acceptance.mjs +29 -13
- package/scripts/run-checks.mjs +11 -21
- package/src/local/bounded-output.mjs +20 -3
- package/src/local/errors.mjs +5 -1
- package/src/local/execution-limits.mjs +6 -1
- package/src/local/process-execution.mjs +94 -14
- package/src/local/process-output-stream.mjs +82 -0
- package/src/local/process-result-projection.mjs +25 -0
- package/src/local/process-sessions.mjs +37 -51
- package/src/local/relay-call-recovery.mjs +148 -0
- package/src/local/relay-connection.mjs +5 -0
- package/src/local/runtime-relay.mjs +16 -0
- package/src/local/runtime.mjs +60 -39
- package/src/local/secure-file.mjs +24 -4
- package/src/local/tools.mjs +4 -9
- package/src/shared/result-projection.d.mts +6 -0
- package/src/shared/result-projection.json +4 -0
- package/src/shared/result-projection.mjs +50 -0
- package/src/shared/server-metadata.json +1 -0
- package/src/shared/tool-catalog.json +4 -4
- package/src/worker/daemon-sockets.ts +10 -1
- package/src/worker/errors.ts +18 -4
- package/src/worker/index.ts +45 -9
- package/src/worker/mcp-jsonrpc.ts +4 -2
- package/src/worker/pending-call-contract.ts +26 -0
- package/src/worker/pending-calls.ts +41 -25
- package/tsconfig.local.json +1 -0
package/src/worker/errors.ts
CHANGED
|
@@ -8,12 +8,14 @@ const WORKER_ERROR_CODES = new Set([
|
|
|
8
8
|
export class WorkerToolError extends Error {
|
|
9
9
|
readonly code: string;
|
|
10
10
|
readonly retryable: boolean;
|
|
11
|
+
readonly details?: Record<string, unknown>;
|
|
11
12
|
|
|
12
|
-
constructor(code: string, message: string, retryable = false) {
|
|
13
|
+
constructor(code: string, message: string, retryable = false, details?: Record<string, unknown>) {
|
|
13
14
|
super(message);
|
|
14
15
|
this.name = "WorkerToolError";
|
|
15
16
|
this.code = normalizeCode(code);
|
|
16
17
|
this.retryable = retryable;
|
|
18
|
+
this.details = details;
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -23,11 +25,19 @@ export function daemonToolError(value: unknown): WorkerToolError {
|
|
|
23
25
|
const message = typeof input.message === "string" && input.message
|
|
24
26
|
? input.message.slice(0, 2000)
|
|
25
27
|
: "daemon tool failed";
|
|
26
|
-
return new WorkerToolError(
|
|
28
|
+
return new WorkerToolError(
|
|
29
|
+
code, message, input.retryable === true,
|
|
30
|
+
isRecord(input.details) ? input.details : undefined,
|
|
31
|
+
);
|
|
27
32
|
}
|
|
28
33
|
|
|
29
|
-
export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean } {
|
|
30
|
-
if (error instanceof WorkerToolError)
|
|
34
|
+
export function publicWorkerToolError(error: unknown): { code: string; message: string; retryable: boolean; details?: Record<string, unknown> } {
|
|
35
|
+
if (error instanceof WorkerToolError) {
|
|
36
|
+
return {
|
|
37
|
+
code: error.code, message: error.message, retryable: error.retryable,
|
|
38
|
+
...(error.details ? { details: error.details } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
31
41
|
return { code: "execution_failed", message: "tool execution failed", retryable: false };
|
|
32
42
|
}
|
|
33
43
|
|
|
@@ -36,6 +46,10 @@ function normalizeCode(value: unknown): string {
|
|
|
36
46
|
return WORKER_ERROR_CODES.has(code) ? code : "execution_failed";
|
|
37
47
|
}
|
|
38
48
|
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
39
53
|
function asObject(value: unknown): Record<string, unknown> {
|
|
40
54
|
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
41
55
|
}
|
package/src/worker/index.ts
CHANGED
|
@@ -34,13 +34,14 @@ import {
|
|
|
34
34
|
} from "./websocket-protocol.ts";
|
|
35
35
|
|
|
36
36
|
const SERVER_NAME = String(serverMetadata.name);
|
|
37
|
-
const SERVER_VERSION = "1.2.
|
|
37
|
+
const SERVER_VERSION = "1.2.11";
|
|
38
38
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
39
39
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
40
40
|
const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
41
41
|
const MAX_BODY_BYTES = 16 * 1024 * 1024;
|
|
42
42
|
const MAX_PENDING_CALLS = 32;
|
|
43
43
|
const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
44
|
+
const DAEMON_RECONNECT_GRACE_MS = 30_000;
|
|
44
45
|
|
|
45
46
|
interface BridgeEnv extends OAuthControllerEnv {
|
|
46
47
|
BRIDGE: DurableObjectNamespace<BridgeRoom>;
|
|
@@ -179,11 +180,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
179
180
|
return;
|
|
180
181
|
}
|
|
181
182
|
const daemonPolicy = sanitizeDaemonPolicy(body.policy);
|
|
183
|
+
const instanceId = daemonInstanceId(body.instance_id);
|
|
184
|
+
if (!instanceId) {
|
|
185
|
+
rejectDaemonMessage(ws, "invalid_daemon_instance", 1002, "daemon instance id required");
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
182
188
|
const authenticatedAt = new Date().toISOString();
|
|
183
189
|
const probeId = randomToken("probe");
|
|
184
190
|
this.daemonRegistry.beginProbe(ws, {
|
|
185
191
|
connectedAt: authenticatedAt,
|
|
186
192
|
probeId,
|
|
193
|
+
instanceId,
|
|
187
194
|
policy: daemonPolicy,
|
|
188
195
|
tools: sanitizeDaemonTools(body.tools, daemonPolicy),
|
|
189
196
|
});
|
|
@@ -218,11 +225,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
218
225
|
return;
|
|
219
226
|
}
|
|
220
227
|
const readyAt = new Date().toISOString();
|
|
221
|
-
|
|
228
|
+
const readyAttachment = this.daemonRegistry.promote(ws, readyAt);
|
|
229
|
+
if (!readyAttachment) {
|
|
222
230
|
rejectDaemonMessage(ws, "invalid_relay_readiness_state", 1002, "invalid daemon readiness state");
|
|
223
231
|
return;
|
|
224
232
|
}
|
|
233
|
+
const reboundCallIds = this.pending.rebindInstance(readyAttachment.instanceId ?? "", ws);
|
|
234
|
+
if (reboundCallIds.length > 0) {
|
|
235
|
+
this.observability.event("info", "daemon.calls.rebound", { rebound_calls: reboundCallIds.length });
|
|
236
|
+
}
|
|
225
237
|
try {
|
|
238
|
+
ws.send(JSON.stringify({ type: "resume_calls", ids: reboundCallIds }));
|
|
226
239
|
ws.send(JSON.stringify({ type: "ready_ack", server: SERVER_NAME, version: SERVER_VERSION }));
|
|
227
240
|
} catch {
|
|
228
241
|
this.invalidateDaemonSocket(ws, "daemon readiness acknowledgement failed", "daemon ready timeout", "daemon_ready_timeout");
|
|
@@ -263,7 +276,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
263
276
|
|
|
264
277
|
private async cleanupDaemonSocket(ws: WebSocket, message: string): Promise<void> {
|
|
265
278
|
this.observability.socketDisconnected();
|
|
266
|
-
this.
|
|
279
|
+
this.detachDaemonSocketCalls(ws, message);
|
|
267
280
|
await this.scheduleSocketAlarms();
|
|
268
281
|
}
|
|
269
282
|
|
|
@@ -428,6 +441,9 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
428
441
|
this.reclaimStaleDaemonSockets();
|
|
429
442
|
const socket = this.daemonRegistry.readySockets()[0];
|
|
430
443
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
444
|
+
const daemonAttachment = this.daemonRegistry.readyAttachment(socket);
|
|
445
|
+
const daemonInstanceId = daemonAttachment?.instanceId ?? "";
|
|
446
|
+
if (!daemonInstanceId) throw new WorkerToolError("unavailable", "local daemon connection is missing its instance identity", true);
|
|
431
447
|
const id = randomToken("call");
|
|
432
448
|
const timeoutMs = daemonToolTimeoutMs(name, args);
|
|
433
449
|
let result: Promise<unknown>;
|
|
@@ -435,20 +451,23 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
435
451
|
result = this.pending.register({
|
|
436
452
|
id,
|
|
437
453
|
socket,
|
|
454
|
+
daemonInstanceId,
|
|
438
455
|
clientRequestKey: requestKey,
|
|
439
456
|
tool: name,
|
|
440
457
|
timeoutMs,
|
|
441
458
|
onTimeout: (record) => {
|
|
442
|
-
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
443
|
-
const silentForMs =
|
|
444
|
-
|
|
459
|
+
if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
460
|
+
const silentForMs = record.socket
|
|
461
|
+
? Date.now() - daemonLastSeenMs(this.daemonRegistry.readyAttachment(record.socket))
|
|
462
|
+
: 0;
|
|
463
|
+
if (record.socket && (!Number.isFinite(silentForMs) || silentForMs > 45_000)) {
|
|
445
464
|
this.invalidateDaemonSocket(record.socket, "daemon became unresponsive", "daemon liveness timeout");
|
|
446
465
|
}
|
|
447
466
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
448
467
|
},
|
|
449
468
|
signal,
|
|
450
469
|
onAbort: (record) => {
|
|
451
|
-
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
470
|
+
if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
452
471
|
return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
|
|
453
472
|
},
|
|
454
473
|
});
|
|
@@ -482,7 +501,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
482
501
|
private cancelClientRequest(requestKey?: string): void {
|
|
483
502
|
if (!requestKey) return;
|
|
484
503
|
this.pending.cancelRequest(requestKey, (record) => {
|
|
485
|
-
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
504
|
+
if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
486
505
|
return new WorkerToolError("cancelled", "tool call cancelled by client");
|
|
487
506
|
});
|
|
488
507
|
}
|
|
@@ -542,12 +561,24 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
542
561
|
closeReason: string,
|
|
543
562
|
errorCode = "daemon_liveness_timeout",
|
|
544
563
|
): void {
|
|
564
|
+
this.detachDaemonSocketCalls(ws, message);
|
|
545
565
|
this.daemonRegistry.expire(ws);
|
|
546
|
-
this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
547
566
|
sendWebSocketQuietly(ws, { type: "error", error: errorCode });
|
|
548
567
|
closeWebSocketQuietly(ws, 1008, closeReason);
|
|
549
568
|
}
|
|
550
569
|
|
|
570
|
+
private detachDaemonSocketCalls(ws: WebSocket, message: string): number {
|
|
571
|
+
const attachment = this.daemonRegistry.attachment(ws);
|
|
572
|
+
if (!attachment?.instanceId) {
|
|
573
|
+
return this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
|
|
574
|
+
}
|
|
575
|
+
return this.pending.detachSocket(
|
|
576
|
+
ws,
|
|
577
|
+
DAEMON_RECONNECT_GRACE_MS,
|
|
578
|
+
() => new WorkerToolError("unavailable", `${message}; reconnect grace expired`, true),
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
551
582
|
private reclaimStaleDaemonSockets(now = Date.now()): void {
|
|
552
583
|
for (const socket of this.daemonRegistry.readyRoleSockets()) {
|
|
553
584
|
const deadline = daemonLivenessDeadlineMs(this.daemonRegistry.readyAttachment(socket));
|
|
@@ -697,3 +728,8 @@ export default {
|
|
|
697
728
|
return stub.fetch(request);
|
|
698
729
|
},
|
|
699
730
|
} satisfies ExportedHandler<BridgeEnv>;
|
|
731
|
+
|
|
732
|
+
function daemonInstanceId(value: unknown): string {
|
|
733
|
+
if (typeof value !== "string" || !/^daemon_[A-Za-z0-9_-]{16,96}$/.test(value)) return "";
|
|
734
|
+
return value;
|
|
735
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { projectMcpResult } from "../shared/result-projection.mjs";
|
|
1
2
|
const JSONRPC_VERSION = "2.0";
|
|
2
3
|
const MAX_SESSION_INSTRUCTION_BYTES = 3 * 1024 * 1024;
|
|
3
4
|
|
|
@@ -48,11 +49,12 @@ export function textToolResult(value: unknown, isError = false): Record<string,
|
|
|
48
49
|
return result;
|
|
49
50
|
}
|
|
50
51
|
}
|
|
52
|
+
const projection = projectMcpResult(value);
|
|
51
53
|
const result: Record<string, unknown> = {
|
|
52
|
-
content: [{ type: "text", text:
|
|
54
|
+
content: [{ type: "text", text: projection.text }],
|
|
53
55
|
isError,
|
|
54
56
|
};
|
|
55
|
-
if (
|
|
57
|
+
if (projection.structuredContent) result.structuredContent = projection.structuredContent;
|
|
56
58
|
return result;
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface PendingCallRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
socket?: WebSocket;
|
|
4
|
+
daemonInstanceId?: string;
|
|
5
|
+
reconnectTimeout?: ReturnType<typeof setTimeout>;
|
|
6
|
+
clientRequestKey?: string;
|
|
7
|
+
tool: string;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
10
|
+
resolve: (value: unknown) => void;
|
|
11
|
+
reject: (error: Error) => void;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
abortHandler?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RegisterPendingCall {
|
|
17
|
+
id: string;
|
|
18
|
+
socket: WebSocket;
|
|
19
|
+
daemonInstanceId?: string;
|
|
20
|
+
clientRequestKey?: string;
|
|
21
|
+
tool: string;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
onTimeout: (record: PendingCallRecord) => Error;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
onAbort?: (record: PendingCallRecord) => Error;
|
|
26
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { PendingCallRecord, RegisterPendingCall } from "./pending-call-contract.ts";
|
|
2
|
+
|
|
1
3
|
export class PendingCallRegistrationError extends Error {
|
|
2
4
|
readonly code: "conflict" | "limit_exceeded";
|
|
3
5
|
readonly retryable: boolean;
|
|
@@ -10,30 +12,6 @@ export class PendingCallRegistrationError extends Error {
|
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export interface PendingCallRecord {
|
|
14
|
-
id: string;
|
|
15
|
-
socket: WebSocket;
|
|
16
|
-
clientRequestKey?: string;
|
|
17
|
-
tool: string;
|
|
18
|
-
startedAt: number;
|
|
19
|
-
timeout: ReturnType<typeof setTimeout>;
|
|
20
|
-
resolve: (value: unknown) => void;
|
|
21
|
-
reject: (error: Error) => void;
|
|
22
|
-
signal?: AbortSignal;
|
|
23
|
-
abortHandler?: () => void;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface RegisterPendingCall {
|
|
27
|
-
id: string;
|
|
28
|
-
socket: WebSocket;
|
|
29
|
-
clientRequestKey?: string;
|
|
30
|
-
tool: string;
|
|
31
|
-
timeoutMs: number;
|
|
32
|
-
onTimeout: (record: PendingCallRecord) => Error;
|
|
33
|
-
signal?: AbortSignal;
|
|
34
|
-
onAbort?: (record: PendingCallRecord) => Error;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
15
|
export class PendingCallRegistry {
|
|
38
16
|
private readonly maximum: number;
|
|
39
17
|
private readonly byId = new Map<string, PendingCallRecord>();
|
|
@@ -78,6 +56,7 @@ export class PendingCallRegistry {
|
|
|
78
56
|
const record: PendingCallRecord = {
|
|
79
57
|
id: input.id,
|
|
80
58
|
socket: input.socket,
|
|
59
|
+
daemonInstanceId: input.daemonInstanceId,
|
|
81
60
|
clientRequestKey: input.clientRequestKey,
|
|
82
61
|
tool: String(input.tool || "unknown"),
|
|
83
62
|
startedAt,
|
|
@@ -126,16 +105,52 @@ export class PendingCallRegistry {
|
|
|
126
105
|
return ids.length;
|
|
127
106
|
}
|
|
128
107
|
|
|
129
|
-
|
|
108
|
+
detachSocket(
|
|
109
|
+
socket: WebSocket,
|
|
110
|
+
graceMs: number,
|
|
111
|
+
createError: (record: PendingCallRecord) => Error,
|
|
112
|
+
): number {
|
|
113
|
+
const records = [...this.byId.values()].filter((record) => record.socket === socket);
|
|
114
|
+
const delay = Math.max(1, Math.floor(Number(graceMs) || 1));
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
record.socket = undefined;
|
|
117
|
+
if (record.reconnectTimeout) clearTimeout(record.reconnectTimeout);
|
|
118
|
+
record.reconnectTimeout = setTimeout(() => {
|
|
119
|
+
const current = this.byId.get(record.id);
|
|
120
|
+
if (!current || current.socket) return;
|
|
121
|
+
const expired = this.take(record.id);
|
|
122
|
+
if (expired) expired.reject(createError(expired));
|
|
123
|
+
}, delay);
|
|
124
|
+
}
|
|
125
|
+
return records.length;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
rebindInstance(daemonInstanceId: string, socket: WebSocket): string[] {
|
|
129
|
+
if (!daemonInstanceId) return [];
|
|
130
|
+
const rebound: string[] = [];
|
|
131
|
+
for (const record of this.byId.values()) {
|
|
132
|
+
if (record.socket || record.daemonInstanceId !== daemonInstanceId) continue;
|
|
133
|
+
if (record.reconnectTimeout) clearTimeout(record.reconnectTimeout);
|
|
134
|
+
record.reconnectTimeout = undefined;
|
|
135
|
+
record.socket = socket;
|
|
136
|
+
rebound.push(record.id);
|
|
137
|
+
}
|
|
138
|
+
return rebound;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
snapshot(): { active: number; detached: number; request_keys: number; maximum: number; oldest_ms: number; by_tool: Record<string, number> } {
|
|
130
142
|
const now = performance.now();
|
|
131
143
|
const byTool: Record<string, number> = {};
|
|
144
|
+
let detached = 0;
|
|
132
145
|
let oldestMs = 0;
|
|
133
146
|
for (const record of this.byId.values()) {
|
|
134
147
|
byTool[record.tool] = (byTool[record.tool] || 0) + 1;
|
|
148
|
+
if (!record.socket) detached += 1;
|
|
135
149
|
oldestMs = Math.max(oldestMs, now - record.startedAt);
|
|
136
150
|
}
|
|
137
151
|
return {
|
|
138
152
|
active: this.byId.size,
|
|
153
|
+
detached,
|
|
139
154
|
request_keys: this.byRequestKey.size,
|
|
140
155
|
maximum: this.maximum,
|
|
141
156
|
oldest_ms: oldestMs,
|
|
@@ -147,6 +162,7 @@ export class PendingCallRegistry {
|
|
|
147
162
|
const record = this.byId.get(id);
|
|
148
163
|
if (!record) return undefined;
|
|
149
164
|
clearTimeout(record.timeout);
|
|
165
|
+
if (record.reconnectTimeout) clearTimeout(record.reconnectTimeout);
|
|
150
166
|
if (record.signal && record.abortHandler) record.signal.removeEventListener("abort", record.abortHandler);
|
|
151
167
|
this.byId.delete(id);
|
|
152
168
|
if (record.clientRequestKey && this.byRequestKey.get(record.clientRequestKey) === id) {
|
package/tsconfig.local.json
CHANGED