feat(server): add host process watchdog to StdioServerTransport#1712
feat(server): add host process watchdog to StdioServerTransport#1712travisbreaks wants to merge 2 commits intomodelcontextprotocol:mainfrom
Conversation
🦋 Changeset detectedLatest commit: 513efd2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
felixweinberger
left a comment
There was a problem hiding this comment.
Thanks. Please pull in a few things before merge (cherry-picks from #1815, credit @windro-xdd):
- Rebase onto main. Stale base conflicts with the
_closedguard and_onstdouterroradditions. - EPERM handling. In the watchdog catch, treat
err.code === 'EPERM'as still-alive. - Workerd shim. Add a
kill(): neverstub topackages/server/src/shimsWorkerd.ts. - Export
StdioServerTransportOptionsfrompackages/server/src/index.ts.
e253410 to
7d89648
Compare
|
@claude review |
There was a problem hiding this comment.
Thinking through this more deeply - while this is technically correct and would work, I wonder what the actual mechanism for using this would be without a change to the MCP spec.
By design this requires client and server to somehow agree how to exchange the processId. Because the spec doesn't have a standard method for it (LSP does, which is what the issue refers to as an example), it implicitly means client and server both need to be controlled by the developer 🤔 - it could still be useful for someone that does this but does raise the question of whether that justifies introducing new APIs for a transport that's probably less used than SHTTP. Without a lot of people calling for this I'd err on the side of not expanding the API surface unless we're sure people want this and run into this problem.
I also had a look at other SDKs and none currently do anything like this watchdog setup (not necessarily making this wrong, but a data point for demand).
However, the close problem does seem real, and there's something the other SDKs do that TS does not - that's handling stdin-EOF better.
I think we could probably get a lot of mileage if we just do something like this alongside the data/error listeners:
- Handler field (near
_ondata):
_onend = () => void this.close().catch(err => this.onerror?.(err));- In
start():
this._started = true;
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
this._stdin.on('end', this._onend); // ← add
this._stdout.on('error', this._onstdouterror);- In close() (near line 86, alongside the other .off() calls):
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
this._stdin.off('end', this._onend); // ← addWe currently don't listen for the end event but the other SDKs all do, so we'd close that gap here.
Would you be open to simplifying to this here and instead of testing the Watchdog setup testing process cleanup on an end event?
|
Thanks for the direction here. The stdin EOF approach is cleaner and I'm happy to rewrite to match what the other SDKs do. One question before I go: does stdin Either way, I'll rewrite to stdin EOF. Just want to understand if there's an edge case worth documenting or handling as a follow-up. |
6abe472 to
1b36001
Compare
|
Rebased on main. The stdin EOF approach is fully implemented per our discussion: Ready for re-review when you have a chance, @felixweinberger. |
Listen for the stdin `end` event to detect when the host process exits or is killed, and close the transport accordingly. This prevents orphaned server processes when the host terminates without cleanly shutting down the server. Implements the simpler stdin EOF pattern used by the Python and Kotlin SDKs, as suggested in review. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1b36001 to
0bcf8fc
Compare
|
@claude review |
| --- | ||
| '@modelcontextprotocol/server': patch | ||
| --- | ||
|
|
||
| Close StdioServerTransport when stdin reaches EOF. When the host process exits or is killed, the stdin pipe closes and the transport now detects this via the `end` event, preventing orphaned server processes. Aligns with the behavior of the Python and Kotlin SDKs. |
There was a problem hiding this comment.
🟡 The PR title ("add host process watchdog") and description (clientProcessId, watchdogIntervalMs, process.kill(pid, 0) polling, StdioServerTransportOptions, constructor overloads, unref() timer, and the listed test plan) still describe the original implementation that was replaced on 2026-04-07 with the stdin-EOF approach — none of those exist in the shipped diff. Please update the PR title/body before merge so the squash-commit message matches what actually shipped; the changeset filename server-host-watchdog.md is also a stale leftover (cosmetic, since changeset files are deleted on release).
Extended reasoning...
What's wrong
The PR metadata describes an implementation that no longer exists. The title is "feat(server): add host process watchdog to StdioServerTransport" and the description documents:
- a
clientProcessIdoption - a
watchdogIntervalMsoption (default 3s) - polling via
process.kill(pid, 0) - a new
StdioServerTransportOptionsinterface - backwards-compatible constructor overloads
- a timer with
.unref() - a test plan listing
should close transport when host process is gone,should not close when host process is alive,should stop watchdog on close,should accept options object constructor
Per the timeline (author comments on 2026-04-05 and 2026-04-07), this was completely rewritten to the stdin-EOF approach. The shipped diff contains none of the above.
What actually shipped
packages/server/src/server/stdio.ts adds exactly three lines of behavior:
_onend = () => void this.close().catch(error => this.onerror?.(error));
// ...
this._stdin.on('end', this._onend); // in start()
this._stdin.off('end', this._onend); // in close()No options, no interface, no constructor overload, no timer, no PID polling. The tests are should close transport when stdin emits end, should not fire onclose twice when close() is called after stdin end, and should remove end listener on close — none of the four tests listed in the PR description exist.
Step-by-step proof
- PR description, bullet 1: "Adds
clientProcessIdoption" →grep clientProcessId packages/server/src/server/stdio.ts→ no matches. - PR description, bullet 4: "Configurable check interval via
watchdogIntervalMs" →grep watchdogIntervalMs→ no matches. - PR description, bullet 5: "New
StdioServerTransportOptionsinterface" →grep StdioServerTransportOptions→ no matches. (The earlier bot review flagging an unusedimport type { StdioServerTransportOptions }is also obsolete — that import no longer exists in the test file.) - PR description, bullet 6: "Timer uses
unref()" →grep unref→ no matches. - Test plan item 1:
should close transport when host process is gone→ not instdio.test.ts; the actual test isshould close transport when stdin emits end. - Changeset body (
.changeset/server-host-watchdog.md): "Close StdioServerTransport when stdin reaches EOF … detects this via theendevent" — this is correct and matches the code. Only the filename is stale.
Why it matters
This repo squash-merges PRs, so the PR title and body become the permanent commit message on main. Anyone running git log or git blame on stdio.ts will see a commit claiming to add a PID-polling watchdog with a configurable interval and a new options interface — and find none of it in the tree. The repo's review conventions explicitly call out "prose that promises behavior the code no longer ships" as something to flag.
The published npm changelog will be fine (the changeset body is accurate), so this has zero runtime or API impact — hence nit severity.
Fix
Before merge:
- Retitle to something like
feat(server): close StdioServerTransport on stdin EOF to prevent orphaned processes. - Replace the PR description's Summary/Test plan with the stdin-EOF behavior and the three actual test names.
- Optionally rename
.changeset/server-host-watchdog.md→ something like.changeset/server-stdin-eof.md(cosmetic — changeset files are deleted on release, so this only affects git history).
Summary
clientProcessIdoption toStdioServerTransportthat enables a watchdog timerprocess.kill(pid, 0))watchdogIntervalMs(default: 3s)StdioServerTransportOptionsinterface with backwards-compatible constructor overloadsunref()so it does not prevent clean process exitFollows the same pattern used in vscode-languageserver-node as suggested in the issue.
Closes #208
Test plan
should close transport when host process is gone: watchdog detects dead PID and closesshould not close when host process is alive: no false positives with own PIDshould stop watchdog on close: cleanup on transport closeshould accept options object constructor: new constructor form worksChangeset
@modelcontextprotocol/server: minor (new opt-in feature, no breaking changes)Co-Authored-By: Claude Opus 4.6 (1M context) tadao@travisfixes.com