SDK Version
@modelcontextprotocol/server 2.0.0 (also present in current createMcpHandler.ts on main)
Environment
Node.js 26.5.0, macOS (darwin 25.5.0) — but nothing platform-specific
Description
createMcpHandler wraps server.onclose once per handled request (packages/server/src/server/createMcpHandler.ts):
const previousOnClose = server.onclose;
inflight.add(server);
server.onclose = () => {
inflight.delete(server);
previousOnClose?.();
};
If the factory passed to createMcpHandler returns the same McpServer instance for every session, each request adds another layer to this chain. The chain grows without bound:
- Memory leak — every request retains one more closure (plus whatever it captures) for the lifetime of the server.
- Process crash — when the chain eventually runs (session cleanup under sustained load, or
handler.close()), it recurses one stack frame per accumulated wrapper and dies with RangeError: Maximum call stack size exceeded. In our runs the overflow lands at roughly 19–25k accumulated sessions.
Notably, the crash surfaces as an uncaught async error after handler.close() has already resolved, so the caller can't even try/catch around close() — the process just dies:
closing handler…
closed cleanly <-- close() resolved
RangeError: Maximum call stack size exceeded
at Set.delete (<anonymous>)
at server.onclose (@modelcontextprotocol/server/dist/index.mjs:1295:19)
at server.onclose (@modelcontextprotocol/server/dist/index.mjs:1296:21)
at server.onclose (@modelcontextprotocol/server/dist/index.mjs:1296:21)
... (thousands of identical frames)
Under sustained concurrent HTTP load the same overflow fires mid-traffic (whenever cleanup closes an accumulated session), taking down an otherwise healthy server after ~19k requests.
Reusing an instance is admittedly not the intended use of the factory — a fresh server per session is the fix on the caller side, and switching to that resolved it for us. But it's an easy mistake to make (the factory signature happily accepts () => sharedServer, every request still returns 200, and nothing hints at a problem until the process dies tens of thousands of requests later). It's also the natural workaround users reach for after reading about per-request allocation cost in #2090, which makes the trap more likely to be hit.
Steps to Reproduce
No HTTP or concurrency needed — in-process handler.fetch() is enough:
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { z } from 'zod';
const server = new McpServer({ name: 'repro', version: '0.0.1' });
server.registerTool(
'echo',
{ description: 'Echo.', inputSchema: z.object({ value: z.string() }) },
async ({ value }) => ({ content: [{ type: 'text', text: value }] }),
);
const handler = createMcpHandler(() => server); // same instance every session
const body = JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
params: {
_meta: {
'io.modelcontextprotocol/protocolVersion': '2026-07-28',
'io.modelcontextprotocol/clientInfo': { name: 'repro', version: '0' },
'io.modelcontextprotocol/clientCapabilities': {},
},
},
});
for (let i = 1; i <= 25_000; i++) {
const res = await handler.fetch(
new Request('http://localhost/mcp', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
'MCP-Protocol-Version': '2026-07-28',
'Mcp-Method': 'tools/list',
},
body,
}),
);
const text = await res.text();
if (res.status !== 200 || text.includes('"error"')) throw new Error(`request ${i}: ${res.status}`);
if (i % 5000 === 0) console.log(`${i} requests handled, all 200`);
}
console.log('closing handler…');
await handler.close();
console.log('closed cleanly'); // prints — then the process crashes anyway
Expected Behavior
Some combination of:
- Graceful degradation: track per-server cleanup without recursive function chaining — e.g. keep the inflight bookkeeping in a
Set/listener structure keyed by server rather than wrapping onclose, so a reused instance costs O(1) per request instead of an ever-growing closure chain.
- Fail fast: if the factory returns a server that's already in
inflight, throw (or warn) immediately — "factory must return a fresh McpServer per session" at request 2 is far kinder than an uncatchable stack overflow at request 20,000.
- At minimum, a docs note on
createMcpHandler that the factory must return a fresh instance per call, and why.
Related
Found while load-testing an MCP gateway whose test fixture reused one McpServer across sessions; fixed on our side by building a fresh server per session.
SDK Version
@modelcontextprotocol/server2.0.0 (also present in currentcreateMcpHandler.tson main)Environment
Node.js 26.5.0, macOS (darwin 25.5.0) — but nothing platform-specific
Description
createMcpHandlerwrapsserver.oncloseonce per handled request (packages/server/src/server/createMcpHandler.ts):If the factory passed to
createMcpHandlerreturns the sameMcpServerinstance for every session, each request adds another layer to this chain. The chain grows without bound:handler.close()), it recurses one stack frame per accumulated wrapper and dies withRangeError: Maximum call stack size exceeded. In our runs the overflow lands at roughly 19–25k accumulated sessions.Notably, the crash surfaces as an uncaught async error after
handler.close()has already resolved, so the caller can't eventry/catcharoundclose()— the process just dies:Under sustained concurrent HTTP load the same overflow fires mid-traffic (whenever cleanup closes an accumulated session), taking down an otherwise healthy server after ~19k requests.
Reusing an instance is admittedly not the intended use of the factory — a fresh server per session is the fix on the caller side, and switching to that resolved it for us. But it's an easy mistake to make (the factory signature happily accepts
() => sharedServer, every request still returns 200, and nothing hints at a problem until the process dies tens of thousands of requests later). It's also the natural workaround users reach for after reading about per-request allocation cost in #2090, which makes the trap more likely to be hit.Steps to Reproduce
No HTTP or concurrency needed — in-process
handler.fetch()is enough:Expected Behavior
Some combination of:
Set/listener structure keyed by server rather than wrappingonclose, so a reused instance costs O(1) per request instead of an ever-growing closure chain.inflight, throw (or warn) immediately — "factory must return a fresh McpServer per session" at request 2 is far kinder than an uncatchable stack overflow at request 20,000.createMcpHandlerthat the factory must return a fresh instance per call, and why.Related
RangeErrorfrom recursive close chaining inwebStandardStreamableHttp.js(closed); this is the v2createMcpHandlersibling.Found while load-testing an MCP gateway whose test fixture reused one
McpServeracross sessions; fixed on our side by building a fresh server per session.