Repository navigation
Description
Version
- Kimi Code CLI:
2.6.0-canary.20261106122412(commiteb2035f3cad57cc12d4d5082ec626f7f0ea5f651) - Platform: WSL2 (Linux), single-binary distribution
- MCP client SDK in use:
@modelcontextprotocol/sdk1.32.2 (dist/esm/client/stdio.js)
What I'm seeing
A stdio MCP server process can outlive the client that spawned it.
Concretely, with a local MCP gateway plugin (a stdio server written in Python that
proxies other MCP servers), the runner replaced the client instance several times
while the previous server processes stayed alive: 3-4 gateway processes, each
with 9 upstream MCP servers, about 2 GB of resident memory in total. All of them
were in state S, 12-13 threads, 6 open sockets, idle (0 % CPU).
The server process does not exit when the client closes its stdin: the MCP Python
server keeps running because its app.run() never returns on EOF. On the client
side, the process is only killed when close() is called on a client whose
_process handle is still set (client/stdio.js:143-170).
Reproduction
- Register a stdio MCP server (for example through a plugin) whose process stays
alive after its stdin is closed - the default for a Python MCP server that does
not watch stdin. - Let the runner replace that client instance: server restart, config reload, or
simply killing the server process by hand. - Observe the previous server processes still running, with their upstream
children.
Any stdio MCP server written in Python that does not watch stdin should show the
same behaviour, so the plugin itself is not required to reproduce it.
Impact
- Roughly 500 MB per duplicated instance (the gateway plus its upstream servers).
- The number of live instances grew over the course of a session, and each
duplication is invisible without inspectingps.
Two questions / suggestions
- Cleanup when a client is abandoned. When the runner gives up on a stdio
client, is the server process always signalled?StdioClientTransport.close()
only kills the child whilethis._processis still set, and_processis
cleared in thecloseevent handler (client/stdio.js:83-84). If the
connection is lost withoutclose()being called, the server process can
survive. Would it be reasonable to always terminate the child process when the
client is dropped, even if the transport already reported itself closed? - Observability. Could the runner expose the number of live MCP server
processes per server name (a counter in/mcp, or a log line on connect)? A
duplicated instance is currently invisible without inspectingps.
Workaround used on my side
Relaying stdin through a pipe inside the server process: a daemon thread reads the
real stdin and writes it into an os.pipe() whose read end is dup2'd onto fd 0.
The SDK then consumes the pipe, and the thread sees the EOF. On EOF the server
terminates its process group.
Verified: the process exits 0.26 s after the pipe is closed, with no change to the
MCP protocol (the MCP handshake and all tool calls behave identically).
Related
- [Bug] Resident child processes survive session end: terminal tasks are skipped at dispose and reconcile never signals the recorded pid #4028 - resident child processes surviving session end. Different code path
(Bash tasks rather than MCP stdio servers), but the symptom - no code path
signals the process - is similar.
Activity
GooseKIller commentedon Nov 7, 2026
I can take this if nobody else is on it yet.
Reproduced on main (eb2035f) with a small vitest case: server finishes the handshake, then closes stdout but keeps running (basically what a Python server that never returns from app.run() looks like). The transport sees the stdout end, runs finish() and just drops the process handle — no kill. After that close() is a no-op because the transport already thinks it's closed, so the child is still alive afterwards (checked with kill(pid, 0)).
So the repo's own transport (packages/agent-core-v2/src/mcpCore/client-stdio.ts) has the same gap described here, on the finish() path.
My plan for the fix: make finish() actually terminate the child if it's still running (same SIGTERM -> grace -> SIGKILL sequence close() already does), plus a regression test with a server that severs stdout after the handshake. Happy to adjust the scope if you'd rather see it handled elsewhere.