Skip to content

fix(server): install createMcpHandler's onclose hook once per instance - #2610

Open
hamzashah-dev wants to merge 1 commit into
modelcontextprotocol:mainfrom
hamzashah-dev:fix/mcp-handler-onclose-hook-once
Open

fix(server): install createMcpHandler's onclose hook once per instance#2610
hamzashah-dev wants to merge 1 commit into
modelcontextprotocol:mainfrom
hamzashah-dev:fix/mcp-handler-onclose-hook-once

Conversation

@hamzashah-dev

Copy link
Copy Markdown

Fixes #2607

Root cause

createMcpHandler keeps a Set<Server> of modern instances with an exchange in flight so close() can tear them down, and it kept that set current by wrapping server.onclose — once per request:

const previousOnClose = server.onclose;
inflight.add(server);
server.onclose = () => {
    inflight.delete(server);
    previousOnClose?.();
};

That is fine when the factory honours its contract and returns a fresh instance per request. When the factory returns the same instance every time (createMcpHandler(() => sharedServer)), each request wraps the previous wrapper. The chain grows one layer per request, retains every closure for the life of the process, and when it finally runs it recurses one frame per layer — RangeError: Maximum call stack size exceeded. Because the throw happens on the async close path it lands after handler.close() has already resolved, so the caller can't catch it; the process just dies.

The fix

Guard the wrap with a WeakSet<Server> so the hook is installed at most once per instance. The inflight set is already keyed by instance, so re-adding a server that is already tracked is idempotent, and the single existing wrapper keeps doing the bookkeeping for every later exchange.

The per-request-instance path — the intended one — is byte-for-byte unchanged: one add, one wrap, one delete.

Scope

This makes instance reuse degrade rather than crash; it does not make reuse a supported pattern. setNegotiatedProtocolVersion, installModernOnlyHandlers and seedClientIdentityFromEnvelope still re-run against the shared instance, and one exchange's teardown still closes it out from under the next. A fresh instance per request remains the contract. But an easy-to-make factory mistake shouldn't take the process down 20k requests later with an uncatchable error.

Testing

Two unit tests in packages/server/test/server/createMcpHandler.test.ts:

  • installs the in-flight onclose hook at most once per instance — 21 requests through a shared instance, then asserts server.onclose is the same function object it was after the first request. Fails on main (expected [Function] to be [Function]), passes with the fix.
  • preserves the instance's own onclose behind the hook — a consumer-supplied onclose set before the first request still fires exactly once on close, so the wrap isn't swallowing it.

I also ran the reporter's 25k-request loop against both trees:

result
main 14,988 × RangeError: Maximum call stack size exceeded, exit 1
this branch 25,000 requests all 200, close() clean, exit 0

pnpm --filter @modelcontextprotocol/server test → 470 passed. pnpm typecheck:all and pnpm lint:all clean (the pre-push hook's build/lint/typecheck basket also passed).

Changeset included as a patch to @modelcontextprotocol/server.

createMcpHandler wrapped server.onclose on every request to keep its
in-flight set current. A factory that returns the same McpServer for every
request therefore stacked one wrapper per request: the chain retained every
closure, and running it recursed one frame per layer and threw
RangeError: Maximum call stack size exceeded. Because the throw happened in
the async close path it landed after handler.close() had already resolved,
so callers could not catch it.

Guard the wrap with a WeakSet so it is installed at most once per instance.
The intended per-request-instance path is unchanged; a reused instance now
costs O(1) per request instead of crashing the process.

Reusing an instance still isn't a supported pattern — the era write, handler
installation and identity seeding all re-run against the shared server — but
it should degrade, not take the process down.
@hamzashah-dev
hamzashah-dev requested a review from a team as a code owner August 3, 2026 08:15
@changeset-bot

changeset-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2baca96

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 3, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2610

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2610

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2610

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2610

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2610

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2610

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2610

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2610

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2610

commit: 2baca96

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants