modelcontextprotocol / modelcontextprotocol/typescript-sdk

createMcpHandler: reused McpServer instance grows an unbounded onclose chain — memory leak, then uncatchable RangeError after ~20k requests

Open
#2,607 1 comment 3 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug P2 ready for work spec-2026-07-28 v2
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:

  1. Memory leak — every request retains one more closure (plus whatever it captures) for the lifetime of the server.
  2. 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

  • #1699 — earlier v1 RangeError from recursive close chaining in webStandardStreamableHttp.js (closed); this is the v2 createMcpHandler sibling.
  • #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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.