Skip to content

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

Description

@blakebauman

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

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions