machine-bridge-mcp 1.2.0 → 1.2.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 +19 -0
- package/browser-extension/browser-operations.js +9 -3
- package/browser-extension/devtools-input.js +2 -2
- package/browser-extension/manifest.json +2 -2
- package/docs/ARCHITECTURE.md +6 -6
- package/docs/AUDIT.md +26 -0
- package/docs/ENGINEERING.md +3 -0
- package/docs/LOGGING.md +6 -2
- package/docs/OPERATIONS.md +5 -3
- package/docs/PROJECT_STANDARDS.md +1 -0
- package/docs/TESTING.md +6 -2
- package/docs/UPGRADING.md +6 -2
- package/package.json +4 -3
- package/scripts/coverage-check.mjs +3 -1
- package/src/local/account-access.mjs +7 -5
- package/src/local/call-registry.mjs +9 -3
- package/src/local/cli-local-admin.mjs +74 -38
- package/src/local/cli-options.mjs +18 -18
- package/src/local/cli-policy.mjs +1 -1
- package/src/local/cli-service.mjs +132 -0
- package/src/local/cli.mjs +24 -106
- package/src/local/log.mjs +10 -2
- package/src/local/managed-job-plan.mjs +3 -3
- package/src/local/policy.mjs +11 -6
- package/src/local/project-package.mjs +1 -1
- package/src/local/relay-connection.mjs +21 -0
- package/src/local/resource-operations.mjs +1 -1
- package/src/local/runtime-reporting.mjs +1 -1
- package/src/local/runtime.mjs +65 -27
- package/src/local/tool-executor.mjs +3 -3
- package/src/local/worker-deployment.mjs +15 -10
- package/src/local/worker-health.mjs +7 -3
- package/src/worker/access.ts +4 -3
- package/src/worker/http.ts +2 -2
- package/src/worker/index.ts +58 -17
- package/src/worker/oauth-controller.ts +11 -2
- package/src/worker/observability.ts +3 -1
- package/src/worker/pending-calls.ts +17 -0
- package/wrangler.jsonc +1 -1
package/src/local/runtime.mjs
CHANGED
|
@@ -113,6 +113,8 @@ export class LocalRuntime {
|
|
|
113
113
|
this.processTracker = new ProcessTracker();
|
|
114
114
|
this.lifecycle = new LifecycleController("local runtime");
|
|
115
115
|
this.observability = new RuntimeObservability();
|
|
116
|
+
this.activeRelayCalls = new Set();
|
|
117
|
+
this.suppressedRelayResults = new Map();
|
|
116
118
|
this.callRegistry = new CallRegistry({
|
|
117
119
|
maximum: MAX_CONCURRENT_TOOL_CALLS,
|
|
118
120
|
onCancel: (record) => {
|
|
@@ -121,7 +123,7 @@ export class LocalRuntime {
|
|
|
121
123
|
this.processTracker.terminateCall(record.id);
|
|
122
124
|
this.logger.event?.("debug", "tool.call.cancel_requested", {
|
|
123
125
|
call_id: shortCallId(record.id), tool: record.tool, origin: record.origin,
|
|
124
|
-
});
|
|
126
|
+
}, "Tool call cancellation requested");
|
|
125
127
|
},
|
|
126
128
|
onFinish: (record) => this.processTracker.releaseCall(record.id),
|
|
127
129
|
});
|
|
@@ -289,7 +291,7 @@ export class LocalRuntime {
|
|
|
289
291
|
return this.relay?.send(value) === true;
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
async handleMessage(raw) {
|
|
294
|
+
async handleMessage(raw, relayContext = {}) {
|
|
293
295
|
let message;
|
|
294
296
|
try { message = JSON.parse(raw); } catch {
|
|
295
297
|
this.handleRelayProtocolViolation("invalid_server_json");
|
|
@@ -304,7 +306,7 @@ export class LocalRuntime {
|
|
|
304
306
|
this.handleRelayProtocolViolation("unexpected_server_message_type");
|
|
305
307
|
return;
|
|
306
308
|
}
|
|
307
|
-
await this.handleRelayToolCall(message);
|
|
309
|
+
await this.handleRelayToolCall(message, relayContext);
|
|
308
310
|
}
|
|
309
311
|
|
|
310
312
|
handleRelayControlMessage(message) {
|
|
@@ -322,7 +324,7 @@ export class LocalRuntime {
|
|
|
322
324
|
return true;
|
|
323
325
|
}
|
|
324
326
|
if (message.type === "cancel_call") {
|
|
325
|
-
if (typeof message.id === "string") this.
|
|
327
|
+
if (typeof message.id === "string") this.cancelRelayCall(message.id, "caller_cancelled");
|
|
326
328
|
return true;
|
|
327
329
|
}
|
|
328
330
|
return false;
|
|
@@ -336,37 +338,65 @@ export class LocalRuntime {
|
|
|
336
338
|
this.logger.error?.("remote relay protocol error; upgrade and redeploy both components, then restart the daemon");
|
|
337
339
|
}
|
|
338
340
|
|
|
339
|
-
async handleRelayToolCall(message) {
|
|
341
|
+
async handleRelayToolCall(message, relayContext = {}) {
|
|
340
342
|
const envelope = normalizeRelayToolCall(message);
|
|
343
|
+
const relaySessionId = Number(relayContext.sessionId) || 0;
|
|
341
344
|
if (!envelope.ok) {
|
|
342
|
-
this.logger.
|
|
345
|
+
this.logger.warn?.("Received an invalid tool request from the relay; the request was rejected.");
|
|
346
|
+
this.logger.event?.("debug", "relay.tool_call.invalid", {
|
|
347
|
+
has_call_id: Boolean(envelope.id),
|
|
348
|
+
}, "Invalid relay tool request details");
|
|
343
349
|
if (envelope.id) this.deliverRelayToolResult({
|
|
344
350
|
type: "tool_result",
|
|
345
351
|
id: envelope.id,
|
|
346
352
|
ok: false,
|
|
347
353
|
error: { code: "invalid_request", message: "invalid tool_call envelope", retryable: false },
|
|
348
|
-
});
|
|
354
|
+
}, relaySessionId);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (this.activeRelayCalls.has(envelope.id)) {
|
|
358
|
+
this.handleRelayProtocolViolation("duplicate_tool_call_id");
|
|
349
359
|
return;
|
|
350
360
|
}
|
|
351
|
-
|
|
361
|
+
this.activeRelayCalls.add(envelope.id);
|
|
352
362
|
try {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
363
|
+
let response;
|
|
364
|
+
try {
|
|
365
|
+
const result = await this.executeTool(envelope.tool, envelope.arguments, {
|
|
366
|
+
callId: envelope.id,
|
|
367
|
+
origin: "relay",
|
|
368
|
+
timeoutMs: envelope.timeoutMs,
|
|
369
|
+
authorization: envelope.authorization,
|
|
370
|
+
});
|
|
371
|
+
response = { type: "tool_result", id: envelope.id, ok: true, result };
|
|
372
|
+
} catch (error) {
|
|
373
|
+
response = { type: "tool_result", id: envelope.id, ok: false, error: publicError(error) };
|
|
374
|
+
}
|
|
375
|
+
const suppressionReason = this.suppressedRelayResults.get(envelope.id) || "";
|
|
376
|
+
if (suppressionReason) {
|
|
377
|
+
this.logger.event?.("debug", "relay.tool_result.discarded", {
|
|
378
|
+
call_id: shortCallId(envelope.id), reason: suppressionReason,
|
|
379
|
+
}, "Discarded a tool result because the caller was no longer waiting");
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
this.deliverRelayToolResult(response, relaySessionId);
|
|
383
|
+
} finally {
|
|
384
|
+
this.activeRelayCalls.delete(envelope.id);
|
|
385
|
+
this.suppressedRelayResults.delete(envelope.id);
|
|
362
386
|
}
|
|
363
|
-
this.deliverRelayToolResult(response);
|
|
364
387
|
}
|
|
365
388
|
|
|
366
|
-
deliverRelayToolResult(response) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
389
|
+
deliverRelayToolResult(response, relaySessionId = 0) {
|
|
390
|
+
const outcome = this.relay?.sendForSession?.(response, relaySessionId)
|
|
391
|
+
|| (this.send(response) ? { ok: true, reason: "sent" } : { ok: false, reason: "transport_unavailable" });
|
|
392
|
+
if (outcome.ok) return true;
|
|
393
|
+
const reason = String(outcome.reason || "transport_unavailable");
|
|
394
|
+
this.logger.event?.("debug", "relay.tool_result.discarded", {
|
|
395
|
+
call_id: shortCallId(response?.id), reason,
|
|
396
|
+
}, reason === "send_failed"
|
|
397
|
+
? "Could not send a tool result because the relay transport failed"
|
|
398
|
+
: "Discarded a tool result because its relay session had ended");
|
|
399
|
+
if (reason === "send_failed") this.relay?.interrupt?.("relay_transport_error");
|
|
370
400
|
return false;
|
|
371
401
|
}
|
|
372
402
|
|
|
@@ -379,11 +409,19 @@ export class LocalRuntime {
|
|
|
379
409
|
return this.callRegistry.cancel(callId, reason);
|
|
380
410
|
}
|
|
381
411
|
|
|
412
|
+
cancelRelayCall(callId, suppressionReason = "caller_cancelled") {
|
|
413
|
+
const id = String(callId);
|
|
414
|
+
if (this.activeRelayCalls.has(id)) this.suppressedRelayResults.set(id, suppressionReason);
|
|
415
|
+
return this.cancelCall(id, "remote cancellation");
|
|
416
|
+
}
|
|
417
|
+
|
|
382
418
|
handleRelayDisconnect() {
|
|
419
|
+
for (const callId of this.activeRelayCalls) this.suppressedRelayResults.set(callId, "relay_disconnected");
|
|
383
420
|
const cancelled = this.callRegistry.cancelOrigin("relay", "remote relay disconnected");
|
|
384
421
|
this.terminateActiveProcesses("SIGTERM", true);
|
|
385
422
|
if (cancelled > 0) {
|
|
386
|
-
this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled }
|
|
423
|
+
this.logger.event?.("debug", "relay.calls.cancelled_on_disconnect", { cancelled_calls: cancelled },
|
|
424
|
+
"Cancelled in-flight tool calls after the relay connection ended");
|
|
387
425
|
}
|
|
388
426
|
}
|
|
389
427
|
|
|
@@ -513,7 +551,7 @@ export class LocalRuntime {
|
|
|
513
551
|
|
|
514
552
|
readLocalResourceBinary(name) {
|
|
515
553
|
const registry = this.managedJobManager.currentResources();
|
|
516
|
-
const resource = registry[name];
|
|
554
|
+
const resource = Object.hasOwn(registry, name) ? registry[name] : null;
|
|
517
555
|
if (!resource) throw new Error(`unknown local resource: ${name}`);
|
|
518
556
|
const inspected = inspectResourceFile(resource.path, { allowInsecurePermissions: resource.allowInsecurePermissions === true });
|
|
519
557
|
if (inspected.size > 1024 * 1024) throw new Error("local resource exceeds 1 MiB browser injection limit");
|
|
@@ -669,7 +707,7 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
|
|
|
669
707
|
policy: runtime.policy,
|
|
670
708
|
protocol_versions: MCP_SUPPORTED_PROTOCOL_VERSIONS,
|
|
671
709
|
}),
|
|
672
|
-
onMessage: (data) => handleRelayData(runtime, data),
|
|
710
|
+
onMessage: (data, relayContext) => handleRelayData(runtime, data, relayContext),
|
|
673
711
|
onDisconnect: () => runtime.handleRelayDisconnect(),
|
|
674
712
|
onSuperseded: () => {
|
|
675
713
|
runtime.terminateActiveProcesses("SIGKILL");
|
|
@@ -684,13 +722,13 @@ function createRelayConnection(runtime, { workerUrl, secret, expectedVersion, on
|
|
|
684
722
|
});
|
|
685
723
|
}
|
|
686
724
|
|
|
687
|
-
function handleRelayData(runtime, data) {
|
|
725
|
+
function handleRelayData(runtime, data, relayContext = {}) {
|
|
688
726
|
const raw = typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
|
689
727
|
if (Buffer.byteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
|
690
728
|
runtime.handleRelayProtocolViolation("server_message_too_large");
|
|
691
729
|
return;
|
|
692
730
|
}
|
|
693
|
-
return runtime.handleMessage(raw);
|
|
731
|
+
return runtime.handleMessage(raw, relayContext);
|
|
694
732
|
}
|
|
695
733
|
|
|
696
734
|
function normalizeRelayToolCall(message) {
|
|
@@ -67,7 +67,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
67
67
|
call_id: shortCallId(operation.context.callId),
|
|
68
68
|
tool: operation.tool,
|
|
69
69
|
origin: operation.context.origin,
|
|
70
|
-
});
|
|
70
|
+
}, "Tool call started");
|
|
71
71
|
try {
|
|
72
72
|
const result = await next(operation);
|
|
73
73
|
const durationMs = performance.now() - started;
|
|
@@ -75,7 +75,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
75
75
|
observability.finish(operation.tool, { status: "completed", durationMs, slow });
|
|
76
76
|
logger.event?.("debug", slow ? "tool.call.slow" : "tool.call.completed", {
|
|
77
77
|
call_id: shortCallId(operation.context.callId), tool: operation.tool, origin: operation.context.origin, duration_ms: durationMs,
|
|
78
|
-
});
|
|
78
|
+
}, slow ? "Tool call completed slowly" : "Tool call completed");
|
|
79
79
|
return result;
|
|
80
80
|
} catch (error) {
|
|
81
81
|
const normalized = normalizeBridgeError(error, { safeMessage: () => safeMessage(error, operation.args) });
|
|
@@ -86,7 +86,7 @@ function observabilityMiddleware(observability, logger, safeMessage, slowMs) {
|
|
|
86
86
|
logger.event?.("debug", "tool.call.failed", {
|
|
87
87
|
call_id: shortCallId(operation.context.callId), tool: operation.tool, origin: operation.context.origin,
|
|
88
88
|
duration_ms: durationMs, error_code: code, retryable: normalized.retryable,
|
|
89
|
-
});
|
|
89
|
+
}, "Tool call failed");
|
|
90
90
|
throw normalized;
|
|
91
91
|
}
|
|
92
92
|
};
|
|
@@ -5,6 +5,7 @@ import { runWrangler } from "./shell.mjs";
|
|
|
5
5
|
import { packageRoot, saveState } from "./state.mjs";
|
|
6
6
|
import { withWorkerSecretsFile } from "./worker-secret-file.mjs";
|
|
7
7
|
import {
|
|
8
|
+
normalizeWorkerOrigin,
|
|
8
9
|
retryWorkerHealth,
|
|
9
10
|
workerHealthRequiresRedeploy,
|
|
10
11
|
workerHealthUserReason,
|
|
@@ -52,7 +53,7 @@ export async function ensureWorkerDeployment(state, args = {}, options = {}) {
|
|
|
52
53
|
"--secrets-file", secretFile,
|
|
53
54
|
], { capture: true }));
|
|
54
55
|
|
|
55
|
-
const detectedUrl = extractWorkerUrl(deploy.stdout) || extractWorkerUrl(deploy.stderr);
|
|
56
|
+
const detectedUrl = extractWorkerUrl(deploy.stdout, state.worker.name) || extractWorkerUrl(deploy.stderr, state.worker.name);
|
|
56
57
|
const recordedUrl = workerUrlMatchesName(state.worker.url, state.worker.name) ? state.worker.url : "";
|
|
57
58
|
const workerUrl = detectedUrl || recordedUrl;
|
|
58
59
|
if (!workerUrl) {
|
|
@@ -93,20 +94,24 @@ export function workerDeploymentFingerprint(state, options = {}) {
|
|
|
93
94
|
return fingerprint.digest("hex");
|
|
94
95
|
}
|
|
95
96
|
|
|
96
|
-
export function extractWorkerUrl(text = "") {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
97
|
+
export function extractWorkerUrl(text = "", workerName = "") {
|
|
98
|
+
const candidates = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)]
|
|
99
|
+
.map((match) => match[0].replace(/[),.;:!?]+$/, ""));
|
|
100
|
+
for (const candidate of candidates.reverse()) {
|
|
101
|
+
try {
|
|
102
|
+
return normalizeWorkerOrigin(candidate, workerName);
|
|
103
|
+
} catch {
|
|
104
|
+
// Wrangler output may contain unrelated links; only a canonical matching workers.dev origin is deployment evidence.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return "";
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
export function workerUrlMatchesName(workerUrl, workerName) {
|
|
104
111
|
if (!workerUrl || !workerName) return false;
|
|
105
112
|
try {
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
&& url.hostname.endsWith(".workers.dev")
|
|
109
|
-
&& url.hostname.startsWith(`${String(workerName)}.`);
|
|
113
|
+
normalizeWorkerOrigin(workerUrl, workerName);
|
|
114
|
+
return true;
|
|
110
115
|
} catch {
|
|
111
116
|
return false;
|
|
112
117
|
}
|
|
@@ -53,8 +53,8 @@ export async function retryWorkerHealth(workerUrl, expectedVersion, attempts, op
|
|
|
53
53
|
return last;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
export function
|
|
57
|
-
const base = new URL(
|
|
56
|
+
export function normalizeWorkerOrigin(workerUrl, expectedWorkerName = "") {
|
|
57
|
+
const base = new URL(String(workerUrl));
|
|
58
58
|
const hostname = base.hostname.toLowerCase();
|
|
59
59
|
const expectedName = String(expectedWorkerName || "").toLowerCase();
|
|
60
60
|
if (base.protocol !== "https:" || base.port || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
|
|
@@ -64,7 +64,11 @@ export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
|
|
|
64
64
|
if (expectedName && (!WORKER_NAME.test(expectedName) || hostname.split(".")[0] !== expectedName)) {
|
|
65
65
|
throw new Error("Worker URL hostname does not match the recorded Worker name");
|
|
66
66
|
}
|
|
67
|
-
return `https://${hostname}
|
|
67
|
+
return `https://${hostname}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
|
|
71
|
+
return `${normalizeWorkerOrigin(workerUrl, expectedWorkerName)}/healthz`;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
export function isRetryableWorkerHealthError(value) {
|
package/src/worker/access.ts
CHANGED
|
@@ -11,16 +11,17 @@ const toolAvailability = new Map((toolCatalog as Array<{ name: string; availabil
|
|
|
11
11
|
|
|
12
12
|
export const ACCOUNT_ACCESS_REVISION = Number(accessContract.revision);
|
|
13
13
|
export const ACCOUNT_ROLES = Object.freeze(Object.keys(roles));
|
|
14
|
+
export const DEFAULT_ACCOUNT_ROLE = String(accessContract.defaultRole) as AccountRole;
|
|
14
15
|
export const OWNER_ACCOUNT_ROLE = String(accessContract.ownerRole) as AccountRole;
|
|
15
16
|
|
|
16
17
|
export function normalizeAccountRole(value: unknown): AccountRole | null {
|
|
17
18
|
const role = String(value ?? "").trim().toLowerCase();
|
|
18
|
-
return role
|
|
19
|
+
return Object.hasOwn(roles, role) ? role as AccountRole : null;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export function accountRolePolicy(role: AccountRole): DaemonPolicy {
|
|
22
|
-
const profileName = roles[role]
|
|
23
|
-
const profile = profiles[profileName];
|
|
23
|
+
const profileName = Object.hasOwn(roles, role) ? roles[role].profile : undefined;
|
|
24
|
+
const profile = profileName && Object.hasOwn(profiles, profileName) ? profiles[profileName] : undefined;
|
|
24
25
|
if (!profile) throw new Error(`account role references an unknown policy profile: ${role}`);
|
|
25
26
|
return {
|
|
26
27
|
profile: String(profile.profile),
|
package/src/worker/http.ts
CHANGED
|
@@ -218,9 +218,9 @@ export function searchParamsEntries(params: URLSearchParams): Array<[string, str
|
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
export function searchParamsObject(params: URLSearchParams): Record<string, unknown> {
|
|
221
|
-
const out
|
|
221
|
+
const out = Object.create(null) as Record<string, unknown>;
|
|
222
222
|
params.forEach((value, key) => {
|
|
223
|
-
if (out
|
|
223
|
+
if (!Object.hasOwn(out, key)) out[key] = value;
|
|
224
224
|
else if (Array.isArray(out[key])) (out[key] as string[]).push(value);
|
|
225
225
|
else out[key] = [out[key] as string, value];
|
|
226
226
|
});
|
package/src/worker/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from "./http.ts";
|
|
18
18
|
|
|
19
19
|
const SERVER_NAME = String(serverMetadata.name);
|
|
20
|
-
const SERVER_VERSION = "1.2.
|
|
20
|
+
const SERVER_VERSION = "1.2.2";
|
|
21
21
|
const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
|
|
22
22
|
const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
|
|
23
23
|
const JSONRPC_VERSION = "2.0";
|
|
@@ -220,8 +220,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
220
220
|
return;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
223
|
+
const matched = body.ok === false
|
|
224
|
+
? this.pending.reject(body.id, daemonToolError(body.error), ws)
|
|
225
|
+
: this.pending.resolve(body.id, ws, body.result);
|
|
226
|
+
if (!matched) this.observability.unmatchedResult();
|
|
225
227
|
}
|
|
226
228
|
|
|
227
229
|
async webSocketClose(ws: WebSocket): Promise<void> {
|
|
@@ -269,19 +271,31 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
269
271
|
|
|
270
272
|
const session = await resolveMcpSession(request, body.method, this.oauth.identityKey(), authorized.tokenKey);
|
|
271
273
|
if (session.kind === "invalid") return json(rpcError(body.id, -32001, "MCP session not found"), 404);
|
|
272
|
-
const response = await this.dispatchJsonRpc(
|
|
274
|
+
const response = await this.dispatchJsonRpc(
|
|
275
|
+
body,
|
|
276
|
+
base,
|
|
277
|
+
authorized,
|
|
278
|
+
session.kind === "active" ? session.sessionId : "",
|
|
279
|
+
request.signal,
|
|
280
|
+
);
|
|
273
281
|
if (response === null) return new Response(null, { status: 202 });
|
|
274
282
|
return session.kind === "initialize" ? json(response, 200, { "mcp-session-id": session.sessionId }) : json(response);
|
|
275
283
|
}
|
|
276
284
|
|
|
277
|
-
private async dispatchJsonRpc(
|
|
285
|
+
private async dispatchJsonRpc(
|
|
286
|
+
request: JsonRpcRequest,
|
|
287
|
+
base: string,
|
|
288
|
+
authorized: AuthorizedToken,
|
|
289
|
+
sessionId: string,
|
|
290
|
+
signal?: AbortSignal,
|
|
291
|
+
): Promise<Record<string, unknown> | null> {
|
|
278
292
|
if (request.method === "initialize") {
|
|
279
293
|
const requested = asObject(request.params).protocolVersion;
|
|
280
294
|
const protocolVersion = typeof requested === "string" && MCP_SUPPORTED_PROTOCOL_VERSIONS.includes(requested as typeof MCP_SUPPORTED_PROTOCOL_VERSIONS[number])
|
|
281
295
|
? requested
|
|
282
296
|
: MCP_PROTOCOL_VERSION;
|
|
283
297
|
const bootstrap = this.daemonToolEnabled("session_bootstrap")
|
|
284
|
-
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized).catch(() => null)
|
|
298
|
+
? await this.callDaemonTool("session_bootstrap", { path: "." }, authorized, undefined, signal).catch(() => null)
|
|
285
299
|
: null;
|
|
286
300
|
const localInstructions = sessionInstructionText(bootstrap);
|
|
287
301
|
return rpcResult(request.id, {
|
|
@@ -310,7 +324,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
310
324
|
const name = requiredString(params, "name");
|
|
311
325
|
const args = asObject(params.arguments);
|
|
312
326
|
try {
|
|
313
|
-
const result = await this.callTool(
|
|
327
|
+
const result = await this.callTool(
|
|
328
|
+
name,
|
|
329
|
+
args,
|
|
330
|
+
base,
|
|
331
|
+
authorized,
|
|
332
|
+
mcpClientRequestKey(authorized.tokenKey, sessionId, request.id),
|
|
333
|
+
signal,
|
|
334
|
+
);
|
|
314
335
|
return rpcResult(request.id, textToolResult(result));
|
|
315
336
|
} catch (error) {
|
|
316
337
|
return rpcResult(request.id, textToolResult({ error: publicWorkerToolError(error) }, true));
|
|
@@ -318,7 +339,14 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
318
339
|
}
|
|
319
340
|
return rpcError(request.id, -32601, `Method not found: ${request.method}`);
|
|
320
341
|
}
|
|
321
|
-
private async callTool(
|
|
342
|
+
private async callTool(
|
|
343
|
+
name: string,
|
|
344
|
+
args: Record<string, unknown>,
|
|
345
|
+
base: string,
|
|
346
|
+
authorized: AuthorizedToken,
|
|
347
|
+
requestKey?: string,
|
|
348
|
+
signal?: AbortSignal,
|
|
349
|
+
): Promise<unknown> {
|
|
322
350
|
if (name === "server_info") {
|
|
323
351
|
const { daemon, tools, authorization } = this.authorityContext(authorized);
|
|
324
352
|
return {
|
|
@@ -351,13 +379,19 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
351
379
|
if (workspaceTools.some((tool) => tool.name === name)) {
|
|
352
380
|
if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
|
|
353
381
|
if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
|
|
354
|
-
const result = await this.callDaemonTool(name, args, authorized, requestKey);
|
|
382
|
+
const result = await this.callDaemonTool(name, args, authorized, requestKey, signal);
|
|
355
383
|
return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
|
|
356
384
|
accountVersion: authorized.accountVersion, role: authorized.role }) : result;
|
|
357
385
|
}
|
|
358
386
|
throw new Error(`unknown tool: ${name}`);
|
|
359
387
|
}
|
|
360
|
-
private async callDaemonTool(
|
|
388
|
+
private async callDaemonTool(
|
|
389
|
+
name: string,
|
|
390
|
+
args: Record<string, unknown>,
|
|
391
|
+
authorized: AuthorizedToken,
|
|
392
|
+
requestKey?: string,
|
|
393
|
+
signal?: AbortSignal,
|
|
394
|
+
): Promise<unknown> {
|
|
361
395
|
const socket = this.daemonSockets()[0];
|
|
362
396
|
if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
|
|
363
397
|
const id = randomToken("call");
|
|
@@ -374,6 +408,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
374
408
|
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
375
409
|
return new WorkerToolError("timeout", `daemon tool timed out: ${name}`, true);
|
|
376
410
|
},
|
|
411
|
+
signal,
|
|
412
|
+
onAbort: (record) => {
|
|
413
|
+
sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
|
|
414
|
+
return new WorkerToolError("cancelled", "MCP client stopped waiting for the tool result");
|
|
415
|
+
},
|
|
377
416
|
});
|
|
378
417
|
} catch (error) {
|
|
379
418
|
if (error instanceof PendingCallRegistrationError) {
|
|
@@ -382,13 +421,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
|
|
|
382
421
|
throw error;
|
|
383
422
|
}
|
|
384
423
|
this.observability.callStarted(name);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
424
|
+
if (!signal?.aborted) {
|
|
425
|
+
try {
|
|
426
|
+
socket.send(JSON.stringify({
|
|
427
|
+
type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
|
|
428
|
+
authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
|
|
429
|
+
}));
|
|
430
|
+
} catch {
|
|
431
|
+
this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
|
|
432
|
+
}
|
|
392
433
|
}
|
|
393
434
|
try {
|
|
394
435
|
const value = await result;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type AccountRole } from "./access.ts";
|
|
1
|
+
import { DEFAULT_ACCOUNT_ROLE, normalizeAccountRole, type AccountRole } from "./access.ts";
|
|
2
2
|
import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin.ts";
|
|
3
3
|
import { exchangeOAuthToken } from "./oauth-tokens.ts";
|
|
4
4
|
import {
|
|
@@ -50,12 +50,21 @@ export class OAuthController {
|
|
|
50
50
|
private async oauthStore(): Promise<OAuthStore> {
|
|
51
51
|
const raw = await this.ctx.storage.get<unknown>("oauth");
|
|
52
52
|
if (raw !== undefined && !isCurrentOAuthStore(raw)) {
|
|
53
|
-
throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state
|
|
53
|
+
throw new HttpError(503, "oauth_state_schema_mismatch", "OAuth state does not match the current schema");
|
|
54
54
|
}
|
|
55
55
|
const store = isCurrentOAuthStore(raw) ? raw : emptyOAuthStore();
|
|
56
56
|
let changed = false;
|
|
57
57
|
const now = Math.floor(Date.now() / 1000);
|
|
58
58
|
|
|
59
|
+
for (const account of Object.values(store.accounts)) {
|
|
60
|
+
if (normalizeAccountRole(account.role)) continue;
|
|
61
|
+
account.role = DEFAULT_ACCOUNT_ROLE;
|
|
62
|
+
account.active = false;
|
|
63
|
+
account.version = Number.isInteger(account.version) && account.version > 0 ? account.version + 1 : 1;
|
|
64
|
+
account.updated_at = now;
|
|
65
|
+
changed = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
59
68
|
for (const [code, value] of Object.entries(store.codes)) {
|
|
60
69
|
const account = store.accounts[value.account_id];
|
|
61
70
|
if (value.expires_at <= now || !account || !account.active || account.version !== value.account_version || account.role !== value.role) {
|
|
@@ -5,7 +5,7 @@ const SENSITIVE_FIELD = /(?:authorization|cookie|credential|password|secret|toke
|
|
|
5
5
|
export class WorkerObservability {
|
|
6
6
|
private readonly startedAt = performance.now();
|
|
7
7
|
private readonly requests = { total: 0, successful: 0, client_error: 0, server_error: 0 };
|
|
8
|
-
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0 };
|
|
8
|
+
private readonly calls = { started: 0, completed: 0, failed: 0, cancelled: 0, timed_out: 0, unmatched_results: 0 };
|
|
9
9
|
private readonly sockets = { candidates: 0, authenticated: 0, disconnected: 0, protocol_errors: 0 };
|
|
10
10
|
private readonly errors = new Map<string, number>();
|
|
11
11
|
private readonly tools = new Map<string, { started: number; completed: number; failed: number; active: number }>();
|
|
@@ -39,6 +39,8 @@ export class WorkerObservability {
|
|
|
39
39
|
this.incrementError(code);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
unmatchedResult(): void { this.calls.unmatched_results += 1; }
|
|
43
|
+
|
|
42
44
|
socketCandidate(): void { this.sockets.candidates += 1; }
|
|
43
45
|
socketAuthenticated(): void { this.sockets.authenticated += 1; }
|
|
44
46
|
socketDisconnected(): void { this.sockets.disconnected += 1; }
|
|
@@ -19,6 +19,8 @@ export interface PendingCallRecord {
|
|
|
19
19
|
timeout: ReturnType<typeof setTimeout>;
|
|
20
20
|
resolve: (value: unknown) => void;
|
|
21
21
|
reject: (error: Error) => void;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
abortHandler?: () => void;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
interface RegisterPendingCall {
|
|
@@ -28,6 +30,8 @@ interface RegisterPendingCall {
|
|
|
28
30
|
tool: string;
|
|
29
31
|
timeoutMs: number;
|
|
30
32
|
onTimeout: (record: PendingCallRecord) => Error;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
onAbort?: (record: PendingCallRecord) => Error;
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export class PendingCallRegistry {
|
|
@@ -63,6 +67,14 @@ export class PendingCallRegistry {
|
|
|
63
67
|
catch { error = new Error("pending daemon call timed out"); }
|
|
64
68
|
reject(error instanceof Error ? error : new Error("pending daemon call timed out"));
|
|
65
69
|
}, input.timeoutMs);
|
|
70
|
+
const abortHandler = () => {
|
|
71
|
+
const record = this.take(input.id);
|
|
72
|
+
if (!record) return;
|
|
73
|
+
let error: unknown;
|
|
74
|
+
try { error = input.onAbort?.(record); }
|
|
75
|
+
catch { error = new Error("pending daemon call was cancelled"); }
|
|
76
|
+
reject(error instanceof Error ? error : new Error("pending daemon call was cancelled"));
|
|
77
|
+
};
|
|
66
78
|
const record: PendingCallRecord = {
|
|
67
79
|
id: input.id,
|
|
68
80
|
socket: input.socket,
|
|
@@ -72,9 +84,13 @@ export class PendingCallRegistry {
|
|
|
72
84
|
timeout,
|
|
73
85
|
resolve,
|
|
74
86
|
reject,
|
|
87
|
+
signal: input.signal,
|
|
88
|
+
abortHandler: input.signal ? abortHandler : undefined,
|
|
75
89
|
};
|
|
76
90
|
this.byId.set(input.id, record);
|
|
77
91
|
if (input.clientRequestKey) this.byRequestKey.set(input.clientRequestKey, input.id);
|
|
92
|
+
if (input.signal?.aborted) abortHandler();
|
|
93
|
+
else input.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
78
94
|
});
|
|
79
95
|
}
|
|
80
96
|
|
|
@@ -131,6 +147,7 @@ export class PendingCallRegistry {
|
|
|
131
147
|
const record = this.byId.get(id);
|
|
132
148
|
if (!record) return undefined;
|
|
133
149
|
clearTimeout(record.timeout);
|
|
150
|
+
if (record.signal && record.abortHandler) record.signal.removeEventListener("abort", record.abortHandler);
|
|
134
151
|
this.byId.delete(id);
|
|
135
152
|
if (record.clientRequestKey && this.byRequestKey.get(record.clientRequestKey) === id) {
|
|
136
153
|
this.byRequestKey.delete(record.clientRequestKey);
|
package/wrangler.jsonc
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "machine-bridge-mcp",
|
|
4
4
|
"main": "src/worker/index.ts",
|
|
5
5
|
"compatibility_date": "2026-07-11",
|
|
6
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
6
|
+
"compatibility_flags": ["nodejs_compat", "enable_request_signal", "request_signal_passthrough"],
|
|
7
7
|
"workers_dev": true,
|
|
8
8
|
"durable_objects": {
|
|
9
9
|
"bindings": [
|