Monitoring a stdio MCP Server When There's No Endpoint to Ping
A stdio MCP server has no port, no URL, and nothing an uptime monitor can ping. The client launches it as a subprocess and talks to it over stdin/stdout. That's great for local tools — no network exposure, no auth to configure, the OS's process permissions do the access control — but it makes "is this thing healthy" a different kind of question than it is for a networked service.
What a stdio server actually is
Per the MCP spec, the contract is narrow and strict:
- Messages are JSON-RPC, one per line, delimited by newlines — a message must not contain an embedded newline.
- The server must not write anything to
stdoutthat isn't a valid MCP message. - The server may write logs to
stderr, and the client may capture, forward, or ignore it — critically, the client should not treat stderr output as an error signal by itself. - The process's lifecycle is tied to the client: when the client closes
stdinand exits, the server is expected to terminate too.
That last rule is why "check if the process is running" is a weak signal. A stdio server has no independent existence to monitor — it only exists inside a client's session. So the question isn't "is the server up," it's "if a client launched this server right now, would the handshake actually complete."
The failure modes a process check misses
A few things go wrong with stdio servers that never show up as a crashed process:
- A logging line leaks onto stdout. Some logging library defaults to stdout instead of stderr, a
print()debug statement survives into production, or a dependency writes a banner on startup. The process is alive and the port (if there were one) would be fine — but the first byte onstdoutisn't valid JSON-RPC, and every client parsing that stream breaks immediately. - The server hangs after
initialize. It accepts the handshake, returns a validInitializeResult, and then never responds totools/list— maybe it's blocked on a slow database connection or a misconfigured credential lookup. The process is running; the protocol is stuck. - Tool schemas drift between releases. Exactly the same risk as networked servers: a tool's
inputSchemachanges shape between versions, and nothing about the process lifecycle reveals it. - Startup latency creeps up. If the server does expensive initialization before it can answer
initialize, clients with short timeouts will start failing intermittently as that startup cost grows, well before anyone notices in a process list.
Scripting the actual handshake
Because there's no network endpoint to point a monitor at, the check has to launch the process itself, exactly the way a real client would:
import subprocess, json
proc = subprocess.Popen(
["node", "server.js"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1,
)
def send(msg):
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
def recv():
line = proc.stdout.readline()
return json.loads(line)
send({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "health-check", "version": "1.0"},
},
})
init_result = recv()
send({"jsonrpc": "2.0", "method": "notifications/initialized"})
send({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
tools = recv()
proc.terminate()
Set a hard timeout on recv() — a hang here is exactly the failure you're trying to catch, and it needs to be a timeout, not an indefinite block. If readline() ever returns something that isn't valid JSON, treat that as a hard failure: it means stdout discipline has been broken somewhere in the process, which is a more serious bug than a normal error response.
Where this check belongs
Since a stdio server can't be polled from outside on its own schedule, the checks above make the most sense in a few specific places:
- In CI, on every build, as a smoke test before anything ships.
- As a pre-deploy gate, run against the exact artifact (container image, binary, package version) that's about to go out.
- On a scheduled runner sitting next to wherever the server actually gets launched in production — a cron job or sidecar process that spawns the server the same way the real client would, on an interval, and alerts on handshake failure or schema drift.
Where it connects to remote monitoring
Stdio servers are, by design, not reachable for continuous external monitoring the way a networked one is — there's no public endpoint for a third-party service to poll. If your stdio server also ships (or could ship) a Streamable HTTP variant for remote use, that's the one you'd want under continuous watch: MCP Vitals runs the same initialize → tools/list handshake against reachable MCP servers on a schedule and flags tool-schema drift automatically, which covers the piece a local process check can't — catching regressions between the moments you happen to run CI.
For the stdio case itself, the handshake script above, wired into your existing CI and deploy pipeline, is the practical equivalent — the same protocol-level check, just run at the moments you control instead of continuously from outside.