machine-bridge-mcp 3.0.0-beta.17 → 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 (74) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +2 -0
  3. package/SECURITY.md +9 -7
  4. package/browser-extension/browser-error-boundary.js +45 -0
  5. package/browser-extension/browser-operations.js +1 -1
  6. package/browser-extension/manifest.json +1 -1
  7. package/browser-extension/service-worker.js +9 -8
  8. package/docs/ARCHITECTURE.md +17 -15
  9. package/docs/AUDIT.md +30 -0
  10. package/docs/ENGINEERING.md +3 -3
  11. package/docs/GETTING_STARTED.md +1 -1
  12. package/docs/LOCAL_AUTHORIZATION.md +2 -2
  13. package/docs/LOCAL_AUTOMATION.md +1 -1
  14. package/docs/LOGGING.md +3 -1
  15. package/docs/MULTI_ACCOUNT.md +2 -2
  16. package/docs/OPERATIONS.md +13 -5
  17. package/docs/OVERVIEW.md +2 -2
  18. package/docs/PRIVACY.md +1 -1
  19. package/docs/TESTING.md +17 -13
  20. package/docs/THREAT_MODEL.md +13 -8
  21. package/docs/UPGRADING.md +19 -1
  22. package/package.json +1 -1
  23. package/scripts/coverage-check.mjs +12 -0
  24. package/src/local/account-admin.mjs +68 -1
  25. package/src/local/agent-skill-discovery.mjs +16 -5
  26. package/src/local/app-automation.mjs +27 -7
  27. package/src/local/cli-options.mjs +1 -1
  28. package/src/local/path-inspection.mjs +23 -0
  29. package/src/local/process-tree-ownership.mjs +17 -5
  30. package/src/local/process-tree.mjs +6 -2
  31. package/src/local/relay-connection.mjs +24 -11
  32. package/src/local/resource-operations.mjs +37 -8
  33. package/src/local/runtime-activation.mjs +16 -0
  34. package/src/local/runtime-capabilities.mjs +15 -3
  35. package/src/local/runtime.mjs +7 -4
  36. package/src/local/worker-deployment.mjs +16 -6
  37. package/src/local/workspace-file-service.mjs +29 -11
  38. package/src/worker/daemon-socket-attachment.ts +52 -0
  39. package/src/worker/daemon-sockets.ts +17 -48
  40. package/src/worker/durable-stream-calls.ts +129 -0
  41. package/src/worker/durable-stream-result.ts +22 -0
  42. package/src/worker/http.ts +37 -15
  43. package/src/worker/index.ts +143 -146
  44. package/src/worker/mcp-pending-call-expiry.ts +20 -0
  45. package/src/worker/mcp-pending-call-inspection.ts +33 -0
  46. package/src/worker/mcp-pending-call-records.ts +58 -0
  47. package/src/worker/mcp-pending-call-storage.ts +18 -0
  48. package/src/worker/mcp-pending-call-store.ts +285 -0
  49. package/src/worker/mcp-resumption-index.ts +41 -0
  50. package/src/worker/mcp-resumption-records.ts +8 -2
  51. package/src/worker/mcp-resumption.ts +84 -86
  52. package/src/worker/mcp-stream-dispatch.ts +42 -54
  53. package/src/worker/mcp-stream-proxy.ts +7 -69
  54. package/src/worker/mcp-stream-subscription.ts +98 -0
  55. package/src/worker/oauth-controller.ts +11 -2
  56. package/src/worker/oauth-refresh-exchange.ts +147 -0
  57. package/src/worker/oauth-refresh-families.ts +59 -17
  58. package/src/worker/oauth-state.ts +13 -2
  59. package/src/worker/oauth-token-derivation.ts +33 -0
  60. package/src/worker/oauth-token-issuance.ts +106 -0
  61. package/src/worker/oauth-tokens.ts +12 -184
  62. package/src/worker/observability.ts +19 -0
  63. package/src/worker/pending-admission.ts +15 -0
  64. package/src/worker/pending-call-contract.ts +4 -7
  65. package/src/worker/pending-calls.ts +4 -10
  66. package/src/worker/runtime-alarm-storage.ts +35 -0
  67. package/src/worker/runtime-alarm.ts +30 -20
  68. package/src/worker/websocket-protocol.ts +4 -0
  69. package/src/worker/worker-edge-guard.ts +81 -0
  70. package/src/worker/worker-edge-log.ts +63 -0
  71. package/src/worker/worker-entry.ts +50 -0
  72. package/src/worker/worker-metadata.ts +39 -0
  73. package/src/worker/worker-static-routes.ts +38 -50
  74. package/wrangler.jsonc +8 -0
