Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/legacy-fallback-response-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

`legacy: 'stateless'` now honors `responseMode`.
21 changes: 14 additions & 7 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand All @@ -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);

Expand Down Expand Up @@ -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);
}

/* ------------------------------------------------------------------------ *
Expand Down Expand Up @@ -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<Response> {
const claimedRevision = route.classification.revision;
Expand Down
19 changes: 19 additions & 0 deletions packages/server/test/server/createMcpHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
94 changes: 94 additions & 0 deletions packages/server/test/server/legacyStatelessFallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(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');
});
});