fix(server): install createMcpHandler's onclose hook once per instance - #2610
Open
hamzashah-dev wants to merge 1 commit into
Open
fix(server): install createMcpHandler's onclose hook once per instance#2610hamzashah-dev wants to merge 1 commit into
hamzashah-dev wants to merge 1 commit into
Conversation
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.
🦋 Changeset detectedLatest commit: 2baca96 The changes in this PR will be included in the next version bump. This PR includes changesets to release 6 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/core
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2607
Root cause
createMcpHandlerkeeps aSet<Server>of modern instances with an exchange in flight soclose()can tear them down, and it kept that set current by wrappingserver.onclose— once per request: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 afterhandler.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. Theinflightset 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, onedelete.Scope
This makes instance reuse degrade rather than crash; it does not make reuse a supported pattern.
setNegotiatedProtocolVersion,installModernOnlyHandlersandseedClientIdentityFromEnvelopestill 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 assertsserver.oncloseis the same function object it was after the first request. Fails onmain(expected [Function] to be [Function]), passes with the fix.preserves the instance's own onclose behind the hook— a consumer-suppliedoncloseset 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:
mainRangeError: Maximum call stack size exceeded, exit 1close()clean, exit 0pnpm --filter @modelcontextprotocol/server test→ 470 passed.pnpm typecheck:allandpnpm lint:allclean (the pre-push hook's build/lint/typecheck basket also passed).Changeset included as a patch to
@modelcontextprotocol/server.