Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/curvy-rivers-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required.
7 changes: 6 additions & 1 deletion packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,10 +867,15 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa
// Track the instance until its exchange tears down so close() can abort it.
const previousOnClose = server.onclose;
inflight.add(server);
server.onclose = () => {
const onExchangeClose = () => {
inflight.delete(server);
// Restore by identity so a handler installed during the exchange is kept.
if (server.onclose === onExchangeClose) {
server.onclose = previousOnClose;
}
previousOnClose?.();
};
server.onclose = onExchangeClose;

try {
const response = await invoke(product, route.message, {
Expand Down
47 changes: 47 additions & 0 deletions packages/server/test/server/createMcpHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,53 @@ describe('createMcpHandler — modern path', () => {
expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' }));
});

it('restores a reused server onclose handler after each modern exchange', async () => {
const reused = new McpServer({ name: 'entry-test-server', version: '1.0.0' });
reused.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));

const originalOnClose = vi.fn();
reused.server.onclose = originalOnClose;

const handler = createMcpHandler(() => reused);

for (let i = 0; i < 3; i++) {
const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: `hello-${i}` })));
expect(response.status).toBe(200);
await response.text();
expect(reused.server.onclose).toBe(originalOnClose);
}

expect(originalOnClose).toHaveBeenCalledTimes(3);
});
Comment on lines +277 to +296

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): maintainers get no test that a reused server's onclose is restored on the failure or shutdown paths, only on the happy path. The new test at packages/server/test/server/createMcpHandler.test.ts:277-296 drives three successful tools/call exchanges; the restore also has to hold when the exchange fails internally (createMcpHandler.ts:900 calls server.close()) and when handler.close() aborts an in-flight reused instance (createMcpHandler.ts:1005). Fix: add coverage for both error/teardown paths with a reused McpServer, asserting reused.server.onclose is the original function afterwards and that it ran once per exchange. [also at: packages/server/test/server/createMcpHandler.test.ts:277 - nit: REVIEW.md asks that new behavior have vitest coverage including error paths: the new test 'restores a reused server onclose handler after each modern exchange' only exercises three successful tools/call exchanges.]

Why this was flagged

REVIEW.md asks that new behavior have vitest coverage including error paths. The behavior this diff adds is the server.onclose = previousOnClose; restore at packages/server/src/server/createMcpHandler.ts:872, which is reached through three routes: the transport's auto-close after a terminal response, the explicit server.close() on the internal-failure path at createMcpHandler.ts:900, and handler.close() at createMcpHandler.ts:1005. The added test at packages/server/test/server/createMcpHandler.test.ts:277-296 only exercises the first route with three successful tools/call exchanges and asserts originalOnClose was called 3 times. The existing failure-path test at createMcpHandler.test.ts:298-325 uses a fresh per-request instance from testFactory() and never inspects onclose, so a regression that leaves the wrapper installed after a failed or aborted exchange on a reused server (the original bug this PR fixes, on a sibling path) would pass CI.…

Verification: nit. The REVIEW.md block above contains, verbatim under "Tests & docs", "- New behavior has vitest coverage including error paths". The new behavior in this diff is the single added line server.onclose = previousOnClose; at /home/claude/typescript-sdk/packages/server/src/server/createMcpHandler.ts:872, inside the wrapper installed at lines 870-874. That wrapper is reached on three routes:…


it('keeps an onclose handler that was installed during the exchange', async () => {
const reused = new McpServer({ name: 'entry-test-server', version: '1.0.0' });
const installedDuringExchange = vi.fn();
let chained: (() => void) | undefined;
reused.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => {
if (chained === undefined) {
const previous = reused.server.onclose;
chained = () => {
installedDuringExchange();
previous?.();
};
reused.server.onclose = chained;
}
return { content: [{ type: 'text', text }] };
});

const handler = createMcpHandler(() => reused);

const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hello' })));
expect(response.status).toBe(200);
await response.text();

expect(reused.server.onclose).toBe(chained);
expect(installedDuringExchange).toHaveBeenCalledTimes(1);
});

it('closes and releases the per-request instance when a modern exchange fails internally', async () => {
const { factory, state } = testFactory();
const onerror = vi.fn();
Expand Down
Loading