modelcontextprotocol / modelcontextprotocol/typescript-sdk
createMcpHandler: reused McpServer instance grows an unbounded onclose chain — memory leak, then uncatchable RangeError after ~20k requests
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 13.4k
- Forks
- 2.2k
- Avg merge
- 3d 15h
- Merged PRs (30d)
- 4
Description
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 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 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 wrappingonclose, 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
createMcpHandlerthat the factory must return a fresh instance per call, and why.
Related
- #1699 — earlier v1
RangeErrorfrom recursive close chaining inwebStandardStreamableHttp.js(closed); this is the v2createMcpHandlersibling. - #2090 — per-request allocation cost in stateless mode; instance reuse is the tempting workaround that walks straight into this crash.
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with packages/server/src/server/createMcpHandler.ts and run the in-process handler.fetch reproduction using a shared McpServer. Trace how onclose and inflight are updated across requests, then implement an agreed fix that avoids recursive growth or fails fast for reused instances; verify that repeated requests and handler.close() do not produce the reported stack overflow.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100