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.
Files changed (39) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/browser-extension/browser-operations.js +9 -3
  3. package/browser-extension/devtools-input.js +2 -2
  4. package/browser-extension/manifest.json +2 -2
  5. package/docs/ARCHITECTURE.md +6 -6
  6. package/docs/AUDIT.md +26 -0
  7. package/docs/ENGINEERING.md +3 -0
  8. package/docs/LOGGING.md +6 -2
  9. package/docs/OPERATIONS.md +5 -3
  10. package/docs/PROJECT_STANDARDS.md +1 -0
  11. package/docs/TESTING.md +6 -2
  12. package/docs/UPGRADING.md +6 -2
  13. package/package.json +4 -3
  14. package/scripts/coverage-check.mjs +3 -1
  15. package/src/local/account-access.mjs +7 -5
  16. package/src/local/call-registry.mjs +9 -3
  17. package/src/local/cli-local-admin.mjs +74 -38
  18. package/src/local/cli-options.mjs +18 -18
  19. package/src/local/cli-policy.mjs +1 -1
  20. package/src/local/cli-service.mjs +132 -0
  21. package/src/local/cli.mjs +24 -106
  22. package/src/local/log.mjs +10 -2
  23. package/src/local/managed-job-plan.mjs +3 -3
  24. package/src/local/policy.mjs +11 -6
  25. package/src/local/project-package.mjs +1 -1
  26. package/src/local/relay-connection.mjs +21 -0
  27. package/src/local/resource-operations.mjs +1 -1
  28. package/src/local/runtime-reporting.mjs +1 -1
  29. package/src/local/runtime.mjs +65 -27
  30. package/src/local/tool-executor.mjs +3 -3
  31. package/src/local/worker-deployment.mjs +15 -10
  32. package/src/local/worker-health.mjs +7 -3
  33. package/src/worker/access.ts +4 -3
  34. package/src/worker/http.ts +2 -2
  35. package/src/worker/index.ts +58 -17
  36. package/src/worker/oauth-controller.ts +11 -2
  37. package/src/worker/observability.ts +3 -1
  38. package/src/worker/pending-calls.ts +17 -0
  39. package/wrangler.jsonc +1 -1
@@ -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.cancelCall(message.id, "remote cancellation");
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.event?.("warn", "relay.tool_call.invalid", { has_call_id: Boolean(envelope.id) });
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
- let response;
361
+ this.activeRelayCalls.add(envelope.id);
352
362
  try {
353
- const result = await this.executeTool(envelope.tool, envelope.arguments, {
354
- callId: envelope.id,
355
- origin: "relay",
356
- timeoutMs: envelope.timeoutMs,
357
- authorization: envelope.authorization,
358
- });
359
- response = { type: "tool_result", id: envelope.id, ok: true, result };
360
- } catch (error) {
361
- response = { type: "tool_result", id: envelope.id, ok: false, error: publicError(error) };
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
- if (this.send(response)) return true;
368
- this.logger.event?.("warn", "relay.tool_result.delivery_failed", { call_id: shortCallId(response?.id) });
369
- this.relay?.interrupt?.("relay_transport_error");
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 matches = [...String(text).matchAll(/https:\/\/[^\s"'<>]+\.workers\.dev[^\s"'<>]*/g)];
98
- if (matches.length) return matches.at(-1)[0].replace(/[),.]+$/, "");
99
- const anyHttps = [...String(text).matchAll(/https:\/\/[^\s"'<>]+/g)];
100
- return anyHttps.find(match => /workers\.dev|\/healthz|\/mcp/.test(match[0]))?.[0]?.replace(/[),.]+$/, "") || "";
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
- const url = new URL(String(workerUrl));
107
- return url.protocol === "https:"
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 workerHealthUrl(workerUrl, expectedWorkerName = "") {
57
- const base = new URL(`${String(workerUrl).replace(/\/+$/, "")}/`);
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}/healthz`;
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) {
@@ -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 in roles ? role as AccountRole : null;
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]?.profile;
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),
@@ -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: Record<string, unknown> = {};
221
+ const out = Object.create(null) as Record<string, unknown>;
222
222
  params.forEach((value, key) => {
223
- if (out[key] === undefined) out[key] = value;
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
  });
@@ -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.0";
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
- if (body.ok === false) this.pending.reject(body.id, daemonToolError(body.error), ws);
224
- else this.pending.resolve(body.id, ws, body.result);
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(body, base, authorized, session.kind === "active" ? session.sessionId : "");
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(request: JsonRpcRequest, base: string, authorized: AuthorizedToken, sessionId: string): Promise<Record<string, unknown> | null> {
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(name, args, base, authorized, mcpClientRequestKey(authorized.tokenKey, sessionId, request.id));
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(name: string, args: Record<string, unknown>, base: string, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
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(name: string, args: Record<string, unknown>, authorized: AuthorizedToken, requestKey?: string): Promise<unknown> {
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
- try {
386
- socket.send(JSON.stringify({
387
- type: "tool_call", id, tool: name, arguments: args, timeout_ms: timeoutMs,
388
- authorization: { account_id: authorized.accountId, account_version: authorized.accountVersion, role: authorized.role },
389
- }));
390
- } catch {
391
- this.pending.reject(id, new WorkerToolError("network_error", "failed to send daemon tool call", true), socket);
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 requires the one-time multi-account upgrade");
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": [