Skip to content

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814

Open
MayCXC wants to merge 1 commit intomodelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 1 commit intomodelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC
Copy link
Copy Markdown

@MayCXC MayCXC commented Mar 29, 2026

Summary

Three related improvements to server lifecycle handling.

1. Allow async onclose callbacks

MCP servers that hold external resources (browser sessions, database connections) need to await cleanup before the process exits. onclose is the only transport/protocol callback called from an awaitable context (transport.close() is async, awaited by server.close()). The other callbacks (onmessage, onerror) fire from event emitters that cannot await.

The onclose signature changes from () => void to () => void | Promise<void>, matching the existing pattern used by onsessionclosed in StreamableHTTPServerTransport. All transports and Protocol._onclose now await the callback.

Changed files: Transport interface, Protocol, StdioServerTransport, StreamableHTTPServerTransport, StdioClientTransport, WebSocketClientTransport, StreamableHTTPClientTransport, SSEClientTransport, InMemoryTransport, and mock transports in tests.

2. Close StdioServerTransport when stdin ends

The transport listened for data and error on stdin but not EOF. When the MCP client disconnects (closing stdin), the transport stays open and onclose never fires. This prevents servers from cleaning up resources.

This is especially visible with containerized MCP servers using docker run --rm: without onclose, the server process never exits, the container never stops, and containers accumulate on each client reconnect.

3. Add SIGTERM handlers in examples

All 10 examples only handle SIGINT (Ctrl+C). MCP servers run as background processes spawned by clients, not interactively. SIGTERM is what container runtimes and process managers send to stop a process. Added SIGTERM handlers alongside SIGINT in all examples.

Test plan

  • New test: should close when stdin ends (push null to stdin, verify onclose fires)
  • New test: should await async onclose callback (async cleanup completes before close() resolves)
  • Existing debounce test passes (state cleared synchronously before async callbacks)
  • All server tests pass (39/39)
  • All core tests pass (440/440)
  • Client test failure is pre-existing on main (jose/RSA base64 error message mismatch)

@MayCXC MayCXC requested a review from a team as a code owner March 29, 2026 12:04
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 29, 2026

🦋 Changeset detected

Latest commit: 3f70c00

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/node Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/hono 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

@MayCXC MayCXC changed the title fix(server): close StdioServerTransport when stdin ends fix: async onclose, stdin EOF detection, SIGTERM in examples Mar 29, 2026
@MayCXC MayCXC force-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99 Compare March 29, 2026 14:26
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 29, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1814

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1814

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1814

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1814

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1814

commit: 3f70c00

@MayCXC MayCXC force-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234b Compare March 29, 2026 14:30
Three related improvements to server lifecycle handling:

1. Allow async onclose callbacks on Transport and Protocol.
   MCP servers that hold external resources (browser sessions,
   database connections) need to await cleanup before the process
   exits. The onclose signature changes from `() => void` to
   `() => void | Promise<void>`, matching the existing pattern
   used by onsessionclosed in StreamableHTTPServerTransport.
   All transports and Protocol._onclose now await the callback.

2. Close StdioServerTransport when stdin ends. The transport
   listened for data and error but not EOF. When the MCP client
   disconnects, the transport stays open and onclose never fires.
   This is especially visible with containerized servers using
   docker run with automatic removal: without onclose the server
   never exits and the container accumulates.

3. Add SIGTERM handlers alongside SIGINT in all examples. MCP
   servers run as background processes spawned by clients, not
   interactively. SIGTERM is what container runtimes and process
   managers send to stop a process.
@MayCXC MayCXC force-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00 Compare March 29, 2026 14:34
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

What prompted you to open this PR, are any of these issues things you're running into?

@MayCXC
Copy link
Copy Markdown
Author

MayCXC commented Mar 30, 2026

What prompted you to open this PR, are any of these issues things you're running into?

yes I ran into them all, and that is what prompted me to open the PR. it upstreams fixes that I have added to individual MCPs separately, for example https://github.com/mozilla/firefox-devtools-mcp/pull/50/changes#diff-a2a171449d862fe29692ce031981047d7ab755ae7f84c707aef80701b3ea0c80R365

