diff --git a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts index 8583b19f..51e93874 100644 --- a/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts +++ b/clients/web/src/test/integration/mcp/remote/remoteClientTransport-unit.test.ts @@ -432,6 +432,38 @@ describe("RemoteClientTransport (focused branch coverage)", () => { expect(onFetchStreamUpdate).toHaveBeenCalledTimes(1); }); + it("synthesizes the close on close() for a consumer that registered only onFetchStreamUpdate", async () => { + const onFetchStreamUpdate = vi.fn(); + const { response, push } = createPushableEventStream(); + const t = makeTransport( + { events: () => response }, + { onFetchStreamUpdate }, + ); + await t.start(); + push({ + type: "fetch_request", + data: { + id: "stream", + method: "GET", + url: "http://x/mcp", + timestamp: new Date().toISOString(), + requestHeaders: {}, + responseStatus: 200, + responseHeaders: { "content-type": "text/event-stream" }, + } as never, + }); + push({ + type: "fetch_stream_update", + data: { id: "stream", eventCount: 3 }, + }); + await tick(); + await t.close(); + expect(onFetchStreamUpdate).toHaveBeenLastCalledWith("stream", { + eventCount: 3, + closedAt: expect.any(Date), + }); + }); + it("drops fetch_stream_update events when no handler is registered", async () => { const onFetchRequest = vi.fn(); const t = makeTransport( diff --git a/core/mcp/remote/remoteClientTransport.ts b/core/mcp/remote/remoteClientTransport.ts index 202310c4..c8c8c92e 100644 --- a/core/mcp/remote/remoteClientTransport.ts +++ b/core/mcp/remote/remoteClientTransport.ts @@ -552,11 +552,11 @@ export class RemoteClientTransport implements Transport { this.resetSseResponseWait(progressToken); } this.onmessage?.(msg, undefined); - } else if ( - parsed.type === "fetch_request" && - this.options.onFetchRequest - ) { + } else if (parsed.type === "fetch_request") { const entry = parsed.data; + // Stream bookkeeping runs whether or not the consumer logs + // requests: `onFetchStreamUpdate` is documented to work on its + // own, and `close()` needs to know which streams are open. if ( isLongLivedStreamResponse( entry.method, @@ -565,7 +565,7 @@ export class RemoteClientTransport implements Transport { ) { this.openStreams.set(entry.id, 0); } - this.options.onFetchRequest({ + this.options.onFetchRequest?.({ ...entry, timestamp: typeof entry.timestamp === "string"