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.
Unified · Split
| @@ -0,0 +1,5 @@ | |||
| 1 | --- | ||
| 2 | "executor": patch | ||
| 3 | --- | ||
| 4 | |||
| 5 | Let stock MCP clients auto-reconnect and recover a tool result when a POST stream drops mid-call. | ||
| @@ -0,0 +1,218 @@ | |||
| 1 | import { | ||
| 2 | request as httpRequest, | ||
| 3 | createServer, | ||
| 4 | type ClientRequest, | ||
| 5 | type ServerResponse, | ||
| 6 | } from "node:http"; | ||
| 7 | import { randomUUID } from "node:crypto"; | ||
| 8 | import type { AddressInfo } from "node:net"; | ||
| 9 | |||
| 10 | import { expect } from "@effect/vitest"; | ||
| 11 | import { Effect } from "effect"; | ||
| 12 | |||
| 13 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 14 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 15 | |||
| 16 | import { scenario } from "../src/scenario"; | ||
| 17 | import { Mcp, Target } from "../src/services"; | ||
| 18 | import type { Identity } from "../src/target"; | ||
| 19 | |||
| 20 | const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | ||
| 21 | |||
| 22 | const 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 | */ | ||
| 37 | const 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 | |||
| 140 | scenario( | ||
| 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 | ); | ||
| @@ -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. | ||
| 18 | import { describe, expect, it } from "@effect/vitest"; | ||
| 19 | import { DurableObjectEventStore } from "agents/mcp"; | ||
| 20 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 21 | import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; | ||
| 22 | |||
| 23 | type 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. */ | ||
| 31 | const 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. | ||
| 61 | const PRIMING_MESSAGE = { | ||
| 62 | jsonrpc: "2.0" as const, | ||
| 63 | method: "notifications/message", | ||
| 64 | params: { level: "debug", data: "mcp-stream-priming" }, | ||
| 65 | }; | ||
| 66 | const primingFrame = (eventId: string): string => | ||
| 67 | `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(PRIMING_MESSAGE)}\n\n`; | ||
| 68 | const messageFrame = (eventId: string, message: unknown): string => | ||
| 69 | `event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`; | ||
| 70 | |||
| 71 | describe("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 | |||
| 123 | describe("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 | }); | ||
| @@ -19,7 +19,7 @@ index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202 · 18 unmodified lines | |||
| 19 | McpAgent, | 19 | McpAgent, |
| 20 | type McpAuthContext, | 20 | type McpAuthContext, |
| 21 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js | 21 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js |
| 22 | index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a62be2e41 100644 | 22 | index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..c3601bdb084bf82ebfea23cdabe1fab5f6d71fb3 100644 |
| 23 | --- a/dist/mcp/index.js | 23 | --- a/dist/mcp/index.js |
| 24 | +++ b/dist/mcp/index.js | 24 | +++ 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 formatting | 579 | * 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 | close | 586 | 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 | * ## Lifecycle | 680 | * ## Lifecycle |
| 606 | * | 681 | * |
| @@ -617,7 +692,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..b0d1c55eda79324b6d76a1177519d26a · 10 unmodified lines | |||
| 617 | * | 692 | * |
| 618 | * Standalone GET stream events (`_GET_stream`) are *not* cleared | 693 | * Standalone GET stream events (`_GET_stream`) are *not* cleared |
| 619 | * automatically; they accumulate for the lifetime of the DO. Bounded | 694 | * 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_LIMIT | 705 | 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 transport | 762 | * Drop the event log for a single stream. Called by the transport |
| 688 | * immediately after a POST's final response has been written to the | 763 | * 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 | //#endregion | 771 | //#endregion |
| 697 | //#region src/mcp/client-transports.ts | 772 | //#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` belongs | 820 | * Reverse lookup: find which POST stream a given `requestId` belongs |
| 746 | * to, and return the stream's full `requestIds` list in the same | 821 | * 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__:"; |