@MayCXC MayCXC requested a review from felixweinberger March 30, 2026 17:33
@km-anthropic
Copy link
Copy Markdown

@claude review

@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review

Comment on lines +493 to 504
private async _onclose(): Promise<void> {
const responseHandlers = this._responseHandlers;
this._responseHandlers = new Map();
this._progressHandlers.clear();
this._taskManager.onClose();
this._pendingDebouncedNotifications.clear();
this._transport = undefined;

await this.onclose?.();

for (const info of this._timeoutInfo.values()) {
clearTimeout(info.timeoutId);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 The refactored _onclose() in protocol.ts removed the try/finally block that previously guaranteed cleanup of in-flight requests even if the onclose callback threw. Since this PR introduces async onclose callbacks as its primary feature, any rejection from await this.onclose?.() will now skip notifying pending response handlers with ConnectionClosed, clearing timeout info, and aborting request handler AbortControllers — causing in-flight requests to hang until their individual timeouts expire instead of failing immediately. Fix: wrap the await this.onclose?.() call in a try { ... } finally { /* cleanup */ } block.

Extended reasoning...

The Bug

The _onclose() method was refactored from synchronous to async to support the new async onclose callback. In the original code, the onclose callback was called inside a try { } finally { } block that unconditionally ran cleanup: notifying response handlers with a ConnectionClosed error, clearing timeout handles, and aborting AbortControllers for in-flight server-side request handlers. The new code calls await this.onclose?.() with no try/finally wrapping the subsequent cleanup loops.

The Code Path

In packages/core/src/shared/protocol.ts, _onclose() (starting around line 490 in the modified file):

  1. responseHandlers is captured from this._responseHandlers (line ~492) and the map is cleared.
  2. await this.onclose?.() is called (line ~501) — no try/finally.
  3. The cleanup loops — clearTimeout for all _timeoutInfo entries, handler(error) for each captured responseHandler, and controller.abort(error) for each captured AbortController — only execute if the await resolves successfully.

Why Existing Code Doesn't Prevent It

The outer connect() wrapper does use try/finally to ensure this._onclose() is called even if the old transport's onclose throws. But that only ensures _onclose() runs — it does not protect the code inside _onclose() from a rejection thrown by the user's onclose callback. Once await this.onclose?.() rejects, the async function propagates the rejection and the remaining cleanup statements are skipped.

Impact

If a user supplies an async onclose callback (which is now the advertised feature of this PR) that rejects:

  1. Pending in-flight requests whose handlers are stored in the captured responseHandlers map will never receive the ConnectionClosed error — they will hang until their individual timeouts expire (default 60 seconds).
  2. clearTimeout calls will be skipped, leaking setTimeout handles.
  3. Server-side AbortControllers for active request handlers will not be aborted, so long-running server-side tool handlers will not be cancelled promptly.

Step-by-Step Proof

  1. Client connects and sends a request; protocol._responseHandlers now contains one entry with the pending handler.
  2. User sets protocol.onclose = async () => { await doAsyncCleanup(); /* throws */ }.
  3. Connection drops; transport fires its onclose callback.
  4. connect()'s wrapper calls await this._onclose().
  5. Inside _onclose: responseHandlers is captured (step 1 entry is snapshotted), then await this.onclose?.() rejects.
  6. _onclose() propagates the rejection — the for (const handler of responseHandlers.values()) { handler(error); } loop never executes.
  7. The pending request from step 1 sits unresolved until its 60-second timeout fires, rather than immediately receiving ConnectionClosed.

Fix

Wrap the await this.onclose?.() call in a try/finally inside _onclose():

try {
    await this.onclose?.();
} finally {
    for (const info of this._timeoutInfo.values()) { clearTimeout(info.timeoutId); }
    this._timeoutInfo.clear();
    const requestHandlerAbortControllers = this._requestHandlerAbortControllers;
    this._requestHandlerAbortControllers = new Map();
    const error = new SdkError(SdkErrorCode.ConnectionClosed, 'Connection closed');
    for (const handler of responseHandlers.values()) { handler(error); }
    for (const controller of requestHandlerAbortControllers.values()) { controller.abort(error); }
}

Comment on lines +62 to +64
this._stdin.on('end', () => {
this.close();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Two async event handlers introduced in this PR lack error handling, creating unhandled Promise rejection risks. In StdioServerTransport.start(), the new stdin end handler calls this.close() without .catch(); in StdioClientTransport, the close event handler was made async but Node.js EventEmitter does not await async listeners, so a rejecting onclose callback escapes silently in both cases. Fix both by using .catch(err => this.onerror?.(err)) instead of await inside the EventEmitter callbacks.

Extended reasoning...

Bug 1 — StdioServerTransport stdin 'end' handler (packages/server/src/server/stdio.ts, lines 62–64):

The PR adds EOF detection to close the server when stdin ends, which is one of its primary features. However, the handler calls this.close() without attaching a .catch():

this._stdin.on('end', () => {
    this.close();  // Promise return value discarded
});

Since close() is async and now awaits this.onclose?.() (whose type was widened to () => void | Promise<void>), any rejection from an async onclose callback propagates out of close() and becomes an unhandled Promise rejection. In Node.js v15+, unhandled rejections terminate the process with a non-zero exit code.

The correct pattern is demonstrated in the very same file by _onstdouterror:

this.close().catch(() => {
    // Ignore errors during close — we're already in an error path
});

For the end handler, errors should be routed through onerror rather than silently swallowed:

this._stdin.on('end', () => {
    this.close().catch(error => this.onerror?.(error));
});

Bug 2 — StdioClientTransport process 'close' handler (packages/client/src/client/stdio.ts, ~line 147):

The handler was changed to async:

this._process.on('close', async _code => {
    this._process = undefined;
    await this.onclose?.();
});

Node.js EventEmitter never awaits the return value of event listeners. When the async listener function rejects (because this.onclose?.() rejects), the returned Promise is silently dropped by the EventEmitter. In Node.js v15+ this surfaces as an unhandledRejection event, which by default terminates the process.

The correct approach avoids async/await inside the listener and instead chains .catch():

this._process.on('close', _code => {
    this._process = undefined;
    this.onclose?.()?.catch(error => this.onerror?.(error));
});

Why this matters specifically for this PR:

The central motivation of the PR is enabling async onclose callbacks for MCP servers that hold external resources (browser sessions, database connections). These are exactly the callbacks most likely to perform fallible async operations (network calls, I/O cleanup). Both bugs mean that the very feature being added — async onclose support — silently fails to propagate errors in the two most common stdio transport paths.

Step-by-step proof for Bug 1:

  1. Server starts with StdioServerTransport.
  2. User sets server.onclose = async () => { await db.close(); } where db.close() can throw.
  3. MCP client disconnects; stdin receives EOF.
  4. The 'end handler fires and calls this.close() (no .catch()).
  5. close() calls await this.onclose?.(); the async callback throws.
  6. close() rejects; the rejection propagates out of the 'end handler with no catch.
  7. Node emits unhandledRejection; process exits in v15+.

Step-by-step proof for Bug 2:

  1. Client spawns a stdio server process.
  2. User sets transport.onclose = async () => { await session.destroy(); } where session.destroy() rejects.
  3. The spawned process exits; the 'close event fires.
  4. The async listener runs and calls await this.onclose?.(); the callback throws.
  5. The async listener's returned Promise rejects.
  6. EventEmitter discards it; unhandled rejection in v15+.

Comment on lines +816 to +819
process.on('SIGTERM', async () => {
console.log('\nReceived SIGINT. Cleaning up...');
await cleanup();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The SIGTERM handler in elicitationUrlExample.ts (line 817) and simpleStreamableHttp.ts logs '\nReceived SIGINT. Cleaning up...' instead of '\nReceived SIGTERM. Cleaning up...'. This copy-paste error means operators monitoring logs will see misleading signal names when the process is stopped via SIGTERM (e.g., by container runtimes or process managers).

Extended reasoning...

What the bug is: In both examples/client/src/elicitationUrlExample.ts and examples/client/src/simpleStreamableHttp.ts, the newly-added SIGTERM handler was copy-pasted from the SIGINT handler but the log string was not updated. It still reads 'Received SIGINT. Cleaning up...' inside process.on('SIGTERM', ...).

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.

3 participants