Skip to content

[Bug] stdio MCP server process can outlive its client when the connection is lost without close() #4745

Open

Description

@ploteddie-bit

Version

  • Kimi Code CLI: 2.6.0-canary.20261106122412 (commit eb2035f3cad57cc12d4d5082ec626f7f0ea5f651)
  • Platform: WSL2 (Linux), single-binary distribution
  • MCP client SDK in use: @modelcontextprotocol/sdk 1.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

  1. 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.
  2. Let the runner replace that client instance: server restart, config reload, or
    simply killing the server process by hand.
  3. 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 inspecting ps.

Two questions / suggestions

  1. 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 while this._process is still set, and _process is
    cleared in the close event handler (client/stdio.js:83-84). If the
    connection is lost without close() 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?
  2. 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 inspecting ps.

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

Activity

  1. GooseKIller commented on Nov 7, 2026

    @GooseKIller

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions