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.
- package/CHANGELOG.md +16 -0
- package/README.md +2 -0
- package/SECURITY.md +4 -4
- package/browser-extension/manifest.json +1 -1
- package/docs/ARCHITECTURE.md +11 -9
- package/docs/AUDIT.md +16 -0
- package/docs/ENGINEERING.md +1 -1
- package/docs/LOCAL_AUTHORIZATION.md +1 -1
- package/docs/LOGGING.md +3 -1
- package/docs/MULTI_ACCOUNT.md +1 -1
- package/docs/OPERATIONS.md +5 -3
- package/docs/OVERVIEW.md +1 -1
- package/docs/PRIVACY.md +1 -1
- package/docs/TESTING.md +9 -7
- package/docs/THREAT_MODEL.md +3 -2
- package/docs/UPGRADING.md +7 -1
- package/package.json +1 -1
- package/src/local/process-tree-ownership.mjs +17 -5
- package/src/local/process-tree.mjs +6 -2
- package/src/local/relay-connection.mjs +24 -11
- package/src/local/runtime-activation.mjs +16 -0
- package/src/local/runtime.mjs +3 -1
- package/src/local/worker-deployment.mjs +16 -6
- package/src/worker/daemon-socket-attachment.ts +52 -0
- package/src/worker/daemon-sockets.ts +17 -48
- package/src/worker/durable-stream-calls.ts +129 -0
- package/src/worker/durable-stream-result.ts +22 -0
- package/src/worker/index.ts +120 -88
- package/src/worker/mcp-pending-call-expiry.ts +20 -0
- package/src/worker/mcp-pending-call-inspection.ts +33 -0
- package/src/worker/mcp-pending-call-records.ts +58 -0
- package/src/worker/mcp-pending-call-storage.ts +18 -0
- package/src/worker/mcp-pending-call-store.ts +285 -0
- package/src/worker/mcp-resumption-index.ts +41 -0
- package/src/worker/mcp-resumption-records.ts +8 -2
- package/src/worker/mcp-resumption.ts +80 -87
- package/src/worker/mcp-stream-dispatch.ts +42 -54
- package/src/worker/pending-admission.ts +15 -0
- package/src/worker/pending-call-contract.ts +4 -7
- package/src/worker/pending-calls.ts +4 -10
- package/src/worker/runtime-alarm-storage.ts +35 -0
- package/src/worker/runtime-alarm.ts +29 -39
- package/src/worker/websocket-protocol.ts +4 -0
- package/src/worker/worker-entry.ts +50 -0
package/src/worker/index.ts
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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)
|
|
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
|
|
317
|
-
?
|
|
318
|
-
:
|
|
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
|
|
420
|
+
requestId,
|
|
389
421
|
});
|
|
390
422
|
} catch (error) {
|
|
391
423
|
if (error instanceof McpStreamLimitError) {
|
|
392
|
-
return json(rpcError(
|
|
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(
|
|
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
|
|
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
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
513
|
-
|
|
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
|
|
582
|
+
let result!: Promise<unknown>;
|
|
548
583
|
try {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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,
|
|
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
|
|
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
|
|
662
|
-
return { daemon,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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
|
+
}
|