History

Emit a priming SSE event on POST tool-call streams so stock MCP clients reconnect

The MCP TypeScript SDK only auto-reconnects a dropped POST SSE stream when it saw an event id before the drop. Our POST tool-call stream emitted its first and only id together with the final result, so a network blip or edge close mid-call left the SDK unable to reconnect and callTool hung forever while the server held the completed result. Write a priming SSE event as the first frame on each POST tool-call stream: a real event-store entry whose id sorts before the eventual response, framed under a non-message event type so the SDK records the id (and will reconnect) without dispatching it as a JSON-RPC message. A reconnect with last-event-id=<primingId> then replays the result.

Rhys Sullivan committed 4 months ago

Commit 89bd2ad1 parent c46730b4 files · +519 −9Browse files

Unified · Split

4 files
  1. .changeset/mcp-priming-reconnect.md +5 −0
  2. e2e/cloud/mcp-priming-reconnect.test.ts +218 −0
  3. packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +212 −0
  4. patches/agents@0.17.3.patch +84 −9
.changeset/mcp-priming-reconnect.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Let stock MCP clients auto-reconnect and recover a tool result when a POST stream drops mid-call.
e2e/cloud/mcp-priming-reconnect.test.ts+218−0
@@ -0,0 +1,218 @@
1import {
2 request as httpRequest,
3 createServer,
4 type ClientRequest,
5 type ServerResponse,
6} from "node:http";
7import { randomUUID } from "node:crypto";
8import type { AddressInfo } from "node:net";
9
10import { expect } from "@effect/vitest";
11import { Effect } from "effect";
12
13import { Client } from "@modelcontextprotocol/sdk/client/index.js";
14import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
15
16import { scenario } from "../src/scenario";
17import { Mcp, Target } from "../src/services";
18import type { Identity } from "../src/target";
19
20const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;
21
22const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
23
24/**
25 * A minimal HTTP relay that forwards to `origin` and, on `sever()`, destroys
26 * the in-flight POST tools/call socket. This models a real network cut of the
27 * POST tools/call SSE stream that the SDK observes as a stream read error, NOT
28 * a client-initiated fetch abort. The relay itself stays up so the SDK's own
29 * reconnect GET has something to connect back to.
30 *
31 * The cut is armed on a real event — the tools/call POST response headers — so
32 * the trigger is identical whether or not the server emits a priming event.
33 * `severPostMidCall()` waits for that stream to open, then cuts it a bounded
34 * grace later: after any priming event has flushed and been read, but well
35 * before the multi-second tool result.
36 */
37const startSeverableRelay = (
38 originUrl: string,
39): Promise<{
40 readonly url: (path: string) => string;
41 readonly severPostMidCall: () => Promise<void>;
42 readonly close: () => void;
43}> => {
44 const origin = new URL(originUrl);
45 // The tools/call POST hop, captured the moment its response headers arrive
46 // (before any body: this is independent of whether the server emits a priming
47 // event, so the trigger is identical on fixed and unfixed servers). The sever
48 // must land AFTER the stream opened but BEFORE the tool result, so it fires a
49 // bounded grace after the headers — long enough for a priming event to have
50 // flushed and been read by the SDK, far short of the multi-second tool call.
51 const SEVER_GRACE_MS = 2_000;
52 let toolCallHop: { readonly upstream: ClientRequest; readonly res: ServerResponse } | null = null;
53 let onToolCallOpen: (() => void) | null = null;
54 const toolCallOpened = new Promise<void>((resolve) => {
55 onToolCallOpen = resolve;
56 });
57
58 const server = createServer((req, res) => {
59 // Detect the tools/call POST by its request body (initialize also returns
60 // SSE, so a plain content-type check would arm on the wrong stream).
61 const bodyChunks: Buffer[] = [];
62 let isToolCall = false;
63 req.on("data", (chunk: Buffer) => {
64 bodyChunks.push(chunk);
65 if (!isToolCall && Buffer.concat(bodyChunks).toString("utf8").includes('"tools/call"')) {
66 isToolCall = true;
67 }
68 });
69 const upstream = httpRequest(
70 {
71 host: origin.hostname,
72 port: Number(origin.port),
73 path: req.url,
74 method: req.method,
75 headers: { ...req.headers, host: origin.host },
76 },
77 (upstreamRes) => {
78 res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
79 if (
80 isToolCall &&
81 (upstreamRes.headers["content-type"] ?? "").includes("text/event-stream")
82 ) {
83 toolCallHop = { upstream, res };
84 onToolCallOpen?.();
85 }
86 upstreamRes.pipe(res);
87 },
88 );
89 upstream.on("error", () => {
90 try {
91 res.destroy();
92 } catch {
93 /* already torn down */
94 }
95 });
96 req.pipe(upstream);
97 req.on("aborted", () => {
98 try {
99 upstream.destroy();
100 } catch {
101 /* already torn down */
102 }
103 });
104 });
105
106 const severPostMidCall = async (): Promise<void> => {
107 await toolCallOpened;
108 await delay(SEVER_GRACE_MS);
109 const hop = toolCallHop;
110 if (!hop) return;
111 // Destroy BOTH ends so every fetch client (undici and bun) observes a hard
112 // stream error: the client-facing response socket (what the SDK's reader is
113 // attached to) AND the upstream to the origin.
114 for (const destroy of [
115 () => hop.res.socket?.destroy(),
116 () => hop.res.destroy(),
117 () => hop.upstream.socket?.destroy(),
118 () => hop.upstream.destroy(),
119 ]) {
120 try {
121 destroy();
122 } catch {
123 /* already gone */
124 }
125 }
126 };
127
128 return new Promise((resolve) => {
129 server.listen(0, "127.0.0.1", () => {
130 const port = (server.address() as AddressInfo).port;
131 resolve({
132 url: (path: string) => `http://127.0.0.1:${port}${path}`,
133 severPostMidCall,
134 close: () => server.close(),
135 });
136 });
137 });
138};
139
140scenario(
141 "MCP streamable HTTP · a stock SDK client recovers a tool result after its POST stream is severed mid-call",
142 { timeout: 160_000 },
143 Effect.gen(function* () {
144 const target = yield* Target;
145 const mcp = yield* Mcp;
146 const identity = yield* target.newIdentity();
147 const bearer = yield* mcp.mintBearer(emailOf(identity));
148
149 const mcpPath = new URL(target.mcpUrl).pathname;
150 const origin = new URL(target.mcpUrl).origin;
151 const relay = yield* Effect.promise(() => startSeverableRelay(origin));
152
153 // A fetch wrapper for the SDK transport that (1) records the SDK's own
154 // reconnect GET (with its last-event-id) so the test can assert the SDK
155 // actually reconnected, and (2) STRIPS the SDK's abort signal so a severed
156 // relay socket surfaces as a stream read error (the real churn scenario),
157 // not a client-side abort. Auth rides on the transport's requestInit.
158 const reconnectGetEventIds: string[] = [];
159 const drivenFetch: typeof fetch = (input, init) => {
160 const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
161 const method = init?.method ?? (input instanceof Request ? input.method : "GET");
162 const headers = new Headers(
163 init?.headers ?? (input instanceof Request ? input.headers : undefined),
164 );
165 const lastEventId = headers.get("last-event-id");
166 if (method === "GET" && lastEventId) reconnectGetEventIds.push(lastEventId);
167 const { signal: _stripped, ...rest } = init ?? {};
168 return fetch(url, rest);
169 };
170
171 const transport = new StreamableHTTPClientTransport(new URL(relay.url(mcpPath)), {
172 requestInit: { headers: { authorization: `Bearer ${bearer}` } },
173 fetch: drivenFetch,
174 });
175 const client = new Client({ name: "executor-e2e-priming-reconnect", version: "0.0.1" });
176
177 const result = yield* Effect.promise(async () => {
178 try {
179 await client.connect(transport);
180
181 const marker = `MARKER_PRIMING_RECONNECT_${randomUUID()}`;
182 const code = [
183 `const marker = ${JSON.stringify(marker)};`,
184 "await new Promise((resolve) => setTimeout(resolve, 15000));",
185 "return marker;",
186 ].join("\n");
187
188 // Arm the sever: the relay cuts the POST tools/call socket a short
189 // grace after the stream opens (see SEVER_GRACE_MS) — after any priming
190 // event has flushed and been read by the SDK, but ~13s before the tool
191 // result. The trigger (stream open) is identical on a fixed and an
192 // unfixed server, so the ONLY variable is whether the client saw a
193 // priming id to reconnect from.
194 void relay.severPostMidCall();
195 // Explicit request timeout > (call duration + reconnect backoff) so a
196 // successful recover resolves well inside it; the scenario timeout is
197 // the real ceiling.
198 const call = await client.callTool({ name: "execute", arguments: { code } }, undefined, {
199 timeout: 90_000,
200 });
201 return { marker, call, reconnected: reconnectGetEventIds.length > 0 };
202 } finally {
203 await client.close().catch(() => undefined);
204 await delay(200);
205 relay.close();
206 }
207 });
208
209 expect(
210 reconnectGetEventIds.length,
211 "the stock SDK issued a reconnect GET carrying a last-event-id (it had a priming id to resume from)",
212 ).toBeGreaterThan(0);
213 expect(
214 JSON.stringify(result.call),
215 "callTool resolves with the completed tool result after the mid-call sever",
216 ).toContain(result.marker);
217 }),
218);
packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts+212−0
@@ -0,0 +1,212 @@
1// Unit coverage for the POST-stream priming SSE event (see
2// patches/agents@0.17.3.patch). Executor's POST tools/call stream used to emit
3// its first and only event `id:` together with the final result, so the MCP TS
4// SDK's StreamableHTTPClientTransport never set hasPrimingEvent and would not
5// auto-reconnect a stream that dropped mid-call: callTool hung while the DO
6// held the completed result. The patch writes a priming event as the first
7// frame on the POST stream, carrying a real event-store id that sorts before
8// the response so a `last-event-id: <primingId>` reconnect replays the result.
9//
10// Two properties are pinned here against real code:
11// 1. Event-store ordering + replay: with the real DurableObjectEventStore, a
12// priming event stored before the response sorts first, and
13// replayEventsAfter(primingId) yields exactly the response.
14// 2. Client contract: fed the exact priming frame the transport emits, the
15// real SDK StreamableHTTPClientTransport records the priming id (so it
16// would reconnect) WITHOUT dispatching it as a JSON-RPC message, then
17// dispatches a following result frame normally.
18import { describe, expect, it } from "@effect/vitest";
19import { DurableObjectEventStore } from "agents/mcp";
20import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
21import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
22
23type ListOptions = {
24 readonly prefix?: string;
25 readonly start?: string;
26 readonly limit?: number;
27 readonly reverse?: boolean;
28};
29
30/** Minimal in-memory stand-in for DurableObjectStorage's sorted KV surface. */
31const makeFakeStorage = () => {
32 const entries = new Map<string, unknown>();
33 return {
34 entries,
35 put: (key: string, value: unknown) => {
36 entries.set(key, value);
37 return Promise.resolve();
38 },
39 delete: (keys: string | ReadonlyArray<string>) => {
40 for (const key of Array.isArray(keys) ? keys : [keys]) entries.delete(key);
41 return Promise.resolve();
42 },
43 list: (options: ListOptions = {}) => {
44 const keys = [...entries.keys()]
45 .filter((key) => (options.prefix === undefined ? true : key.startsWith(options.prefix)))
46 .filter((key) => (options.start === undefined ? true : key >= options.start))
47 .sort();
48 if (options.reverse === true) keys.reverse();
49 const limited = options.limit === undefined ? keys : keys.slice(0, options.limit);
50 return Promise.resolve(new Map(limited.map((key) => [key, entries.get(key)])));
51 },
52 };
53};
54
55// The exact priming notification the patched transport persists, and the exact
56// SSE frame it writes to the client. Mirrors emitPrimingEvent /
57// writePrimingSSEEvent in patches/agents@0.17.3.patch: a benign JSON-RPC
58// notification stored (so a plain-GET replay via writeSSEEvent is ignorable),
59// framed live under a non-`message` event type so the SDK primes but does not
60// dispatch it.
61const PRIMING_MESSAGE = {
62 jsonrpc: "2.0" as const,
63 method: "notifications/message",
64 params: { level: "debug", data: "mcp-stream-priming" },
65};
66const primingFrame = (eventId: string): string =>
67 `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(PRIMING_MESSAGE)}\n\n`;
68const messageFrame = (eventId: string, message: unknown): string =>
69 `event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`;
70
71describe("POST-stream priming event: store ordering and replay", () => {
72 it("persists the priming event before the response so replayEventsAfter(primingId) yields the response", async () => {
73 const storage = makeFakeStorage();
74 const store = new DurableObjectEventStore(storage as never);
75 const streamId = "post-stream";
76
77 // Transport order: priming event first, then the tool response.
78 const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE);
79 const response = {
80 jsonrpc: "2.0" as const,
81 id: 1,
82 result: { content: [{ type: "text", text: "MARKER" }] },
83 };
84 const responseId = await store.storeEvent(streamId, response);
85
86 expect(primingId, "priming event is seq 1 for the stream").toBe(`${streamId}:0000000000000001`);
87 expect(responseId, "response is seq 2, after the priming id").toBe(
88 `${streamId}:0000000000000002`,
89 );
90 expect(primingId < responseId, "priming id sorts strictly before the response id").toBe(true);
91
92 const replayed: Array<{ readonly eventId: string; readonly message: unknown }> = [];
93 await store.replayEventsAfter(primingId, {
94 send: async (eventId: string, message: unknown) => {
95 replayed.push({ eventId, message });
96 },
97 });
98
99 expect(
100 replayed.map((entry) => entry.eventId),
101 "a reconnect with last-event-id=<primingId> replays exactly the response",
102 ).toEqual([responseId]);
103 expect(replayed[0]?.message).toEqual(response);
104 });
105
106 it("does not replay the priming event itself on a last-event-id reconnect", async () => {
107 const storage = makeFakeStorage();
108 const store = new DurableObjectEventStore(storage as never);
109 const streamId = "post-stream";
110 const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE);
111
112 const replayed: string[] = [];
113 await store.replayEventsAfter(primingId, {
114 send: async (eventId: string) => {
115 replayed.push(eventId);
116 },
117 });
118
119 expect(replayed, "nothing after the priming event yet, so replay is empty").toEqual([]);
120 });
121});
122
123describe("POST-stream priming event: SDK client contract", () => {
124 // Drive the real SDK StreamableHTTPClientTransport with a controlled fetch
125 // that returns a POST tools/call SSE stream: priming frame first, then the
126 // result. Assert the SDK records the priming id as a resumption token (so it
127 // would reconnect) but only dispatches the result as a JSON-RPC message.
128 const drivePostStream = async (frames: ReadonlyArray<string>) => {
129 const encoder = new TextEncoder();
130 const body = new ReadableStream<Uint8Array>({
131 start(controller) {
132 for (const frame of frames) controller.enqueue(encoder.encode(frame));
133 controller.close();
134 },
135 });
136 // oxlint-disable-next-line executor/no-double-cast -- boundary: a minimal fetch stub for a unit test; only the Response shape the SDK reads matters.
137 const fetchStub = (async () =>
138 new Response(body, {
139 status: 200,
140 headers: { "content-type": "text/event-stream" },
141 })) as unknown as typeof fetch;
142
143 const transport = new StreamableHTTPClientTransport(new URL("https://executor.sh/mcp"), {
144 fetch: fetchStub,
145 });
146
147 const messages: JSONRPCMessage[] = [];
148 const resumptionTokens: string[] = [];
149 transport.onmessage = (message) => {
150 messages.push(message);
151 };
152
153 await transport.start();
154 // POST a tools/call request; the stub returns the SSE stream above.
155 await transport.send(
156 { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "execute", arguments: {} } },
157 {
158 onresumptiontoken: (token: string) => {
159 resumptionTokens.push(token);
160 },
161 },
162 );
163 // Let the SSE stream drain.
164 for (let i = 0; i < 20; i += 1) await Promise.resolve();
165 await transport.close();
166 return { messages, resumptionTokens };
167 };
168
169 it("records the priming id as a resumption token without dispatching it, then dispatches the result", async () => {
170 const primingId = "post-stream:0000000000000001";
171 const responseId = "post-stream:0000000000000002";
172 const result = {
173 jsonrpc: "2.0" as const,
174 id: 1,
175 result: { content: [{ type: "text", text: "MARKER" }] },
176 };
177
178 const { messages, resumptionTokens } = await drivePostStream([
179 primingFrame(primingId),
180 messageFrame(responseId, result),
181 ]);
182
183 expect(
184 resumptionTokens,
185 "the SDK records the priming id first (this is what sets hasPrimingEvent), then the response id",
186 ).toEqual([primingId, responseId]);
187 expect(
188 messages,
189 "the priming frame is NOT dispatched as a JSON-RPC message; only the result is",
190 ).toEqual([result]);
191 });
192
193 it("would not prime on a stream whose first event id arrives only with the result (the old behavior)", async () => {
194 // Sanity anchor for the fix: without a priming frame, the first recorded
195 // resumption token is the result's own id, which the SDK only sees at the
196 // same instant it receives the result. There is no earlier id to reconnect
197 // from, which is exactly the hang this patch removes.
198 const responseId = "post-stream:0000000000000001";
199 const result = {
200 jsonrpc: "2.0" as const,
201 id: 1,
202 result: { content: [{ type: "text", text: "MARKER" }] },
203 };
204
205 const { messages, resumptionTokens } = await drivePostStream([
206 messageFrame(responseId, result),
207 ]);
208
209 expect(resumptionTokens, "the only id ever seen is the result's own id").toEqual([responseId]);
210 expect(messages).toEqual([result]);
211 });
212});
patches/agents@0.17.3.patch+84−9
@@ -19,7 +19,7 @@ index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202 · 18 unmodified lines
19 McpAgent,19 McpAgent,
20 type McpAuthContext,20 type McpAuthContext,
21diff --git a/dist/mcp/index.js b/dist/mcp/index.js21diff --git a/dist/mcp/index.js b/dist/mcp/index.js
22index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a62be2e41 10064422index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..c3601bdb084bf82ebfea23cdabe1fab5f6d71fb3 100644
23--- a/dist/mcp/index.js23--- a/dist/mcp/index.js
24+++ b/dist/mcp/index.js24+++ b/dist/mcp/index.js
25@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/25@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
@@ -577,7 +577,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 551 unmodified lines
577 }577 }
578 /**578 /**
579 * Writes an event to the SSE stream with proper formatting579 * Writes an event to the SSE stream with proper formatting
580@@ -689,6 +977,8 @@ var StreamableHTTPServerTransport = class {580@@ -689,10 +977,65 @@ var StreamableHTTPServerTransport = class {
581 return connection.send(JSON.stringify({581 return connection.send(JSON.stringify({
582 type: "cf_mcp_agent_event",582 type: "cf_mcp_agent_event",
583 event: eventData,583 event: eventData,
@@ -586,7 +586,82 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 2 unmodified lines
586 close586 close
587 }));587 }));
588 }588 }
589@@ -777,9 +1067,11 @@ var StreamableHTTPServerTransport = class {589 /**
590+ * Persist and write the priming SSE event for a freshly opened POST
591+ * stream. The stored message is a benign JSON-RPC notification so that
592+ * if a plain recovery GET ever replays this stream via
593+ * {@link writeSSEEvent} (which forces `event: message`), a compliant
594+ * client parses and ignores it rather than erroring. The live priming
595+ * frame, however, uses a non-`message` SSE event type so the SDK
596+ * records the id (setting hasPrimingEvent / lastEventId) WITHOUT
597+ * dispatching it as a JSON-RPC message: its SSE loop skips any event
598+ * whose type is not `message`. A data line is required because the
599+ * SDK's SSE parser drops events with empty data before recording the
600+ * id, so an id-only frame would not prime.
601+ */
602+ async emitPrimingEvent(agent, connection, streamId) {
603+ if (!this._eventStore) return;
604+ const primingMessage = {
605+ jsonrpc: "2.0",
606+ method: "notifications/message",
607+ params: {
608+ level: "debug",
609+ data: "mcp-stream-priming"
610+ }
611+ };
612+ let eventId;
613+ try {
614+ eventId = await this._eventStore.storeEvent(streamId, primingMessage);
615+ } catch (error) {
616+ this.onerror?.(error);
617+ return;
618+ }
619+ if (!eventId) return;
620+ try {
621+ this.writePrimingSSEEvent(connection, primingMessage, eventId);
622+ } catch (error) {
623+ this.onerror?.(error);
624+ }
625+ }
626+ /**
627+ * Write a priming SSE frame: `event: mcp-priming`, an `id:` carrying a
628+ * real replayable event-store id, and a data line the SDK ignores
629+ * because the event type is not `message`. Never sets `close`.
630+ */
631+ writePrimingSSEEvent(connection, message, eventId) {
632+ let eventData = "event: mcp-priming\n";
633+ eventData += `id: ${eventId}\n`;
634+ eventData += `data: ${JSON.stringify(message)}\n\n`;
635+ return connection.send(JSON.stringify({
636+ type: "cf_mcp_agent_event",
637+ event: eventData,
638+ eventId,
639+ streamId: eventId.slice(0, eventId.lastIndexOf(":"))
640+ }));
641+ }
642+ /**
643 * Handles POST requests containing JSON-RPC messages
644 */
645 async handlePostRequest(req, parsedBody) {
646@@ -733,6 +1076,17 @@ var StreamableHTTPServerTransport = class {
647 };
648 connection.setState(postState);
649 if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds);
650+ // Emit a priming SSE event as the very first frame on this POST
651+ // response stream, before dispatching the request(s). The MCP TS SDK
652+ // only auto-reconnects a dropped POST SSE stream when it saw an event
653+ // `id:` before the drop (hasPrimingEvent). Without this, a network
654+ // blip or edge close mid-call leaves the SDK with hasPrimingEvent
655+ // false, so it never issues the recovery GET that replays the
656+ // persisted result, and callTool hangs forever. The priming event is
657+ // a real event-store entry so its id sorts BEFORE the eventual
658+ // response; a reconnect with `last-event-id: <primingId>` replays
659+ // everything after it (i.e. the result). See patches/agents patch.
660+ await this.emitPrimingEvent(agent, connection, streamId);
661 for (const message of messages) {
662 if (this.messageInterceptor) {
663 if (await this.messageInterceptor(message, {
664@@ -777,9 +1131,11 @@ var StreamableHTTPServerTransport = class {
590 } catch (error) {665 } catch (error) {
591 this.onerror?.(error);666 this.onerror?.(error);
592 }667 }
@@ -600,7 +675,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 7 unmodified lines
600 }675 }
601 }676 }
602 async send(message, options) {677 async send(message, options) {
603@@ -861,12 +1153,10 @@ var StreamableHTTPServerTransport = class {678@@ -861,12 +1217,10 @@ var StreamableHTTPServerTransport = class {
604 *679 *
605 * ## Lifecycle680 * ## Lifecycle
606 *681 *
@@ -617,7 +692,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 10 unmodified lines
617 *692 *
618 * Standalone GET stream events (`_GET_stream`) are *not* cleared693 * Standalone GET stream events (`_GET_stream`) are *not* cleared
619 * automatically; they accumulate for the lifetime of the DO. Bounded694 * automatically; they accumulate for the lifetime of the DO. Bounded
620@@ -899,6 +1189,7 @@ var DurableObjectEventStore = class DurableObjectEventStore {695@@ -899,6 +1253,7 @@ var DurableObjectEventStore = class DurableObjectEventStore {
621 const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`;696 const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`;
622 const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`;697 const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`;
623 await this.storage.put(eventKey, message);698 await this.storage.put(eventKey, message);
@@ -625,7 +700,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 1 unmodified line
625 return eventId;700 return eventId;
626 }701 }
627 async getStreamIdForEventId(eventId) {702 async getStreamIdForEventId(eventId) {
628@@ -915,9 +1206,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {703@@ -915,9 +1270,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {
629 start: startKey,704 start: startKey,
630 limit: DurableObjectEventStore.REPLAY_LIMIT705 limit: DurableObjectEventStore.REPLAY_LIMIT
631 });706 });
@@ -686,7 +761,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 54 unmodified lines
686 /**761 /**
687 * Drop the event log for a single stream. Called by the transport762 * Drop the event log for a single stream. Called by the transport
688 * immediately after a POST's final response has been written to the763 * immediately after a POST's final response has been written to the
689@@ -973,6 +1314,9 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";764@@ -973,6 +1378,9 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";
690 DurableObjectEventStore.SEQ_PAD = 16;765 DurableObjectEventStore.SEQ_PAD = 16;
691 DurableObjectEventStore.DELETE_CHUNK = 128;766 DurableObjectEventStore.DELETE_CHUNK = 128;
692 DurableObjectEventStore.REPLAY_LIMIT = 1e3;767 DurableObjectEventStore.REPLAY_LIMIT = 1e3;
@@ -696,7 +771,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 3 unmodified lines
696 //#endregion771 //#endregion
697 //#region src/mcp/client-transports.ts772 //#region src/mcp/client-transports.ts
698 /**773 /**
699@@ -1381,6 +1725,47 @@ var McpAgent = class McpAgent extends Agent {774@@ -1381,6 +1789,47 @@ var McpAgent = class McpAgent extends Agent {
700 async deleteStreamRequestIds(streamId) {775 async deleteStreamRequestIds(streamId) {
701 await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);776 await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
702 }777 }
@@ -744,7 +819,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 41 unmodified lines
744 /**819 /**
745 * Reverse lookup: find which POST stream a given `requestId` belongs820 * Reverse lookup: find which POST stream a given `requestId` belongs
746 * to, and return the stream's full `requestIds` list in the same821 * to, and return the stream's full `requestIds` list in the same
747@@ -1697,7 +2082,8 @@ var McpAgent = class McpAgent extends Agent {822@@ -1697,7 +2146,8 @@ var McpAgent = class McpAgent extends Agent {
748 }823 }
749 };824 };
750 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";825 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";