machine-bridge-mcp 3.0.0-beta.20 → 3.0.0-beta.21

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 (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +4 -4
  4. package/browser-extension/manifest.json +1 -1
  5. package/docs/ARCHITECTURE.md +11 -9
  6. package/docs/AUDIT.md +16 -0
  7. package/docs/ENGINEERING.md +1 -1
  8. package/docs/LOCAL_AUTHORIZATION.md +1 -1
  9. package/docs/LOGGING.md +3 -1
  10. package/docs/MULTI_ACCOUNT.md +1 -1
  11. package/docs/OPERATIONS.md +5 -3
  12. package/docs/OVERVIEW.md +1 -1
  13. package/docs/PRIVACY.md +1 -1
  14. package/docs/TESTING.md +9 -7
  15. package/docs/THREAT_MODEL.md +3 -2
  16. package/docs/UPGRADING.md +7 -1
  17. package/package.json +1 -1
  18. package/src/local/process-tree-ownership.mjs +17 -5
  19. package/src/local/process-tree.mjs +6 -2
  20. package/src/local/relay-connection.mjs +24 -11
  21. package/src/local/runtime-activation.mjs +16 -0
  22. package/src/local/runtime.mjs +3 -1
  23. package/src/local/worker-deployment.mjs +16 -6
  24. package/src/worker/daemon-socket-attachment.ts +52 -0
  25. package/src/worker/daemon-sockets.ts +17 -48
  26. package/src/worker/durable-stream-calls.ts +129 -0
  27. package/src/worker/durable-stream-result.ts +22 -0
  28. package/src/worker/index.ts +120 -88
  29. package/src/worker/mcp-pending-call-expiry.ts +20 -0
  30. package/src/worker/mcp-pending-call-inspection.ts +33 -0
  31. package/src/worker/mcp-pending-call-records.ts +58 -0
  32. package/src/worker/mcp-pending-call-storage.ts +18 -0
  33. package/src/worker/mcp-pending-call-store.ts +285 -0
  34. package/src/worker/mcp-resumption-index.ts +41 -0
  35. package/src/worker/mcp-resumption-records.ts +8 -2
  36. package/src/worker/mcp-resumption.ts +80 -87
  37. package/src/worker/mcp-stream-dispatch.ts +42 -54
  38. package/src/worker/pending-admission.ts +15 -0
  39. package/src/worker/pending-call-contract.ts +4 -7
  40. package/src/worker/pending-calls.ts +4 -10
  41. package/src/worker/runtime-alarm-storage.ts +35 -0
  42. package/src/worker/runtime-alarm.ts +29 -39
  43. package/src/worker/websocket-protocol.ts +4 -0
  44. package/src/worker/worker-entry.ts +50 -0
@@ -2,6 +2,8 @@ import { DurableObject } from "cloudflare:workers";
2
2
  import serverMetadata from "../shared/server-metadata.json" with { type: "json" };
3
3
  import relayContract from "../shared/relay-contract.json" with { type: "json" };
4
4
  import { PendingCallRegistrationError, PendingCallRegistry } from "./pending-calls.ts";
5
+ import { PendingAdmissionGate } from "./pending-admission.ts";
6
+ import type { PendingCallOutcome } from "./pending-call-contract.ts";
5
7
  import {
6
8
  DAEMON_LIVENESS_TIMEOUT_MS,
7
9
  DAEMON_READY_TIMEOUT_MS,
@@ -18,11 +20,12 @@ import { authorizeMcpRequest } from "./mcp-access.ts";
18
20
  import { handleMcpResumptionRequest } from "./mcp-resumption-http.ts";
19
21
  import {
20
22
  handleMcpStreamSubscribeRequest, mcpStreamDescriptorResponse, mcpStreamProxyMode,
21
- proxyMcpEventStream, sanitizeBridgeRequest,
22
23
  } from "./mcp-stream-proxy.ts";
23
24
  import { McpResumptionStore, McpStreamLimitError } from "./mcp-resumption.ts";
24
25
  import { McpStreamChannel } from "./mcp-stream-channel.ts";
25
26
  import { buildServerInfoResult, persistImmediateStreamOutcome, startEventDrivenStreamCall } from "./mcp-stream-dispatch.ts";
27
+ import { DurableStreamCallCoordinator } from "./durable-stream-calls.ts";
28
+ import { handleOuterWorkerFetch } from "./worker-entry.ts";
26
29
  import { daemonToolTimeoutMs } from "./tool-timeout.ts";
27
30
  import { WorkerObservability } from "./observability.ts";
28
31
  import { daemonToolError, publicWorkerToolError, WorkerToolError } from "./errors.ts";
@@ -36,23 +39,18 @@ import {
36
39
  HttpError, applyCors, baseUrl, corsPreflight, json, methodNotAllowed,
37
40
  parseJsonRequest, workerErrorClass,
38
41
  } from "./http.ts";
39
- import { respondWithoutDurableObject } from "./worker-static-routes.ts";
40
42
  import { authorizationServerMetadata } from "./worker-metadata.ts";
41
- import { createThrottledEdgeLogger } from "./worker-edge-log.ts";
42
- import {
43
- admitStatefulRequest, durableObjectQuotaResponse, isDurableObjectQuotaError,
44
- outerWorkerErrorClass, workerGatewayErrorResponse,
45
- } from "./worker-edge-guard.ts";
46
43
  import {
47
44
  asObject, isJsonRpcRequest, isJsonRpcResponse, requiredString, rpcError, rpcResult,
48
45
  sessionInstructionText, textToolResult, validateProtocolVersionHeader, type JsonRpcRequest,
49
46
  } from "./mcp-jsonrpc.ts";
50
47
  import {
51
- closeWebSocketQuietly, isObjectRecord, rejectDaemonMessage, sendWebSocketQuietly, trySendWebSocket,
48
+ closeWebSocketQuietly, daemonErrorCloseCode, isObjectRecord, rejectDaemonMessage,
49
+ sendWebSocketQuietly, trySendWebSocket,
52
50
  } from "./websocket-protocol.ts";
53
51
 
54
52
  const SERVER_NAME = String(serverMetadata.name);
55
- const SERVER_VERSION = "3.0.0-beta.20";
53
+ const SERVER_VERSION = "3.0.0-beta.21";
56
54
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
57
55
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
58
56
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
@@ -60,7 +58,6 @@ const MAX_BODY_BYTES = 16 * 1024 * 1024;
60
58
  const MAX_PENDING_CALLS = 32;
61
59
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
62
60
  const DAEMON_RECONNECT_GRACE_MS = relayContract.reconnectGraceMs;
63
- const logOuterFetchFailure = createThrottledEdgeLogger();
64
61
 
65
62
  interface BridgeEnv extends OAuthControllerEnv {
66
63
  BRIDGE: DurableObjectNamespace<BridgeRoom>;
@@ -80,6 +77,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
80
77
  private readonly daemonRegistry: DaemonSocketRegistry;
81
78
  private readonly streamChannel: McpStreamChannel;
82
79
  private readonly resumption: McpResumptionStore;
80
+ private readonly durableCalls: DurableStreamCallCoordinator;
81
+ private readonly pendingAdmission = new PendingAdmissionGate();
83
82
 
84
83
  constructor(ctx: DurableObjectState, env: BridgeEnv) {
85
84
  super(ctx, env);
@@ -95,10 +94,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
95
94
  (streamId, message) => this.streamChannel.publish(streamId, message),
96
95
  (rows) => this.observability.streamStorageRowsWritten(rows),
97
96
  );
97
+ this.durableCalls = new DurableStreamCallCoordinator(
98
+ this.resumption.calls,
99
+ this.daemonRegistry,
100
+ this.observability,
101
+ MAX_PENDING_CALLS,
102
+ (socket, message, closeReason, errorCode) => this.invalidateDaemonSocket(socket, message, closeReason, errorCode),
103
+ );
98
104
  }
99
105
 
100
106
  async fetch(request: Request): Promise<Response> {
101
- await this.pending.expireDue();
107
+ await this.expireOverdueCalls();
102
108
  const base = baseUrl(request);
103
109
  const extraOrigins = this.env.MBM_ALLOWED_ORIGINS ?? "";
104
110
  let response: Response;
@@ -141,7 +147,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
141
147
  }
142
148
 
143
149
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
144
- await this.pending.expireDue();
150
+ await this.expireOverdueCalls();
145
151
  if (this.streamChannel.isSubscriber(ws)) {
146
152
  this.streamChannel.rejectSubscriberMessage(ws);
147
153
  return;
@@ -231,10 +237,16 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
231
237
  const daemonPolicy = sanitizeDaemonPolicy(body.policy);
232
238
  const authenticatedAt = new Date().toISOString();
233
239
  const probeId = randomToken("probe");
240
+ const connectionId = socketAttachment.connectionId ?? "";
241
+ if (!connectionId) {
242
+ rejectDaemonMessage(ws, "missing_daemon_connection_identity", 1008, "daemon connection identity missing");
243
+ return;
244
+ }
234
245
  this.daemonRegistry.beginProbe(ws, {
235
246
  connectedAt: authenticatedAt,
236
247
  probeId,
237
248
  instanceId,
249
+ connectionId,
238
250
  policy: daemonPolicy,
239
251
  tools: sanitizeDaemonTools(body.tools, daemonPolicy),
240
252
  });
@@ -277,9 +289,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
277
289
  return;
278
290
  }
279
291
  const daemonInstanceId = readyAttachment.instanceId ?? "";
292
+ const connectionId = readyAttachment.connectionId ?? "";
293
+ if (!daemonInstanceId || !connectionId) {
294
+ await this.invalidateDaemonSocket(ws, "daemon readiness identity is incomplete", "daemon ready timeout", "daemon_ready_timeout");
295
+ return;
296
+ }
280
297
  const previousSockets = this.daemonRegistry.readyRoleSockets().filter((socket) => socket !== ws);
281
298
  const fallbackSocket = previousSockets.find((socket) => this.daemonRegistry.readyAttachment(socket)?.instanceId === daemonInstanceId);
282
- const reboundCallIds = this.pending.rebindInstance(daemonInstanceId, ws);
299
+ const reboundCallIds = [
300
+ ...this.pending.rebindInstance(daemonInstanceId, ws),
301
+ ...await this.durableCalls.rebind(daemonInstanceId, connectionId),
302
+ ];
283
303
  if (reboundCallIds.length > 0) {
284
304
  this.observability.event("info", "daemon.calls.rebound", { rebound_calls: reboundCallIds.length });
285
305
  }
@@ -288,7 +308,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
288
308
  ws.send(JSON.stringify({ type: "ready_ack", server: SERVER_NAME, version: SERVER_VERSION }));
289
309
  } catch {
290
310
  await this.invalidateDaemonSocket(ws, "daemon readiness acknowledgement failed", "daemon ready timeout", "daemon_ready_timeout");
291
- if (fallbackSocket?.readyState === WebSocket.OPEN) this.pending.rebindInstance(daemonInstanceId, fallbackSocket);
311
+ if (fallbackSocket?.readyState === WebSocket.OPEN) {
312
+ this.pending.rebindInstance(daemonInstanceId, fallbackSocket);
313
+ const fallbackConnectionId = this.daemonRegistry.readyAttachment(fallbackSocket)?.connectionId ?? "";
314
+ if (fallbackConnectionId) await this.durableCalls.rebind(daemonInstanceId, fallbackConnectionId);
315
+ }
292
316
  await this.scheduleRuntimeAlarm();
293
317
  return;
294
318
  }
@@ -313,10 +337,17 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
313
337
  }
