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'); + }); +});