Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix: async onclose, stdin EOF detection, SIGTERM in examples by MayCXC · Pull Request #1814 · modelcontextprotocol/typescript-sdk · GitHub
Skip to content

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

Open
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof
Open

fix: async onclose, stdin EOF detection, SIGTERM in examples#1814
MayCXC wants to merge 2 commits into
modelcontextprotocol:mainfrom
MayCXC:fix/stdio-server-stdin-eof

Conversation

@MayCXC

@MayCXCMayCXC commented Mar 29, 2026

Copy link
Copy Markdown

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 ownerMarch 29, 2026 12:04
@changeset-bot

changeset-botBot commented Mar 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 59d67bf

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

This PR includes changesets to release 7 packages
NameType
@modelcontextprotocol/corePatch
@modelcontextprotocol/serverPatch
@modelcontextprotocol/clientPatch
@modelcontextprotocol/nodePatch
@modelcontextprotocol/expressPatch
@modelcontextprotocol/fastifyPatch
@modelcontextprotocol/honoPatch

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

@MayCXCMayCXC changed the title fix(server): close StdioServerTransport when stdin endsfix: async onclose, stdin EOF detection, SIGTERM in examplesMar 29, 2026
@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch 2 times, most recently from 8cff2e5 to 5559b99CompareMarch 29, 2026 14:26
@pkg-pr-new

pkg-pr-newBot commented Mar 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 59d67bf

@MayCXC
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 5559b99 to 3d3234bCompareMarch 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
MayCXCforce-pushed the fix/stdio-server-stdin-eof branch from 3d3234b to 3f70c00CompareMarch 29, 2026 14:34

@felixweinbergerfelixweinberger left a comment

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.

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

@MayCXC

Copy link
Copy Markdown
Author

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

@km-anthropic

Copy link
Copy Markdown

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/core/src/shared/protocol.ts Outdated
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{awaitthis.onclose?.();}finally{for(constinfoofthis._timeoutInfo.values()){clearTimeout(info.timeoutId);}this._timeoutInfo.clear();constrequestHandlerAbortControllers=this._requestHandlerAbortControllers;this._requestHandlerAbortControllers=newMap();consterror=newSdkError(SdkErrorCode.ConnectionClosed,'Connection closed');for(consthandlerofresponseHandlers.values()){handler(error);}for(constcontrollerofrequestHandlerAbortControllers.values()){controller.abort(error);}}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf. Wrapped await this.onclose?.() in try/finally so the clearTimeout loop, response-handler ConnectionClosed notification, and AbortController.abort calls all run even if the user's async onclose rejects. The thrown rejection still propagates up to connect()'s wrapper.

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;awaitthis.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+.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf, both handlers using .catch() to route async rejections to onerror, matching the existing _onstdouterror pattern in the same server file.

Server stdio 'end' handler:

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

Client stdio 'close' handler (changed back from async listener):

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

Sync throws from a user-supplied onclose will surface as the existing convention here — async functions wrap throws in their returned Promise (caught by .catch), and a sync () => void that throws is treated as a programming error worth crashing on, consistent with how the rest of this file handles user callbacks.

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', ...).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 59d67bf — both examples/client/elicitationUrlExample.ts and examples/client/simpleStreamableHttp.ts SIGTERM handlers now log '\nReceived SIGTERM. Cleaning up...'.

Server examples already use a generic 'Shutting down server...' log, so no copy-paste leak there.

Resolves three review comments from modelcontextprotocol#1814:
1. protocol.ts: wrap `await this.onclose?.()` in try/finally so
in-flight request cleanup (clearTimeout, ConnectionClosed handler
notification, AbortController.abort) runs even when an async
onclose callback rejects.
2. stdio EventEmitter handlers: avoid bare `await onclose()` and
bare `this.close()` inside listener callbacks where rejections
would surface as unhandledRejection (process exit in Node 15+).
Use `.catch()` to route errors to onerror, matching the existing
`_onstdouterror` pattern in the same file.
3. examples: fix copy-paste in elicitationUrlExample.ts and
simpleStreamableHttp.ts SIGTERM handlers, which logged
"Received SIGINT" instead of "Received SIGTERM".
Merge conflicts:
- transport.ts onclose: combine our async return type with main's
explicit `| undefined` (exactOptionalPropertyTypes).
- streamableHttp.ts close(): keep main's restructured try/finally
scope; apply our `await` to onclose call.
- websocket.ts: deleted in main (transport removed), our changes
dropped along with the file.
@MayCXC

Copy link
Copy Markdown
Author

@felixweinberger requested changes are addressed

Sign up for freeto 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

@MayCXC@km-anthropic@felixweinberger