314
338
 
315
339
  await this.touchDaemonSocket(ws);
316
- const matched = body.ok === false
317
- ? await this.pending.reject(body.id, daemonToolError(body.error), ws)
318
- : await this.pending.resolve(body.id, ws, body.result);
340
+ const outcome: PendingCallOutcome = body.ok === false
341
+ ? { ok: false, error: daemonToolError(body.error) }
342
+ : { ok: true, value: body.result };
343
+ let matched = outcome.ok
344
+ ? await this.pending.resolve(body.id, ws, outcome.value)
345
+ : await this.pending.reject(body.id, outcome.error, ws);
346
+ if (!matched && socketAttachment.connectionId) {
347
+ matched = await this.durableCalls.settle(body.id, socketAttachment.connectionId, outcome);
348
+ }
319
349
  if (!matched) this.observability.unmatchedResult();
350
+ else await this.scheduleRuntimeAlarm();
320
351
  }
321
352
 
322
353
  async webSocketClose(ws: WebSocket): Promise<void> {
@@ -379,20 +410,21 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
379
410
  if (body.method === "tools/call" && acceptsEventStream(request)) {
380
411
  if (proxyMode !== "prepare") return json(rpcError(body.id, -32603, "MCP stream proxy is unavailable"), 500);
381
412
  if (body.id === undefined || body.id === null) return json(rpcError(null, -32600, "tools/call requires a non-null request id"), 400);
413
+ const requestId = body.id;
382
414
  const streamId = randomToken("stream");
383
415
  try {
384
416
  await this.resumption.begin({
385
417
  streamId,
386
418
  tokenKey: access.authorized.tokenKey,
387
419
  sessionId,
388
- requestId: body.id,
420
+ requestId,
389
421
  });
390
422
  } catch (error) {
391
423
  if (error instanceof McpStreamLimitError) {
392
- return json(rpcError(body.id, -32004, error.message), 429);
424
+ return json(rpcError(requestId, -32004, error.message), 429);
393
425
  }
394
426
  this.observability.event("error", "mcp.stream.begin.failed", { error_class: workerErrorClass(error) });
395
- return json(rpcError(body.id, -32603, "Resumable stream storage is unavailable"), 503);
427
+ return json(rpcError(requestId, -32603, "Resumable stream storage is unavailable"), 503);
396
428
  }
397
429
 
398
430
  const params = asObject(body.params);
@@ -401,38 +433,40 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
401
433
  try {
402
434
  if (name === "server_info") {
403
435
  await persistImmediateStreamOutcome({
404
- resumption: this.resumption, observability: this.observability, streamId, requestId: body.id,
405
- outcome: { ok: true, value: this.serverInfoResult(base, access.authorized) },
436
+ resumption: this.resumption, observability: this.observability, streamId, requestId,
437
+ outcome: { ok: true, value: await this.serverInfoResult(base, access.authorized) },
406
438
  });
407
439
  } else {
408
440
  if (!workspaceTools.some((tool) => tool.name === name)) throw new Error(`unknown tool: ${name}`);
409
- if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
410
441
  if (!accountRoleAllowsTool(access.authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
411
442
  this.reclaimStaleDaemonSockets();
412
443
  const socket = this.daemonRegistry.readySockets()[0];
413
444
  if (!socket) throw new WorkerToolError("unavailable", "local daemon is not connected; keep the CLI start command running", true);
414
- const daemonInstanceId = this.daemonRegistry.readyAttachment(socket)?.instanceId ?? "";
415
- if (!daemonInstanceId) throw new WorkerToolError("unavailable", "local daemon connection is missing its instance identity", true);
416
- await startEventDrivenStreamCall({
417
- pending: this.pending, resumption: this.resumption, observability: this.observability,
418
- streamId, requestId: body.id, clientRequestKey: mcpClientRequestKey(access.authorized.tokenKey, sessionId, body.id),
419
- tool: name, arguments: args, socket, daemonInstanceId, timeoutMs: daemonToolTimeoutMs(name, args),
445
+ const attachment = this.daemonRegistry.readyAttachment(socket);
446
+ const daemonInstanceId = attachment?.instanceId ?? "";
447
+ const connectionId = attachment?.connectionId ?? "";
448
+ if (!daemonInstanceId || !connectionId) throw new WorkerToolError("unavailable", "local daemon connection is missing its relay identity", true);
449
+ if (!attachment?.tools?.includes(name)) throw new WorkerToolError("authorization_denied", `tool disabled by local daemon policy: ${name}`);
450
+ await this.pendingAdmission.run(() => startEventDrivenStreamCall({
451
+ resumption: this.resumption, observability: this.observability,
452
+ streamId, requestId, clientRequestKey: mcpClientRequestKey(access.authorized.tokenKey, sessionId, requestId),
453
+ tool: name, arguments: args, socket, daemonInstanceId, connectionId, timeoutMs: daemonToolTimeoutMs(name, args),
454
+ transientActiveCount: this.pending.size, maximumPendingCalls: MAX_PENDING_CALLS,
420
455
  authorization: {
421
456
  account_id: access.authorized.accountId, account_version: access.authorized.accountVersion,
422
457
  client_id: access.authorized.clientId, family_id: access.authorized.familyId, role: access.authorized.role,
423
458
  },
424
- onTimeout: (record) => this.daemonCallTimeout(record, name),
459
+ transform: name === "project_overview" ? {
460
+ kind: "project_overview", account_id: access.authorized.accountId,
461
+ account_version: access.authorized.accountVersion, role: access.authorized.role,
462
+ } : undefined,
425
463
  onSendFailure: () => this.invalidateDaemonSocket(socket, "failed to send daemon tool call", "daemon send failed"),
426
- transformResult: name === "project_overview"
427
- ? (value) => decorateProjectOverview(value, { accountId: access.authorized.accountId,
428
- accountVersion: access.authorized.accountVersion, role: access.authorized.role })
429
- : undefined,
430
- });
464
+ }));
431
465
  await this.scheduleRuntimeAlarm();
432
466
  }
433
467
  } catch (error) {
434
468
  await persistImmediateStreamOutcome({
435
- resumption: this.resumption, observability: this.observability, streamId, requestId: body.id,
469
+ resumption: this.resumption, observability: this.observability, streamId, requestId,
436
470
  outcome: { ok: false, error: error instanceof Error ? error : new Error("streamed tool call failed") },
437
471
  });
438
472
  }
@@ -505,12 +539,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
505
539
  }
506
540
  return rpcError(request.id, -32601, `Method not found: ${request.method}`);
507
541
  }
508
- private serverInfoResult(base: string, authorized: AuthorizedToken): Record<string, unknown> {
509
- const { daemon, tools, authorization } = this.authorityContext(authorized);
542
+ private async serverInfoResult(base: string, authorized: AuthorizedToken): Promise<Record<string, unknown>> {
543
+ const { daemon, effectiveTools, advertisedTools, authorization } = this.authorityContext(authorized);
510
544
  return buildServerInfoResult({
511
545
  serverName: SERVER_NAME, serverVersion: SERVER_VERSION, base,
512
- oauth: authorizationServerMetadata(base, SERVER_NAME), authorization, daemon, tools,
513
- pending: this.pending, daemonRegistry: this.daemonRegistry, observability: this.observability,
546
+ oauth: authorizationServerMetadata(base, SERVER_NAME), authorization, daemon,
547
+ effectiveTools, advertisedTools, pendingSnapshot: await this.durableCalls.snapshot(this.pending.snapshot()),
548
+ daemonRegistry: this.daemonRegistry, observability: this.observability,
514
549
  });
515
550
  }
516
551
  private async callTool(
@@ -522,7 +557,6 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
522
557
  ): Promise<unknown> {
523
558
  if (name === "server_info") return this.serverInfoResult(base, authorized);
524
559
  if (workspaceTools.some((tool) => tool.name === name)) {
525
- if (!this.daemonToolEnabled(name)) throw new Error(`tool disabled by local daemon policy: ${name}`);
526
560
  if (!accountRoleAllowsTool(authorized.role, name)) throw new WorkerToolError("authorization_denied", "tool is not allowed for this account role");
527
561
  const result = await this.callDaemonTool(name, args, authorized, requestKey);
528
562
  return name === "project_overview" ? decorateProjectOverview(result, { accountId: authorized.accountId,
@@ -542,18 +576,25 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
542
576
  const daemonAttachment = this.daemonRegistry.readyAttachment(socket);
543
577
  const daemonInstanceId = daemonAttachment?.instanceId ?? "";
544
578
  if (!daemonInstanceId) throw new WorkerToolError("unavailable", "local daemon connection is missing its instance identity", true);
579
+ if (!daemonAttachment?.tools?.includes(name)) throw new WorkerToolError("authorization_denied", `tool disabled by local daemon policy: ${name}`);
545
580
  const id = randomToken("call");
546
581
  const timeoutMs = daemonToolTimeoutMs(name, args);
547
- let result: Promise<unknown>;
582
+ let result!: Promise<unknown>;
548
583
  try {
549
- result = this.pending.register({
550
- id,
551
- socket,
552
- daemonInstanceId,
553
- clientRequestKey: requestKey,
554
- tool: name,
555
- timeoutMs,
556
- onTimeout: (record) => this.daemonCallTimeout(record, name),
584
+ await this.pendingAdmission.run(async () => {
585
+ const durableActive = await this.resumption.calls.activeCount();
586
+ if (this.pending.size + durableActive >= MAX_PENDING_CALLS) {
587
+ throw new WorkerToolError("limit_exceeded", "too many concurrent daemon tool calls", true);
588
+ }
589
+ result = this.pending.register({
590
+ id,
591
+ socket,
592
+ daemonInstanceId,
593
+ clientRequestKey: requestKey,
594
+ tool: name,
595
+ timeoutMs,
596
+ onTimeout: (record) => this.daemonCallTimeout(record, name),
597
+ });
557
598
  });
558
599
  } catch (error) {
559
600
  if (error instanceof PendingCallRegistrationError) {
@@ -598,10 +639,15 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
598
639
 
599
640
  private async cancelClientRequest(requestKey?: string): Promise<void> {
600
641
  if (!requestKey) return;
601
- await this.pending.cancelRequest(requestKey, (record) => {
642
+ const cancelledTransient = await this.pending.cancelRequest(requestKey, (record) => {
602
643
  if (record.socket) sendWebSocketQuietly(record.socket, { type: "cancel_call", id: record.id });
603
644
  return new WorkerToolError("cancelled", "tool call cancelled by client");
604
645
  });
646
+ if (cancelledTransient) {
647
+ await this.scheduleRuntimeAlarm();
648
+ return;
649
+ }
650
+ if (await this.durableCalls.cancel(requestKey)) await this.scheduleRuntimeAlarm();
605
651
  }
606
652
  private async acceptDaemonWebSocket(request: Request): Promise<Response> {
607
653
  if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") return new Response("Expected Upgrade: websocket", { status: 426 });
@@ -627,7 +673,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
627
673
  const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
628
674
  this.ctx.acceptWebSocket(server);
629
675
  this.observability.socketCandidate();
630
- this.daemonRegistry.beginCandidate(server, challenge, preflight);
676
+ this.daemonRegistry.beginCandidate(server, challenge, preflight, randomToken("connection"));
631
677
  await this.scheduleRuntimeAlarm();
632
678
  const welcomed = trySendWebSocket(server, {
633
679
  type: "welcome",
@@ -650,16 +696,20 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
650
696
  }
651
697
 
652
698
  private allTools(role: AccountRole): Array<Record<string, unknown>> {
653
- const advertised = accountRoleToolNames(role, this.daemonAdvertisedTools());
699
+ const advertised = accountRoleToolNames(role, workspaceTools.map((tool) => tool.name));
654
700
  const localTools = workspaceTools.filter((tool) => advertised.has(tool.name));
655
701
  return [serverInfoTool, ...localTools].map((tool) => structuredClone(tool));
656
702
  }
703
+ private effectiveToolNames(role: AccountRole): string[] {
704
+ return ["server_info", ...accountRoleToolNames(role, this.daemonAdvertisedTools())];
705
+ }
657
706
  private authorityContext(authorized: AuthorizedToken) {
658
707
  const daemon = describeDaemonCeiling(this.daemonStatus(true));
659
- const tools = this.allTools(authorized.role).map((tool) => String(tool.name));
708
+ const advertisedTools = this.allTools(authorized.role).map((tool) => String(tool.name));
709
+ const effectiveTools = this.effectiveToolNames(authorized.role);
660
710
  const authorization = accountAuthoritySnapshot({ accountId: authorized.accountId, accountVersion: authorized.accountVersion,
661
- role: authorized.role, daemonPolicy: daemon.policy, effectiveTools: tools });
662
- return { daemon, tools, authorization };
711
+ role: authorized.role, daemonPolicy: daemon.policy, effectiveTools });
712
+ return { daemon, effectiveTools, advertisedTools, authorization };
663
713
  }
664
714
  private daemonToolEnabled(name: string): boolean {
665
715
  return this.daemonAdvertisedTools().has(name);
@@ -687,20 +737,22 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
687
737
  const cleanup = this.detachDaemonSocketCalls(ws, message);
688
738
  this.daemonRegistry.expire(ws);
689
739
  sendWebSocketQuietly(ws, { type: "error", error: errorCode });
690
- closeWebSocketQuietly(ws, 1008, closeReason);
740
+ closeWebSocketQuietly(ws, daemonErrorCloseCode(errorCode), closeReason);
691
741
  await cleanup;
692
742
  }
693
743
 
694
744
  private async detachDaemonSocketCalls(ws: WebSocket, message: string): Promise<number> {
695
745
  const attachment = this.daemonRegistry.attachment(ws);
696
- if (!attachment?.instanceId) {
746
+ if (!attachment?.instanceId || !attachment.connectionId) {
697
747
  return await this.pending.rejectSocket(ws, () => new WorkerToolError("unavailable", message, true));
698
748
  }
699
- return this.pending.detachSocket(
749
+ const transient = this.pending.detachSocket(
700
750
  ws,
701
751
  DAEMON_RECONNECT_GRACE_MS,
702
752
  () => new WorkerToolError("unavailable", `${message}; reconnect grace expired`, true),
703
753
  );
754
+ const durable = await this.durableCalls.detach(attachment.connectionId, DAEMON_RECONNECT_GRACE_MS);
755
+ return transient + durable;
704
756
  }
705
757
 
706
758
  private reclaimStaleDaemonSockets(now = Date.now()): void {
@@ -715,6 +767,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
715
767
  await processRuntimeAlarm(this.runtimeAlarmContext());
716
768
  }
717
769
 
770
+ private async expireOverdueCalls(): Promise<void> {
771
+ await this.pending.expireDue();
772
+ await this.durableCalls.expireDue();
773
+ }
774
+
718
775
  private async scheduleRuntimeAlarm(): Promise<void> {
719
776
  await scheduleRuntimeAlarm(this.runtimeAlarmContext());
720
777
  }
@@ -723,6 +780,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
723
780
  return {
724
781
  storage: this.ctx.storage,
725
782
  pending: this.pending,
783
+ durableCalls: this.resumption.calls,
784
+ expireDurableCall: (call: import("./mcp-pending-call-store.ts").PendingStreamCallView) => this.durableCalls.expire(call),
726
785
  daemonRegistry: this.daemonRegistry,
727
786
  invalidateDaemonSocket: (socket: WebSocket, message: string, closeReason: string, errorCode?: string) =>
728
787
  this.invalidateDaemonSocket(socket, message, closeReason, errorCode),
@@ -764,35 +823,8 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
764
823
  }
765
824
 
766
825
  export default {
767
- async fetch(request: Request, env: BridgeEnv, ctx: ExecutionContext): Promise<Response> {
768
- const extraOrigins = env.MBM_ALLOWED_ORIGINS ?? "";
769
- const staticResponse = respondWithoutDurableObject(
770
- request,
771
- { server: SERVER_NAME, version: SERVER_VERSION },
772
- extraOrigins,
773
- );
774
- if (staticResponse) return staticResponse;
775
-
776
- try {
777
- const limited = await admitStatefulRequest(request, env.STATEFUL_RATE_LIMITER, extraOrigins);
778
- if (limited) return limited;
779
- const stub = env.BRIDGE.getByName("default");
780
- const streamed = await proxyMcpEventStream({
781
- request,
782
- bridge: stub,
783
- extraOrigins,
784
- ctx,
785
- });
786
- return streamed ?? stub.fetch(sanitizeBridgeRequest(request));
787
- } catch (error) {
788
- if (isDurableObjectQuotaError(error)) return durableObjectQuotaResponse(request, extraOrigins);
789
- logOuterFetchFailure("error", "outer.fetch.failed", {
790
- path: new URL(request.url).pathname,
791
- error_class: outerWorkerErrorClass(error),
792
- });
793
- return workerGatewayErrorResponse(request, extraOrigins);
794
- }
795
- },
826
+ fetch: (request: Request, env: BridgeEnv, ctx: ExecutionContext) =>
827
+ handleOuterWorkerFetch(request, env, ctx, { server: SERVER_NAME, version: SERVER_VERSION }),
796
828
  } satisfies ExportedHandler<BridgeEnv>;
797
829
 
798
830
  function daemonInstanceId(value: unknown): string {
@@ -0,0 +1,20 @@
1
+ export function positiveDelay(value: unknown): number {
2
+ return Math.max(1, Math.floor(Number(value) || 1));
3
+ }
4
+
5
+ export function extendAttachedCallExpiry(
6
+ currentExpiry: number,
7
+ operationDeadline: number,
8
+ terminalRetentionMs: number,
9
+ ): number {
10
+ return Math.max(currentExpiry, operationDeadline + terminalRetentionMs);
11
+ }
12
+
13
+ export function extendDetachedCallExpiry(
14
+ currentExpiry: number,
15
+ reconnectDeadline: number,
16
+ remainingOperationMs: number,
17
+ terminalRetentionMs: number,
18
+ ): number {
19
+ return Math.max(currentExpiry, reconnectDeadline + remainingOperationMs + terminalRetentionMs);
20
+ }
@@ -0,0 +1,33 @@
1
+ import type { StreamIndexEntry } from "./mcp-resumption-records.ts";
2
+
3
+ export type PendingStreamCallSnapshot = {
4
+ active: number;
5
+ detached: number;
6
+ request_keys: number;
7
+ maximum: number;
8
+ oldest_ms: number;
9
+ by_tool: Record<string, number>;
10
+ };
11
+
12
+ export function activePendingCallCount(entries: StreamIndexEntry[]): number {
13
+ return entries.filter((entry) => entry.call).length;
14
+ }
15
+
16
+ export function pendingCallSnapshot(
17
+ entries: StreamIndexEntry[],
18
+ now: number,
19
+ maximum: number,
20
+ ): PendingStreamCallSnapshot {
21
+ const calls = entries.flatMap((entry) => entry.call ? [entry.call] : []);
22
+ const byTool = Object.create(null) as Record<string, number>;
23
+ let detached = 0;
24
+ let oldestMs = 0;
25
+ let requestKeys = 0;
26
+ for (const call of calls) {
27
+ byTool[call.tool] = (byTool[call.tool] ?? 0) + 1;
28
+ detached += Number(call.state === "detached");
29
+ requestKeys += Number(Boolean(call.client_request_key));
30
+ oldestMs = Math.max(oldestMs, now - call.started_at);
31
+ }
32
+ return { active: calls.length, detached, request_keys: requestKeys, maximum, oldest_ms: oldestMs, by_tool: byTool };
33
+ }
@@ -0,0 +1,58 @@
1
+ export type PendingStreamTransform = {
2
+ kind: "project_overview";
3
+ account_id: string;
4
+ account_version: number;
5
+ role: "owner" | "operator" | "editor" | "reviewer";
6
+ };
7
+
8
+ export type PendingStreamCall = {
9
+ call_id: string;
10
+ daemon_instance_id: string;
11
+ connection_id: string;
12
+ client_request_key?: string;
13
+ tool: string;
14
+ state: "attached" | "detached";
15
+ started_at: number;
16
+ operation_deadline_at: number;
17
+ remaining_timeout_ms: number;
18
+ reconnect_deadline_at?: number;
19
+ transform?: PendingStreamTransform;
20
+ };
21
+
22
+ const CALL_ID_PATTERN = /^call_[A-Za-z0-9_-]{43}$/;
23
+ const CONNECTION_ID_PATTERN = /^connection_[A-Za-z0-9_-]{43}$/;
24
+ const DAEMON_INSTANCE_PATTERN = /^daemon_[A-Za-z0-9_-]{16,96}$/;
25
+
26
+ export function validPendingStreamCall(value: unknown): value is PendingStreamCall {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
28
+ const call = value as Partial<PendingStreamCall>;
29
+ const transform = call.transform;
30
+ const validTransform = transform === undefined || (
31
+ transform.kind === "project_overview"
32
+ && typeof transform.account_id === "string"
33
+ && transform.account_id.length > 0
34
+ && transform.account_id.length <= 160
35
+ && Number.isSafeInteger(transform.account_version)
36
+ && transform.account_version! >= 1
37
+ && ["owner", "operator", "editor", "reviewer"].includes(String(transform.role))
38
+ );
39
+ return typeof call.call_id === "string" && CALL_ID_PATTERN.test(call.call_id)
40
+ && typeof call.daemon_instance_id === "string" && DAEMON_INSTANCE_PATTERN.test(call.daemon_instance_id)
41
+ && typeof call.connection_id === "string" && CONNECTION_ID_PATTERN.test(call.connection_id)
42
+ && (call.client_request_key === undefined || (
43
+ typeof call.client_request_key === "string"
44
+ && call.client_request_key.length > 0
45
+ && call.client_request_key.length <= 1024
46
+ ))
47
+ && typeof call.tool === "string" && call.tool.length > 0 && call.tool.length <= 128
48
+ && (call.state === "attached" || call.state === "detached")
49
+ && Number.isSafeInteger(call.started_at) && call.started_at! >= 0
50
+ && Number.isSafeInteger(call.operation_deadline_at) && call.operation_deadline_at! > call.started_at!
51
+ && Number.isSafeInteger(call.remaining_timeout_ms) && call.remaining_timeout_ms! >= 1
52
+ && (call.reconnect_deadline_at === undefined || (
53
+ Number.isSafeInteger(call.reconnect_deadline_at)
54
+ && call.reconnect_deadline_at! >= call.started_at!
55
+ ))
56
+ && (call.state === "attached" ? call.reconnect_deadline_at === undefined : call.reconnect_deadline_at !== undefined)
57
+ && validTransform;
58
+ }
@@ -0,0 +1,18 @@
1
+ import {
2
+ streamKey,
3
+ validRecord,
4
+ type StreamIndexEntry,
5
+ type StreamRecord,
6
+ } from "./mcp-resumption-records.ts";
7
+ import type { PendingStreamCall } from "./mcp-pending-call-records.ts";
8
+
9
+ export async function requiredPendingCallRecord(
10
+ transaction: Pick<DurableObjectTransaction, "get">,
11
+ entry: StreamIndexEntry & { call: PendingStreamCall },
12
+ ): Promise<StreamRecord & { call: PendingStreamCall }> {
13
+ const record = await transaction.get<unknown>(streamKey(entry.stream_id));
14
+ if (!validRecord(record) || !record.call || record.call.call_id !== entry.call.call_id) {
15
+ throw new Error("persisted pending call record is corrupt");
16
+ }
17
+ return record as StreamRecord & { call: PendingStreamCall };
18
+ }