Skip to content

fix: drain stdio responses after stdin EOF#2815

Open
fengjikui wants to merge 1 commit into
modelcontextprotocol:mainfrom
fengjikui:fix-stdio-eof-drains-responses
Open

fix: drain stdio responses after stdin EOF#2815
fengjikui wants to merge 1 commit into
modelcontextprotocol:mainfrom
fengjikui:fix-stdio-eof-drains-responses

Conversation

@fengjikui
Copy link
Copy Markdown

Fixes #2678.

Summary

This keeps the stdio server write side alive briefly after stdin EOF so responses for already-accepted requests can flush before the process exits.

The default server/session behavior is unchanged for other transports: read-stream closure still closes the write stream and cancels in-flight handlers. Only MCPServer.run_stdio_async() opts into the bounded drain path.

Root Cause

For file-redirected stdio input, the server can read and accept tools/call requests, then immediately hit stdin EOF while those handlers are still mid-await.

Before this change:

  • BaseSession._receive_loop() wrapped the read and write streams in one async with, so read EOF closed the write stream.
  • Server.run() then unconditionally cancelled the handler task group.
  • Accepted tool calls could run far enough to be visible in server logs, but never send their JSON-RPC responses to stdout.

Change

  • Add an internal session option to decouple write-stream closure from read EOF.
  • Add an internal Server.run() drain option that waits for in-flight requests to complete, bounded by a timeout, then falls back to cancellation.
  • Enable that drain only for the stdio server path.
  • Add regression coverage for both successful stdio drain and drain-timeout cancellation.

Validation

  • Reproduced FastMCP/stdio: in-flight tool responses dropped on stdin EOF when input is bash-redirected from a file #2678 before the fix: stdout had only id=0, missing id=1 and id=2 despite both CallToolRequest handlers being entered.
  • Re-ran the same repro after the fix: stdout has id=[0, 1, 2] and both tool response texts.
  • uv run --frozen ruff format src/mcp/shared/session.py src/mcp/server/session.py src/mcp/server/lowlevel/server.py src/mcp/server/mcpserver/server.py tests/server/test_stdio.py tests/server/test_cancel_handling.py
  • uv run --frozen ruff check src/mcp/shared/session.py src/mcp/server/session.py src/mcp/server/lowlevel/server.py src/mcp/server/mcpserver/server.py tests/server/test_stdio.py tests/server/test_cancel_handling.py
  • uv run --frozen pytest tests/server/test_stdio.py tests/server/test_cancel_handling.py tests/server/test_lowlevel_exception_handling.py -q -> 13 passed
  • uv run --frozen pyright src/mcp/shared/session.py src/mcp/server/session.py src/mcp/server/lowlevel/server.py src/mcp/server/mcpserver/server.py tests/server/test_stdio.py tests/server/test_cancel_handling.py -> 0 errors
  • UV_FROZEN=1 uv run --frozen strict-no-cover -> passed

Note: I also attempted ./scripts/test; local HTTP/SSE/WebSocket tests were affected by my machine's proxy environment (socks5://127.0.0.1:7890), so I did not count that as a clean validation signal.

AI Assistance Disclosure

I used AI assistance to help trace the shutdown path and draft the patch. I reviewed the changed code and ran the validation above locally.

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.

FastMCP/stdio: in-flight tool responses dropped on stdin EOF when input is bash-redirected from a file

1 participant