Repository navigation
FastMCP/stdio: in-flight tool responses dropped on stdin EOF when input is bash-redirected from a file #2678
Description
1. Initial Checks
- I have searched the issue tracker for similar bugs (closest existing: Using transport="stdio" closes real stdio, causing ValueError after server exits #1933, but that's about premature close of real stdio during shutdown — different failure layer)
- I am using a supported MCP SDK version (pinned
mcp>=1.27,<2)
2. Description
Triage note: checked jlowin/fastmcp (now PrefectHQ fork) — FastMCP's server-side stdio delegates to mcp.server.stdio via a transport mixin, so the bug belongs here in the SDK rather than in the FastMCP wrapper.
When driving a FastMCP stdio server with a file-redirected stdin (e.g. python -m my_server < payload.jsonl > response.jsonl), in-flight tool-call responses can be dropped if their response writer hasn't been scheduled when stdin EOF arrives.
The stdio read loop appears to treat stdin EOF as an immediate-shutdown signal, cancelling pending writer tasks before they flush their JSON-RPC responses to stdout. The failure is silent — no traceback, no log line, the response is simply absent from stdout.
Expected: All responses for processed requests appear on stdout before the server exits.
Actual: Responses for the last-issued requests can be missing entirely.
3. Example Code (minimal reproducible)
# server.py
from mcp.server.fastmcp import FastMCP
import asyncio
mcp = FastMCP("repro")
@mcp.tool()
async def slow_echo(text: str) -> str:
await asyncio.sleep(0.05) # guaranteed yield point so writer scheduling is observable
return text
if __name__ == "__main__":
mcp.run(transport="stdio")# payload.jsonl
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"repro","version":"0.1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"slow_echo","arguments":{"text":"first"}}}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"slow_echo","arguments":{"text":"second"}}}python server.py < payload.jsonl > response.jsonl
# Observed: response.jsonl contains id=0 + id=1 results, id=2 is absent.The race is timing-sensitive; if id=2 surfaces on the first run, increase the asyncio.sleep delay or run repeatedly. We saw it deterministically in production where the tool body did a real HTTP call (~50-200ms).
Diagnostic fingerprint: the difference between this transport race and a quality bug in the tool itself is absence-of-response vs response-with-empty-result. If you ever see id=N missing entirely (not {"id":N,"result":[]}), suspect this race.
4. Python and MCP Python SDK
- Python: 3.12 (
python:3.12-slimDocker base) - MCP SDK: 1.27.x (
mcp>=1.27,<2) - OS: Linux (Debian Bookworm; reproduced on Synology DSM 7.2 host)
- Transport: stdio via
mcp.run(transport="stdio")
Workaround we shipped (in case it helps the fix design): wrote a Python driver that owns both pipes via subprocess.Popen(stdin=PIPE, stdout=PIPE) and refuses to close stdin until the response for the last-issued id is observed on stdout. ~140 lines stdlib-only.
Suggested fix: before exiting on stdin EOF, await any pending writer tasks (with a small timeout) so their JSON-RPC responses reach stdout before the process terminates.
Activity
Asti1982 commentedon May 26, 2026
I think the sharp regression boundary here is: once a JSON-RPC request has been read and accepted, stdin EOF should not be able to cancel the write path before that request's response is flushed.
A focused test could drive stdio with redirected stdin using:
initializenotifications/initializedtools/callrequests where the second tool awaits at least once0,1, and2all appear before process exitA narrow fix shape would be to let stdin EOF close the read side, but keep the stdout writer alive long enough to drain already-queued responses, with a bounded shutdown window so the server cannot hang forever.
The important part is that a missing
id=Nresponse is materially different from an error response: clients and replay harnesses may retry without knowing whether the accepted tool call already ran. I can turn this into a focused fix/test PR if maintainers want it.norika1207-lab commentedon Jun 2, 2026
TimeToBuildBob commentedon Jul 10, 2026
rudidev08 commentedon Aug 10, 2026
Still reproduces on v2.0.0.
Repro: a minimal
MCPServerwith one tool, fedinitialize+notifications/initialized+tools/liston stdin with immediate EOF (Popen.communicate). Over 5 rounds, thetools/listresponse (id 2) was missing from stdout in 1 round. The race is timing-sensitive; a short delay before the server imports (time.sleep(0.5)) makes it easier to hit.Driver: pipe these three lines to the script's stdin and close it, then check stdout for
"id":2:{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "probe", "version": "1.0"}}} {"jsonrpc": "2.0", "method": "notifications/initialized"} {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}Environment: macOS, Python 3.11+, mcp 2.0.0 in a fresh
uvenvironment.ace-trump-tech commentedon Aug 31, 2026
I reproduced this issue on the current main branch.
The problem is not the tool result itself. When stdin reaches EOF, the JSON-RPC dispatcher immediately enters shutdown and cancels request handlers that have already been accepted but are still waiting to produce their responses. This can silently drop the final tool responses.
My proposed fix is to add an opt-in, bounded graceful-shutdown drain:
BoltyBolterson commentedon Sep 11, 2026
Adding measurements from a slightly different angle, because two things were not obvious from the repros above: the tool does not need an
awaitto lose its response (a plain synchronous echo loses too), and the loss rate is roughly half of the outstanding calls from two calls upward. It also still reproduces on 2.2.0.Environment: Linux (Ubuntu 26.04 on WSL2, kernel 6.6), Python 3.14.4, fresh venvs with
mcp==1.27.2andmcp==2.2.0.Repro: a one-tool server with a synchronous
echotool, and a client that writesinitialize+notifications/initialized+ Ntools/callrequests throughsubprocess.run(input=...)(so stdin closes as soon as the payload is written), then counts which request ids got a response on stdout.server.py
client.py
Results (
python client.py server.py N 20; "replies" = the initialize response plus one pertools/call):The server exits 0 on every run and stdout is valid JSON-RPC, there are simply fewer frames. Nothing on stderr on 2.2.0; on 1.27.2 with Python 3.14 the only stderr output is an unrelated
pydantic_settingswarning.Waiting before closing stdin does not help; only holding stdin open until every id has been answered does (mcp 1.27.2, 5 calls, 30 runs per row, same payload written through
Popenwith a reader thread on stdout):So the trigger is "stdin closed while requests are outstanding", not "closed too soon", which matches the cancel-on-EOF analysis above: the response for anything accepted but not yet written is dropped regardless of how long the client waits.
For comparison, the same client pattern (write everything, close stdin, count replies) against an equivalent one-tool echo server built on the TypeScript SDK (
@modelcontextprotocol/sdk) and on Rustrmcp3.2.0 lost 0 replies at every batch size in the table above (measured earlier this month with the same payload shape, not re-run today).sairam0424 commentedon Sep 14, 2026
We independently reproduced this while auditing our own server (cost-guard-mcp, a local-stdio-only MCP server whose tool calls can run up to 120 seconds - warehouse EXPLAIN/dry-run calls against BigQuery/Snowflake/Databricks). Confirmed the response drop happens exactly as described here.
For anyone else finding this: we could not find evidence either way on whether real, persistent MCP clients (Claude Desktop, Claude Code, Cursor) ever close stdin mid-tool-call during normal operation - every reproduction known to us (including our own) used batch/file-redirected stdin with an injected delay. So real-world exposure for well-behaved clients that keep stdin open for the session appears low, but this is still worth fixing given several long-running-tool-call servers (like ours) exist.
+1 on getting one of the existing fix attempts (#2680/#2682/#2815/#2821/#3026/#3421) merged - happy to help test if useful.
docs: note the mcp SDK stdin-EOF known limitation (#37)
stdio: answer every request read before stdin EOF, stop at once when …