@@ -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
+ }
@@ -0,0 +1,285 @@
1
+ import {
2
+ STREAM_INDEX_KEY,
3
+ indexEntry,
4
+ readIndex,
5
+ streamKey,
6
+ validRecord,
7
+ type JsonRpcMessage,
8
+ type StreamIndex,
9
+ type StreamIndexEntry,
10
+ type StreamRecord,
11
+ } from "./mcp-resumption-records.ts";
12
+ import type { PendingStreamCall, PendingStreamTransform } from "./mcp-pending-call-records.ts";
13
+ import {
14
+ extendAttachedCallExpiry,
15
+ extendDetachedCallExpiry,
16
+ positiveDelay,
17
+ } from "./mcp-pending-call-expiry.ts";
18
+ import { requiredPendingCallRecord } from "./mcp-pending-call-storage.ts";
19
+ import {
20
+ activePendingCallCount,
21
+ pendingCallSnapshot,
22
+ type PendingStreamCallSnapshot,
23
+ } from "./mcp-pending-call-inspection.ts";
24
+
25
+ type PendingCallStorage = Pick<DurableObjectStorage, "get" | "put" | "transaction">;
26
+ type CompleteStream = (
27
+ streamId: string,
28
+ message: JsonRpcMessage,
29
+ expected: { callId: string; connectionId?: string },
30
+ ) => Promise<boolean>;
31
+ type StorageRowsWrittenListener = (rows: number) => void;
32
+
33
+ export type PendingStreamCallView = PendingStreamCall & {
34
+ streamId: string;
35
+ requestId: string | number;
36
+ };
37
+
38
+ export class McpPendingCallLimitError extends Error {
39
+ constructor() {
40
+ super("too many concurrent daemon tool calls");
41
+ this.name = "McpPendingCallLimitError";
42
+ }
43
+ }
44
+
45
+ export class McpPendingCallConflictError extends Error {
46
+ constructor(message: string) {
47
+ super(message);
48
+ this.name = "McpPendingCallConflictError";
49
+ }
50
+ }
51
+
52
+ const DEFAULT_MAXIMUM_PENDING_CALLS = 32;
53
+
54
+ export class McpPendingCallStore {
55
+ private readonly storage: PendingCallStorage;
56
+ private readonly now: () => number;
57
+ private readonly activateStream: (streamId: string) => void;
58
+ private readonly completeStream: CompleteStream;
59
+ private readonly onRowsWritten: StorageRowsWrittenListener;
60
+ private readonly terminalRetentionMs: number;
61
+
62
+ constructor(
63
+ storage: PendingCallStorage,
64
+ now: () => number,
65
+ activateStream: (streamId: string) => void,
66
+ completeStream: CompleteStream,
67
+ onRowsWritten: StorageRowsWrittenListener,
68
+ terminalRetentionMs: number,
69
+ ) {
70
+ this.storage = storage;
71
+ this.now = now;
72
+ this.activateStream = activateStream;
73
+ this.completeStream = completeStream;
74
+ this.onRowsWritten = onRowsWritten;
75
+ this.terminalRetentionMs = positiveDelay(terminalRetentionMs);
76
+ }
77
+
78
+ async activate(input: {
79
+ streamId: string;
80
+ callId: string;
81
+ daemonInstanceId: string;
82
+ connectionId: string;
83
+ clientRequestKey?: string;
84
+ tool: string;
85
+ timeoutMs: number;
86
+ transform?: PendingStreamTransform;
87
+ transientActiveCount?: number;
88
+ maximumPendingCalls?: number;
89
+ }): Promise<void> {
90
+ const now = this.now();
91
+ const timeoutMs = positiveDelay(input.timeoutMs);
92
+ const maximum = Math.max(1, Math.floor(input.maximumPendingCalls ?? DEFAULT_MAXIMUM_PENDING_CALLS));
93
+ const call: PendingStreamCall = {
94
+ call_id: input.callId,
95
+ daemon_instance_id: input.daemonInstanceId,
96
+ connection_id: input.connectionId,
97
+ ...(input.clientRequestKey ? { client_request_key: input.clientRequestKey } : {}),
98
+ tool: input.tool,
99
+ state: "attached",
100
+ started_at: now,
101
+ operation_deadline_at: now + timeoutMs,
102
+ remaining_timeout_ms: timeoutMs,
103
+ ...(input.transform ? { transform: structuredClone(input.transform) } : {}),
104
+ };
105
+ await this.storage.transaction(async (transaction) => {
106
+ const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
107
+ const activeCalls = index.entries.filter((entry) => entry.call).length;
108
+ if (activeCalls + Math.max(0, Math.floor(input.transientActiveCount ?? 0)) >= maximum) {
109
+ throw new McpPendingCallLimitError();
110
+ }
111
+ if (index.entries.some((entry) => entry.call?.call_id === input.callId)) {
112
+ throw new McpPendingCallConflictError("duplicate internal daemon call id");
113
+ }
114
+ if (input.clientRequestKey && index.entries.some((entry) => entry.call?.client_request_key === input.clientRequestKey)) {
115
+ throw new McpPendingCallConflictError("duplicate in-flight JSON-RPC request id within this MCP session");
116
+ }
117
+ const record = await transaction.get<unknown>(streamKey(input.streamId));
118
+ if (!validRecord(record) || record.status !== "pending") {
119
+ throw new Error("resumable MCP stream record is unavailable for activation");
120
+ }
121
+ if (record.call) throw new McpPendingCallConflictError("resumable MCP stream already has an active daemon call");
122
+ const updated: StreamRecord = {
123
+ ...record,
124
+ expires_at: extendAttachedCallExpiry(record.expires_at, call.operation_deadline_at, this.terminalRetentionMs),
125
+ call,
126
+ };
127
+ const entries = index.entries.map((entry) => entry.stream_id === input.streamId ? indexEntry(updated) : entry);
128
+ if (!entries.some((entry) => entry.stream_id === input.streamId)) {
129
+ throw new Error("resumable MCP stream index lost its record");
130
+ }
131
+ await transaction.put(streamKey(input.streamId), updated);
132
+ await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
133
+ });
134
+ this.onRowsWritten(2);
135
+ this.activateStream(input.streamId);
136
+ }
137
+
138
+ async activeCount(): Promise<number> {
139
+ return activePendingCallCount(readIndex(await this.storage.get<unknown>(STREAM_INDEX_KEY)).entries);
140
+ }
141
+
142
+ async get(callId: string): Promise<PendingStreamCallView | undefined> {
143
+ const index = readIndex(await this.storage.get<unknown>(STREAM_INDEX_KEY));
144
+ const entry = index.entries.find((candidate) => candidate.call?.call_id === callId);
145
+ return entry ? this.callView(entry) : undefined;
146
+ }
147
+
148
+ async getByRequestKey(requestKey: string): Promise<PendingStreamCallView | undefined> {
149
+ const index = readIndex(await this.storage.get<unknown>(STREAM_INDEX_KEY));
150
+ const entry = index.entries.find((candidate) => candidate.call?.client_request_key === requestKey);
151
+ return entry ? this.callView(entry) : undefined;
152
+ }
153
+
154
+ async snapshot(maximum = DEFAULT_MAXIMUM_PENDING_CALLS): Promise<PendingStreamCallSnapshot> {
155
+ const index = readIndex(await this.storage.get<unknown>(STREAM_INDEX_KEY));
156
+ return pendingCallSnapshot(index.entries, this.now(), maximum);
157
+ }
158
+
159
+ async nextDeadlineDelayMs(): Promise<number> {
160
+ const index = readIndex(await this.storage.get<unknown>(STREAM_INDEX_KEY));
161
+ const now = this.now();
162
+ return Math.min(Number.POSITIVE_INFINITY, ...index.entries.flatMap((entry) => {
163
+ if (!entry.call) return [];
164
+ const deadline = entry.call.state === "attached"
165
+ ? entry.call.operation_deadline_at
166
+ : entry.call.reconnect_deadline_at;
167
+ return Number.isSafeInteger(deadline) ? [Math.max(0, Number(deadline) - now)] : [];
168
+ }));
169
+ }
170
+
171
+ async due(now = this.now()): Promise<PendingStreamCallView[]> {
172
+ const index = readIndex(await this.storage.get<unknown>(STREAM_INDEX_KEY));
173
+ const due: PendingStreamCallView[] = [];
174
+ for (const entry of index.entries) {
175
+ const call = entry.call;
176
+ if (!call) continue;
177
+ const deadline = call.state === "attached" ? call.operation_deadline_at : call.reconnect_deadline_at;
178
+ if (Number.isSafeInteger(deadline) && Number(deadline) <= now) due.push(await this.callView(entry));
179
+ }
180
+ return due;
181
+ }
182
+
183
+ async detach(connectionId: string, graceMs: number): Promise<number> {
184
+ const now = this.now();
185
+ const reconnectDeadline = now + positiveDelay(graceMs);
186
+ let rows = 0;
187
+ const count = await this.storage.transaction(async (transaction) => {
188
+ const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
189
+ const entries = [...index.entries];
190
+ let changed = 0;
191
+ for (let position = 0; position < entries.length; position += 1) {
192
+ const entry = entries[position];
193
+ if (entry.call?.state !== "attached" || entry.call.connection_id !== connectionId) continue;
194
+ const record = await requiredPendingCallRecord(transaction, entry as StreamIndexEntry & { call: PendingStreamCall });
195
+ const remaining = Math.max(1, record.call.operation_deadline_at - now);
196
+ const updated: StreamRecord = {
197
+ ...record,
198
+ expires_at: extendDetachedCallExpiry(
199
+ record.expires_at,
200
+ reconnectDeadline,
201
+ remaining,
202
+ this.terminalRetentionMs,
203
+ ),
204
+ call: {
205
+ ...record.call,
206
+ state: "detached",
207
+ remaining_timeout_ms: remaining,
208
+ reconnect_deadline_at: reconnectDeadline,
209
+ },
210
+ };
211
+ await transaction.put(streamKey(entry.stream_id), updated);
212
+ entries[position] = indexEntry(updated);
213
+ changed += 1;
214
+ }
215
+ if (changed > 0) {
216
+ await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
217
+ rows = changed + 1;
218
+ }
219
+ return changed;
220
+ });
221
+ this.onRowsWritten(rows);
222
+ return count;
223
+ }
224
+
225
+ async rebind(daemonInstanceId: string, connectionId: string): Promise<string[]> {
226
+ if (!daemonInstanceId || !connectionId) return [];
227
+ const now = this.now();
228
+ let rows = 0;
229
+ const rebound = await this.storage.transaction(async (transaction) => {
230
+ const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
231
+ const entries = [...index.entries];
232
+ const ids: string[] = [];
233
+ for (let position = 0; position < entries.length; position += 1) {
234
+ const entry = entries[position];
235
+ if (!entry.call || entry.call.daemon_instance_id !== daemonInstanceId || entry.call.connection_id === connectionId) continue;
236
+ const record = await requiredPendingCallRecord(transaction, entry as StreamIndexEntry & { call: PendingStreamCall });
237
+ const remaining = record.call.state === "detached"
238
+ ? record.call.remaining_timeout_ms
239
+ : Math.max(1, record.call.operation_deadline_at - now);
240
+ const updatedCall: PendingStreamCall = {
241
+ ...record.call,
242
+ connection_id: connectionId,
243
+ state: "attached",
244
+ operation_deadline_at: now + remaining,
245
+ remaining_timeout_ms: remaining,
246
+ };
247
+ delete updatedCall.reconnect_deadline_at;
248
+ const updated: StreamRecord = {
249
+ ...record,
250
+ expires_at: extendAttachedCallExpiry(
251
+ record.expires_at,
252
+ updatedCall.operation_deadline_at,
253
+ this.terminalRetentionMs,
254
+ ),
255
+ call: updatedCall,
256
+ };
257
+ await transaction.put(streamKey(entry.stream_id), updated);
258
+ entries[position] = indexEntry(updated);
259
+ ids.push(updatedCall.call_id);
260
+ }
261
+ if (ids.length > 0) {
262
+ await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
263
+ rows = ids.length + 1;
264
+ }
265
+ return ids;
266
+ });
267
+ this.onRowsWritten(rows);
268
+ return rebound;
269
+ }
270
+
271
+ async complete(callId: string, connectionId: string | undefined, message: JsonRpcMessage): Promise<boolean> {
272
+ const view = await this.get(callId);
273
+ if (!view || (connectionId && view.connection_id !== connectionId)) return false;
274
+ return this.completeStream(view.streamId, message, { callId, connectionId });
275
+ }
276
+
277
+ private async callView(entry: StreamIndexEntry): Promise<PendingStreamCallView> {
278
+ if (!entry.call) throw new Error("stream index entry has no pending call");
279
+ const record = await this.storage.get<unknown>(streamKey(entry.stream_id));
280
+ if (!validRecord(record) || !record.call || record.call.call_id !== entry.call.call_id) {
281
+ throw new Error("persisted pending call record is corrupt");
282
+ }
283
+ return { ...structuredClone(record.call), streamId: record.stream_id, requestId: record.request_id };
284
+ }
285
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ streamKey,
3
+ type StreamIndexEntry,
4
+ } from "./mcp-resumption-records.ts";
5
+
6
+ type TransactionStorage = Pick<DurableObjectTransaction, "delete">;
7
+
8
+ export async function pruneExpiredStreams(
9
+ storage: TransactionStorage,
10
+ entries: StreamIndexEntry[],
11
+ now: number,
12
+ ): Promise<{ entries: StreamIndexEntry[]; removedStreamIds: string[] }> {
13
+ const retained: StreamIndexEntry[] = [];
14
+ const removedStreamIds: string[] = [];
15
+ for (const entry of entries) {
16
+ if (entry.expires_at <= now) {
17
+ await storage.delete(streamKey(entry.stream_id));
18
+ removedStreamIds.push(entry.stream_id);
19
+ } else retained.push(entry);
20
+ }
21
+ return { entries: retained, removedStreamIds };
22
+ }
23
+
24
+ export async function freeCompletedStreamSlots(
25
+ storage: TransactionStorage,
26
+ entries: StreamIndexEntry[],
27
+ maximumRetained: number,
28
+ ): Promise<{ available: boolean; entries: StreamIndexEntry[]; removedStreamIds: string[] }> {
29
+ const retained = [...entries];
30
+ const removedStreamIds: string[] = [];
31
+ const completed = retained
32
+ .filter((entry) => entry.status === "ready")
33
+ .sort((left, right) => left.created_at - right.created_at);
34
+ for (const entry of completed) {
35
+ if (retained.length <= maximumRetained) break;
36
+ await storage.delete(streamKey(entry.stream_id));
37
+ retained.splice(retained.findIndex((candidate) => candidate.stream_id === entry.stream_id), 1);
38
+ removedStreamIds.push(entry.stream_id);
39
+ }
40
+ return { available: retained.length <= maximumRetained, entries: retained, removedStreamIds };
41
+ }
@@ -1,4 +1,5 @@
1
1
  import relayContract from "../shared/relay-contract.json" with { type: "json" };
