From 0134d9a24827f63265c34619978b0757bd80b2b5 Mon Sep 17 00:00:00 2001 From: dielduarte Date: Thu, 6 Aug 2026 15:02:38 -0300 Subject: [PATCH] fix(server): thread responseMode into the legacy stateless fallback createLegacyStatelessFallback built a WebStandardStreamableHTTPServerTransport per legacy request but never passed it enableJsonResponse, so a legacy- classified request always answered over SSE, flushing its 200 before a tool call resolved. The modern leg already solved the equivalent problem via responseMode: 'json' (backed by PerRequestHTTPServerTransport); it just never reached the legacy leg's transport, which has the same knob under a different name. responseMode: 'json' now sets enableJsonResponse: true on the legacy leg's transport too, buffering the reply until the result is known - the same guarantee the modern leg already had. 'auto' and 'sse' are equivalent on this leg (both leave its SSE-first default unchanged), since the transport has no partial-upgrade concept to distinguish them. legacyStatelessFallback(factory, onerror, responseMode) gains a third, optional parameter mirroring the entry's option, so hand-wired compositions that use the standalone building block get the same guarantee without reimplementing the legacy transport wiring by hand. --- .changeset/legacy-fallback-response-mode.md | 5 + .../server/src/server/createMcpHandler.ts | 21 +++-- .../test/server/createMcpHandler.test.ts | 19 ++++ .../server/legacyStatelessFallback.test.ts | 94 +++++++++++++++++++ 4 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 .changeset/legacy-fallback-response-mode.md diff --git a/.changeset/legacy-fallback-response-mode.md b/.changeset/legacy-fallback-response-mode.md new file mode 100644 index 0000000000..377d21a373 --- /dev/null +++ b/.changeset/legacy-fallback-response-mode.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`legacy: 'stateless'` now honors `responseMode`. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index a484869944..ea368f7b8a 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -166,11 +166,12 @@ export interface CreateMcpHandlerOptions { /** Callback for out-of-band errors and rejected requests (reporting only; it never alters the response). */ onerror?: (error: Error) => void; /** - * Response shaping for modern (2026-07-28) request exchanges: + * Response shaping, applied on both legs this entry serves: * * - `'auto'` (default) — a single JSON body unless the handler emits a * related message before its result, in which case the response upgrades - * to an SSE stream. + * to an SSE stream. The legacy leg has no partial-upgrade concept, so it + * behaves like `'sse'` there. * - `'sse'` — always stream. * - `'json'` — never stream. **Mid-call notifications (progress, logging, * any related message emitted before the result) are dropped** — only the @@ -310,7 +311,8 @@ function internalServerErrorResponse(id: RequestId | null = null): Response { function createLegacyStatelessFallback( factory: McpServerFactory, onerror?: (error: Error) => void, - keepAliveMs?: number + keepAliveMs?: number, + responseMode?: PerRequestResponseMode ): LegacyHttpHandler { return async (request, options) => { if (request.method.toUpperCase() !== 'POST') { @@ -324,7 +326,8 @@ function createLegacyStatelessFallback( }); const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, - ...(keepAliveMs !== undefined && { keepAliveMs }) + ...(keepAliveMs !== undefined && { keepAliveMs }), + ...(responseMode === 'json' && { enableJsonResponse: true }) }); await product.connect(transport); @@ -398,8 +401,12 @@ function createLegacyStatelessFallback( }; } -export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler { - return createLegacyStatelessFallback(factory, onerror); +export function legacyStatelessFallback( + factory: McpServerFactory, + onerror?: (error: Error) => void, + responseMode?: PerRequestResponseMode +): LegacyHttpHandler { + return createLegacyStatelessFallback(factory, onerror, undefined, responseMode); } /* ------------------------------------------------------------------------ * @@ -645,7 +652,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // The default posture is the stateless fallback; 'reject' is the only way // to turn legacy serving off (modern-only strict). const legacyHandler: LegacyHttpHandler | undefined = - legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs); + legacy === 'reject' ? undefined : createLegacyStatelessFallback(factory, reportError, options.keepAliveMs, responseMode); async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise { const claimedRevision = route.classification.revision; diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index ded506e57c..add66ec951 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -776,6 +776,25 @@ describe('createMcpHandler — responseMode', () => { expect(response.headers.get('content-type')).toContain('text/event-stream'); expect(await response.text()).toContain('eager stream'); }); + + it("responseMode: 'json' never streams legacy-classified requests", async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory, { responseMode: 'json' }); + + const response = await handler.fetch( + postRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'progress-then-echo', arguments: { text: 'legacy json only' } } + }) + ); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + const text = await response.text(); + expect(text).not.toContain('notifications/progress'); + expect(text).toContain('legacy json only'); + }); }); describe('createMcpHandler — handler faces', () => { diff --git a/packages/server/test/server/legacyStatelessFallback.test.ts b/packages/server/test/server/legacyStatelessFallback.test.ts index 50247ae6ab..092f74eeda 100644 --- a/packages/server/test/server/legacyStatelessFallback.test.ts +++ b/packages/server/test/server/legacyStatelessFallback.test.ts @@ -182,3 +182,97 @@ describe('legacyStatelessFallback', () => { expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); }); }); + +describe('legacyStatelessFallback — responseMode', () => { + function gatedToolHandler(): { factory: () => McpServer; release: () => void } { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + const factory = (): McpServer => { + const mcpServer = new McpServer({ name: 'fallback-response-mode', version: '1.0.0' }); + mcpServer.registerTool('gated', { inputSchema: z.object({}) }, async () => { + await gate; + return { content: [{ type: 'text', text: 'done' }] }; + }); + return mcpServer; + }; + return { factory, release }; + } + + it.each([ + ['is left at its default', undefined], + ["is 'auto'", 'auto'], + ["is 'sse'", 'sse'] + ] as const)('answers over SSE before the gated tool call resolves when responseMode %s', async (_label, responseMode) => { + const { factory, release } = gatedToolHandler(); + const handler = legacyStatelessFallback(factory, undefined, responseMode); + + let resolved = false; + const pending = handler( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'gated', arguments: {} } }) + ).then(response => { + resolved = true; + return response; + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + expect(resolved).toBe(true); + + const response = await pending; + expect(response.headers.get('content-type')).toContain('text/event-stream'); + + release(); + expect(await response.text()).toContain('done'); + }); + + it("responseMode: 'json' does not answer until the gated tool call resolves, and buffers a single JSON body", async () => { + const { factory, release } = gatedToolHandler(); + const handler = legacyStatelessFallback(factory, undefined, 'json'); + + let resolved = false; + const pending = handler( + postRequest({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'gated', arguments: {} } }) + ).then(response => { + resolved = true; + return response; + }); + + await new Promise(resolve => setTimeout(resolve, 20)); + expect(resolved).toBe(false); + + release(); + const response = await pending; + expect(resolved).toBe(true); + expect(response.headers.get('content-type')).toContain('application/json'); + expect(await response.text()).toContain('done'); + }); + + it("responseMode: 'json' drops mid-call notifications, delivering only the terminal result", async () => { + const handler = legacyStatelessFallback( + () => { + const mcpServer = new McpServer({ name: 'fallback-response-mode-notify', version: '1.0.0' }); + mcpServer.registerTool('progress-then-echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'p', progress: 1 } }); + return { content: [{ type: 'text', text }] }; + }); + return mcpServer; + }, + undefined, + 'json' + ); + + const response = await handler( + postRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'progress-then-echo', arguments: { text: 'json only' } } + }) + ); + expect(response.headers.get('content-type')).toContain('application/json'); + const text = await response.text(); + expect(text).not.toContain('notifications/progress'); + expect(text).toContain('json only'); + }); +});