A stdio MCP server shuts down when its client closes stdin. pforge_runtime::McpServer::run() does not: the process stays alive indefinitely after EOF, so every client disconnect leaks a server.
Reproduction
Any binary using McpServer::run() with transport: Stdio. Using forjar mcp (pforge-runtime 0.1.4):
$ printf %sn '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"p","version":"0"}}}' \
| forjar mcp > /dev/null 2>&1 &
# stdin is at EOF immediately after the single line
$ sleep 6; kill -0 $! # still alive
Observed: alive after 6s, and indefinitely. Expected: exit 0 shortly after EOF.
An automated version, which is how it surfaced — it spawns the release binary, writes one initialize, drops stdin, and polls try_wait for 10s:
the_server_exits_when_stdin_closes ... FAILED
server did not exit within 10s of stdin EOF
The other four checks in that suite pass, so this is specifically the lifecycle half: initialize answers, tools/list advertises 9 tools, all 9 dispatch, and an unadvertised name is refused.
Where it is
crates/pforge-runtime/src/server.rs:202 run() → server.run_stdio().await (pmcp, pinned 2.18). pforge may be able to fix it at its own layer by racing the transport future against an EOF watcher; otherwise it is pmcp's read loop treating EOF as "keep waiting" rather than "shut down".
Why it matters
Stdio is how MCP clients (Claude Desktop and friends) launch servers, and closing stdin is the documented shutdown path. A long-running client that reconnects accumulates one orphan per disconnect, each holding whatever the tools hold.
Not blocking downstream
Filing rather than fixing downstream: forjar cannot fix this without owning stdin, and gating its release on a two-hop upstream chain would make an unpassable gate. Its e2e suite therefore covers reachability and dispatch and names this as explicitly not covered, pointing here.
A stdio MCP server shuts down when its client closes stdin.
pforge_runtime::McpServer::run()does not: the process stays alive indefinitely after EOF, so every client disconnect leaks a server.Reproduction
Any binary using
McpServer::run()withtransport: Stdio. Usingforjar mcp(pforge-runtime 0.1.4):Observed: alive after 6s, and indefinitely. Expected: exit 0 shortly after EOF.
An automated version, which is how it surfaced — it spawns the release binary, writes one
initialize, drops stdin, and pollstry_waitfor 10s:The other four checks in that suite pass, so this is specifically the lifecycle half:
initializeanswers,tools/listadvertises 9 tools, all 9 dispatch, and an unadvertised name is refused.Where it is
crates/pforge-runtime/src/server.rs:202 run()→server.run_stdio().await(pmcp, pinned2.18). pforge may be able to fix it at its own layer by racing the transport future against an EOF watcher; otherwise it is pmcp's read loop treating EOF as "keep waiting" rather than "shut down".Why it matters
Stdio is how MCP clients (Claude Desktop and friends) launch servers, and closing stdin is the documented shutdown path. A long-running client that reconnects accumulates one orphan per disconnect, each holding whatever the tools hold.
Not blocking downstream
Filing rather than fixing downstream: forjar cannot fix this without owning stdin, and gating its release on a two-hop upstream chain would make an unpassable gate. Its e2e suite therefore covers reachability and dispatch and names this as explicitly not covered, pointing here.