Skip to content

feat(server): add host process watchdog to StdioServerTransport#1712

Open
travisbreaks wants to merge 1 commit intomodelcontextprotocol:mainfrom
travisbreaks:feat/server-orphan-detection
Open

feat(server): add host process watchdog to StdioServerTransport#1712
travisbreaks wants to merge 1 commit intomodelcontextprotocol:mainfrom
travisbreaks:feat/server-orphan-detection

Conversation

@travisbreaks
Copy link
Copy Markdown

@travisbreaks travisbreaks commented Mar 19, 2026

Summary

  • Adds clientProcessId option to StdioServerTransport that enables a watchdog timer
  • Watchdog periodically checks if the host process is alive using signal 0 (process.kill(pid, 0))
  • Self-terminates the transport when the host is gone, preventing orphaned server processes
  • Configurable check interval via watchdogIntervalMs (default: 3s)
  • New StdioServerTransportOptions interface with backwards-compatible constructor overloads
  • Timer uses unref() so it does not prevent clean process exit

Follows 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 closes
  • should not close when host process is alive: no false positives with own PID
  • should stop watchdog on close: cleanup on transport close
  • should accept options object constructor: new constructor form works
  • All existing stdio tests continue to pass (7 total)
  • Build succeeds
  • Lint passes

Changeset

@modelcontextprotocol/server: minor (new opt-in feature, no breaking changes)

Co-Authored-By: Claude Opus 4.6 (1M context) tadao@travisfixes.com

@travisbreaks travisbreaks requested a review from a team as a code owner March 19, 2026 16:12
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 19, 2026

🦋 Changeset detected

Latest commit: 6abe472

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

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/fastify Patch
@modelcontextprotocol/hono Patch
@modelcontextprotocol/node Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 19, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 6abe472

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

Thanks. Please pull in a few things before merge (cherry-picks from #1815, credit @windro-xdd):

  1. Rebase onto main. Stale base conflicts with the _closed guard and _onstdouterror additions.
  2. EPERM handling. In the watchdog catch, treat err.code === 'EPERM' as still-alive.
  3. Workerd shim. Add a kill(): never stub to packages/server/src/shimsWorkerd.ts.
  4. Export StdioServerTransportOptions from packages/server/src/index.ts.

@travisbreaks travisbreaks force-pushed the feat/server-orphan-detection branch from e253410 to 7d89648 Compare April 2, 2026 05:36
@felixweinberger
Copy link
Copy Markdown
Contributor

@claude review


import type { JSONRPCMessage } from '@modelcontextprotocol/core';
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';

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 import type { StdioServerTransportOptions } on line 6 of the test file is imported but never referenced anywhere in the file body. This will trigger an ESLint @typescript-eslint/no-unused-vars error since the project config applies that rule globally with no exception for type-only imports.

Extended reasoning...

The PR adds StdioServerTransportOptions as a new exported interface in packages/server/src/server/stdio.ts and also imports it in the test file on line 6 via import type { StdioServerTransportOptions } from '../../src/server/stdio.js'. However, the type is never actually used anywhere in the test file.

Searching the entire test file, no variable is declared with an explicit StdioServerTransportOptions type annotation. Every test case that exercises the new options-object constructor form passes inline object literals directly to new StdioServerTransport({...}) — for example, new StdioServerTransport({ stdin: input, stdout: output }) — relying on TypeScript structural type inference rather than an explicit annotation.

The project ESLint configuration (in common/eslint-config/eslint.config.mjs) sets @typescript-eslint/no-unused-vars: ["error", { argsIgnorePattern: "^_" }] globally. The only test-file-specific override disables unicorn/consistent-function-scoping, not no-unused-vars. The @typescript-eslint/no-unused-vars rule in typescript-eslint v8 does flag unused import type declarations, so this import will cause a lint error.

Concrete proof: scan every line of the test file for the string StdioServerTransportOptions — it appears only once, on the import line itself. There is no const opts: StdioServerTransportOptions = ..., no as StdioServerTransportOptions cast, no JSDoc reference, nothing.

The fix is straightforward: remove the unused import line. Since import type is erased at compile time, there is zero runtime impact; this is purely a lint/code-quality issue. Severity is nit — no functional regression, but it breaks the lint check.

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.

Legitimate finding

Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

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:

  1. Handler field (near _ondata):
_onend = () => void this.close().catch(err => this.onerror?.(err));
  1. 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);
  1. 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);         // ← add

We 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?

@travisbreaks
Copy link
Copy Markdown
Author

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 end reliably fire when the host process is kill -9'd? The watchdog with process.kill(pid, 0) explicitly handles that case (OS-level process existence check), but if the pipe teardown on SIGKILL is reliable across platforms, the simpler approach covers everything.

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.

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.

Replaces the previous watchdog approach with the simpler stdin EOF
pattern used by the Python and Kotlin SDKs.

Co-Authored-By: Tadao <tadao@travisfixes.com>
@travisbreaks travisbreaks force-pushed the feat/server-orphan-detection branch from 7d89648 to 6abe472 Compare April 5, 2026 02:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ways for server processes to self-terminate when host is gone

2 participants