2
+ import { validPendingStreamCall, type PendingStreamCall } from "./mcp-pending-call-records.ts";
2
3
  export type JsonRpcMessage = Record<string, unknown> | null;
3
4
  export type StreamStatus = "pending" | "ready";
4
5
  export type StreamIndexEntry = {
@@ -6,6 +7,7 @@ export type StreamIndexEntry = {
6
7
  status: StreamStatus;
7
8
  created_at: number;
8
9
  expires_at: number;
10
+ call?: PendingStreamCall;
9
11
  };
10
12
  export type StreamIndex = { schema_version: 1; entries: StreamIndexEntry[] };
11
13
  export type StreamRecord = StreamIndexEntry & {
@@ -55,6 +57,7 @@ export function validRecord(value: unknown): value is StreamRecord {
55
57
  return false;
56
58
  }
57
59
  if (record.status === "pending") return record.message_json === undefined && record.message_sha256 === undefined;
60
+ if (record.call !== undefined) return false;
58
61
  return typeof record.message_json === "string"
59
62
  && new TextEncoder().encode(record.message_json).byteLength <= DEFAULT_MAXIMUM_MESSAGE_BYTES
60
63
  && typeof record.message_sha256 === "string"
@@ -67,6 +70,7 @@ export function indexEntry(record: StreamRecord): StreamIndexEntry {
67
70
  status: record.status,
68
71
  created_at: record.created_at,
69
72
  expires_at: record.expires_at,
73
+ ...(record.call ? { call: structuredClone(record.call) } : {}),
70
74
  };
71
75
  }
72
76
 
@@ -90,8 +94,9 @@ export function readyRecord(
90
94
  messageSha256: string,
91
95
  expiresAt: number,
92
96
  ): StreamRecord {
97
+ const { call: _call, ...base } = record;
93
98
  return {
94
- ...record,
99
+ ...base,
95
100
  status: "ready",
96
101
  expires_at: expiresAt,
97
102
  message_json: messageJson,
@@ -150,7 +155,8 @@ function validIndexEntry(value: unknown): value is StreamIndexEntry {
150
155
  return typeof entry.stream_id === "string" && isStreamId(entry.stream_id)
151
156
  && (entry.status === "pending" || entry.status === "ready")
152
157
  && Number.isSafeInteger(entry.created_at) && Number.isSafeInteger(entry.expires_at)
153
- && entry.expires_at! > entry.created_at!;
158
+ && entry.expires_at! > entry.created_at!
159
+ && (entry.call === undefined || (entry.status === "pending" && validPendingStreamCall(entry.call)));
154
160
  }
155
161
 
156
162
  function storedIntegrityMessage(requestId: string | number): Record<string, unknown> {
@@ -13,27 +13,32 @@ import {
13
13
  workerRestartMessage,
14
14
  type JsonRpcMessage,
15
15
  type StreamIndex,
16
- type StreamIndexEntry,
17
16
  type StreamRecord,
18
17
  } from "./mcp-resumption-records.ts";
18
+ import { McpPendingCallStore } from "./mcp-pending-call-store.ts";
19
+ import { freeCompletedStreamSlots, pruneExpiredStreams } from "./mcp-resumption-index.ts";
19
20
  import { resumptionLimits, type McpResumptionOptions } from "./mcp-resumption-config.ts";
20
21
  export type { JsonRpcMessage } from "./mcp-resumption-records.ts";
22
+
21
23
  type ResumptionStorage = Pick<DurableObjectStorage, "get" | "put" | "delete" | "transaction">;
22
- type TransactionStorage = Pick<DurableObjectTransaction, "get" | "put" | "delete">;
23
24
  type StreamReadyListener = (streamId: string, message: JsonRpcMessage) => void;
25
+ type StorageRowsWrittenListener = (rows: number) => void;
24
26
  export type StreamResumeResult =
25
27
  | { kind: "invalid" | "not_found" | "expired" }
26
28
  | { kind: "complete"; streamId: string }
27
29
  | { kind: "message"; streamId: string };
30
+
28
31
  export class McpStreamLimitError extends Error {
29
32
  constructor() {
30
33
  super("too many resumable MCP streams are active");
31
34
  this.name = "McpStreamLimitError";
32
35
  }
33
36
  }
37
+
34
38
  const EVENT_ID_PATTERN = /^(stream_[A-Za-z0-9_-]{43}):([01])$/;
39
+
35
40
  export class McpResumptionStore {
36
- private readonly storage: ResumptionStorage;
41
+ readonly calls: McpPendingCallStore;
37
42
  private readonly active = new Set<string>();
38
43
  private readonly transientReady = new Map<string, JsonRpcMessage>();
39
44
  private readonly now: () => number;
@@ -41,17 +46,35 @@ export class McpResumptionStore {
41
46
  private readonly pendingRetentionMs: number;
42
47
  private readonly maximumStreams: number;
43
48
  private readonly maximumMessageBytes: number;
49
+ private readonly storage: ResumptionStorage;
44
50
  private readonly onReady: StreamReadyListener;
45
- constructor(storage: ResumptionStorage, options: McpResumptionOptions = {}, onReady: StreamReadyListener = () => {}) {
51
+ private readonly onRowsWritten: StorageRowsWrittenListener;
52
+
53
+ constructor(
54
+ storage: ResumptionStorage,
55
+ options: McpResumptionOptions = {},
56
+ onReady: StreamReadyListener = () => {},
57
+ onRowsWritten: StorageRowsWrittenListener = () => {},
58
+ ) {
46
59
  this.storage = storage;
47
- this.now = options.now ?? Date.now;
48
60
  this.onReady = onReady;
61
+ this.onRowsWritten = onRowsWritten;
62
+ this.now = options.now ?? Date.now;
49
63
  const limits = resumptionLimits(options);
50
64
  this.retentionMs = limits.retentionMs;
51
65
  this.pendingRetentionMs = limits.pendingRetentionMs;
52
66
  this.maximumStreams = limits.maximumStreams;
53
67
  this.maximumMessageBytes = limits.maximumMessageBytes;
68
+ this.calls = new McpPendingCallStore(
69
+ storage,
70
+ this.now,
71
+ (streamId) => this.activate(streamId),
72
+ (streamId, message, expected) => this.completeInternal(streamId, message, expected),
73
+ onRowsWritten,
74
+ this.retentionMs,
75
+ );
54
76
  }
77
+
55
78
  async begin(input: {
56
79
  streamId: string;
57
80
  tokenKey: string;
@@ -72,14 +95,15 @@ export class McpResumptionStore {
72
95
  };
73
96
  const removedStreamIds = await this.storage.transaction(async (transaction) => {
74
97
  const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
75
- const pruned = await pruneExpired(transaction, index.entries, now);
76
- const freed = await freeCompletedSlots(transaction, pruned.entries, this.maximumStreams - 1);
98
+ const pruned = await pruneExpiredStreams(transaction, index.entries, now);
99
+ const freed = await freeCompletedStreamSlots(transaction, pruned.entries, this.maximumStreams - 1);
77
100
  if (!freed.available) throw new McpStreamLimitError();
78
101
  const entries = [...freed.entries, indexEntry(record)];
79
102
  await transaction.put(streamKey(input.streamId), record);
80
103
  await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
81
104
  return [...pruned.removedStreamIds, ...freed.removedStreamIds];
82
105
  });
106
+ this.onRowsWritten(2 + removedStreamIds.length);
83
107
  for (const removedStreamId of removedStreamIds) this.clearMemory(removedStreamId);
84
108
  }
85
109
 
@@ -90,43 +114,12 @@ export class McpResumptionStore {
90
114
  }
91
115
 
92
116
  async complete(streamId: string, message: JsonRpcMessage): Promise<void> {
93
- try {
94
- const initial = await this.storage.get<unknown>(streamKey(streamId));
95
- if (initial === undefined) throw new Error("resumable MCP stream record disappeared before completion");
96
- if (!validRecord(initial)) throw new Error("resumable MCP stream record is corrupt");
97
- const messageJson = resumableMessageJson(message, initial.request_id, this.maximumMessageBytes);
98
- const digest = await messageSha256(messageJson);
99
- const expiresAt = this.now() + this.retentionMs;
100
- const completed = await this.storage.transaction(async (transaction) => {
101
- const record = await transaction.get<unknown>(streamKey(streamId));
102
- if (record === undefined) return false;
103
- if (!validRecord(record)) throw new Error("resumable MCP stream record is corrupt");
104
- if (record.status === "ready") return true;
105
- const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
106
- if (!index.entries.some((candidate) => candidate.stream_id === streamId)) {
107
- throw new Error("resumable MCP stream index lost its record");
108
- }
109
- const ready = readyRecord(record, messageJson, digest, expiresAt);
110
- const entries = index.entries.map((candidate) => candidate.stream_id === streamId
111
- ? indexEntry(ready)
112
- : candidate);
113
- await transaction.put(streamKey(streamId), ready);
114
- await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
115
- return true;
116
- });
117
- if (!completed) throw new Error("resumable MCP stream record disappeared during completion");
118
- this.transientReady.delete(streamId);
119
- } catch (error) {
120
- this.transientReady.set(streamId, message);
121
- throw error;
122
- } finally {
123
- this.active.delete(streamId);
124
- // Persistent state remains authoritative if a disconnected subscriber misses this push.
125
- try { this.onReady(streamId, message); } catch { /* resume/poll remains available */ }
126
- }
117
+ await this.completeInternal(streamId, message);
127
118
  }
128
119
 
129
- async pollMessage(streamId: string): Promise<{ kind: "pending" } | { kind: "not_found" } | { kind: "message"; message: JsonRpcMessage }> {
120
+ async pollMessage(streamId: string): Promise<
121
+ { kind: "pending" } | { kind: "not_found" } | { kind: "message"; message: JsonRpcMessage }
122
+ > {
130
123
  if (!isStreamId(streamId)) return { kind: "not_found" };
131
124
  const transient = this.transientReady.get(streamId);
132
125
  if (transient) return { kind: "message", message: transient };
@@ -138,18 +131,13 @@ export class McpResumptionStore {
138
131
  return { kind: "not_found" };
139
132
  }
140
133
  if (record.status === "ready") return { kind: "message", message: await storedMessage(record) };
141
- if (this.active.has(streamId)) return { kind: "pending" };
142
-
134
+ if (record.call || this.active.has(streamId)) return { kind: "pending" };
143
135
  const unavailable = workerRestartMessage(record.request_id);
144
136
  await this.complete(streamId, unavailable);
145
137
  return { kind: "message", message: unavailable };
146
138
  }
147
139
 
148
- async resume(input: {
149
- lastEventId: string;
150
- tokenKey: string;
151
- sessionId: string;
152
- }): Promise<StreamResumeResult> {
140
+ async resume(input: { lastEventId: string; tokenKey: string; sessionId: string }): Promise<StreamResumeResult> {
153
141
  const event = parseStreamEventId(input.lastEventId);
154
142
  if (!event) return { kind: "invalid" };
155
143
  const record = await this.storage.get<unknown>(streamKey(event.streamId));
@@ -161,15 +149,59 @@ export class McpResumptionStore {
161
149
  return { kind: "expired" };
162
150
  }
163
151
  if (event.sequence >= 1) return { kind: "complete", streamId: event.streamId };
164
- if (record.status === "ready" || this.transientReady.has(event.streamId) || this.active.has(event.streamId)) {
152
+ if (record.status === "ready" || this.transientReady.has(event.streamId) || record.call || this.active.has(event.streamId)) {
165
153
  return { kind: "message", streamId: event.streamId };
166
154
  }
167
-
168
155
  const unavailable = workerRestartMessage(record.request_id);
169
156
  await this.complete(event.streamId, unavailable);
170
157
  return { kind: "message", streamId: event.streamId };
171
158
  }
172
159
 
160
+ private async completeInternal(
161
+ streamId: string,
162
+ message: JsonRpcMessage,
163
+ expected?: { callId: string; connectionId?: string },
164
+ ): Promise<boolean> {
165
+ try {
166
+ const initial = await this.storage.get<unknown>(streamKey(streamId));
167
+ if (initial === undefined) throw new Error("resumable MCP stream record disappeared before completion");
168
+ if (!validRecord(initial)) throw new Error("resumable MCP stream record is corrupt");
169
+ const messageJson = resumableMessageJson(message, initial.request_id, this.maximumMessageBytes);
170
+ const digest = await messageSha256(messageJson);
171
+ const expiresAt = this.now() + this.retentionMs;
172
+ const rowsWritten = await this.storage.transaction(async (transaction) => {
173
+ const record = await transaction.get<unknown>(streamKey(streamId));
174
+ if (record === undefined) return -1;
175
+ if (!validRecord(record)) throw new Error("resumable MCP stream record is corrupt");
176
+ if (record.status === "ready") return 0;
177
+ if (expected && (
178
+ record.call?.call_id !== expected.callId
179
+ || (expected.connectionId && record.call.connection_id !== expected.connectionId)
180
+ )) return -2;
181
+ const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
182
+ if (!index.entries.some((candidate) => candidate.stream_id === streamId)) {
183
+ throw new Error("resumable MCP stream index lost its record");
184
+ }
185
+ const ready = readyRecord(record, messageJson, digest, expiresAt);
186
+ const entries = index.entries.map((candidate) => candidate.stream_id === streamId ? indexEntry(ready) : candidate);
187
+ await transaction.put(streamKey(streamId), ready);
188
+ await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
189
+ return 2;
190
+ });
191
+ if (rowsWritten < -1) return false;
192
+ if (rowsWritten < 0) throw new Error("resumable MCP stream record disappeared during completion");
193
+ this.onRowsWritten(rowsWritten);
194
+ if (rowsWritten === 0) return false;
195
+ this.clearMemory(streamId);
196
+ try { this.onReady(streamId, message); } catch { /* Persistent state remains authoritative. */ }
197
+ return true;
198
+ } catch (error) {
199
+ this.transientReady.set(streamId, message);
200
+ try { this.onReady(streamId, message); } catch { /* In-memory terminal state remains available. */ }
201
+ throw error;
202
+ }
203
+ }
204
+
173
205
  private async remove(streamId: string): Promise<void> {
174
206
  await this.storage.transaction(async (transaction) => {
175
207
  const index = readIndex(await transaction.get<unknown>(STREAM_INDEX_KEY));
@@ -178,6 +210,7 @@ export class McpResumptionStore {
178
210
  if (entries.length === 0) await transaction.delete(STREAM_INDEX_KEY);
179
211
  else await transaction.put(STREAM_INDEX_KEY, { schema_version: 1, entries } satisfies StreamIndex);
180
212
  });
213
+ this.onRowsWritten(2);
181
214
  this.clearMemory(streamId);
182
215
  }
183
216
 
@@ -197,38 +230,3 @@ export function parseStreamEventId(value: string): { streamId: string; sequence:
197
230
  if (!match) return null;
198
231
  return { streamId: match[1], sequence: Number(match[2]) as 0 | 1 };
199
232
  }
200
-
201
- async function pruneExpired(
202
- storage: TransactionStorage,
203
- entries: StreamIndexEntry[],
204
- now: number,
205
- ): Promise<{ entries: StreamIndexEntry[]; removedStreamIds: string[] }> {
206
- const retained: StreamIndexEntry[] = [];
207
- const removedStreamIds: string[] = [];
208
- for (const entry of entries) {
209
- if (entry.expires_at <= now) {
210
- await storage.delete(streamKey(entry.stream_id));
211
- removedStreamIds.push(entry.stream_id);
212
- } else retained.push(entry);
213
- }
214
- return { entries: retained, removedStreamIds };
215
- }
216
-
217
- async function freeCompletedSlots(
218
- storage: TransactionStorage,
219
- entries: StreamIndexEntry[],
220
- maximumRetained: number,
221
- ): Promise<{ available: boolean; entries: StreamIndexEntry[]; removedStreamIds: string[] }> {
222
- const retained = [...entries];
223
- const removedStreamIds: string[] = [];
224
- const completed = retained
225
- .filter((entry) => entry.status === "ready")
226
- .sort((left, right) => left.created_at - right.created_at);
227
- for (const entry of completed) {
228
- if (retained.length <= maximumRetained) break;
229
- await storage.delete(streamKey(entry.stream_id));
230
- retained.splice(retained.findIndex((candidate) => candidate.stream_id === entry.stream_id), 1);
231
- removedStreamIds.push(entry.stream_id);
232
- }
233
- return { available: retained.length <= maximumRetained, entries: retained, removedStreamIds };
234
- }