Skip to content

Commit f74f608

Browse files
committed
Isolate the stdio server's stdout from handler code and subprocesses
While serving on the process's real stdout, stdio_server now moves the protocol pipe to a private descriptor and points fd 1 - and, on Windows, the standard output handle - at stderr, restoring it when the transport exits. A stray print() in handler code or a child process writing to its inherited stdout lands in the client's log instead of corrupting the JSON-RPC stream. The null device stands in when stderr is unusable or, on POSIX, is detected as merged into stdout (2>&1). The stdin claim generalizes into the shared _claim_fd mechanism: one lock-guarded sentinel table covers both descriptors, private wire duplicates are forced above the standard descriptor range so a process started with a standard descriptor closed cannot hand the wire out as its "stderr", and a failed claim degrades to serving the sys stream's buffer in place exactly as v1 did. Docs now describe the guarded behavior with its remaining gaps (output flushed before serving begins, injected streams, merged stderr on Windows), and the transport:stdio:stream-purity divergence narrows accordingly.
1 parent 42ef19a commit f74f608

12 files changed

Lines changed: 600 additions & 87 deletions

File tree

docs/get-started/real-host.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Once that command sits and waits, what's left is almost always one of three thin
165165

166166
* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too.
167167
* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect.
168-
* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
168+
* **Something reached stdout before serving began.** On stdio, stdout *is* the protocol. The SDK diverts stray output to stderr while serving, but anything that reaches stdout before then -- a wrapper script echoing, an import-time `print()` in an unbuffered process -- hands the host a corrupt message and it drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story.
169169

170170
Claude Desktop keeps a log per server: `mcp-server-<NAME>.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows.
171171

docs/handlers/logging.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ For a **stdio** server this question matters more than usual. The host launched
3333
The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean.
3434

3535
!!! tip
36-
Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
37-
line and the client is trying to parse it as JSON-RPC.
36+
Don't `print()` in a stdio server. `print` writes to **stdout**, and stdout belongs to the protocol:
37+
while serving, the SDK diverts stray stdout to stderr so it can't corrupt the wire, but that leaves
38+
your line interleaved raw among the log output -- no level, no logger name, no way to filter it.
3839

3940
`logger.debug("got here")` is the same one line of effort and goes to the right place.
4041

@@ -72,7 +73,7 @@ went to standard error: the terminal, not the wire.
7273
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
7374
* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
7475
* Log output never reaches the model. Only the value you `return` does.
75-
* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server.
76+
* Standard error is yours; stdout belongs to the protocol. The SDK diverts a stray `print()` to stderr while serving, but it arrives unlabeled -- use `logging`.
7677
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
7778

7879
Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**.

docs/migration.md

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,31 +1855,40 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
18551855
per-process terminate/kill fallback are gone. The win32 utilities logger is now
18561856
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
18571857

1858-
### `stdio_server` keeps the protocol stdin on a private descriptor
1859-
1860-
While serving on the process's real stdin, the stdio server transport now duplicates
1861-
the protocol pipe to a private descriptor and points fd 0 — and, on Windows, the
1862-
standard input handle — at the null device, restoring both when the transport exits.
1863-
Subprocesses started by handler code therefore inherit the null device instead of the
1864-
protocol pipe. (The claim is best-effort: in the rare process whose descriptor table
1865-
cannot be rearranged, the transport serves stdin in place, exactly as v1 did.)
1866-
1867-
In v1 a child inheriting the pipe could consume protocol bytes, and on Windows it
1868-
hung inside interpreter startup — behind the transport's pending read on the shared
1869-
pipe ([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — until
1858+
### `stdio_server` keeps the protocol streams on private descriptors
1859+
1860+
While serving on the process's real stdin and stdout, the stdio server transport now
1861+
duplicates each protocol pipe to a private descriptor and points the standard
1862+
descriptors — with their Windows standard handles — away from the wire, restoring
1863+
both when the transport exits: fd 0 reads the null device, and fd 1 writes to stderr
1864+
(the null device if stderr is unusable). Subprocesses started by handler code
1865+
therefore inherit the diversions instead of the protocol pipes, and a stray
1866+
`print()` lands on stderr rather than the wire. (The claim is best-effort: in the
1867+
rare process whose descriptor table cannot be rearranged, the transport serves in
1868+
place, exactly as v1 did.)
1869+
1870+
In v1 a child inheriting the pipes could consume protocol bytes or corrupt the
1871+
outgoing stream with its own output, and on Windows it hung inside interpreter
1872+
startup — behind the transport's pending read on the shared pipe
1873+
([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — until
18701874
the next request arrived: the
18711875
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) hang, for
1872-
which passing `stdin=subprocess.DEVNULL` on every spawn was the required
1873-
workaround. On v2 the workaround is no longer needed, for any spawn API,
1874-
redirected or not.
1875-
1876-
To migrate: nothing, unless handler code read `sys.stdin` (or called `input()`)
1877-
during a stdio session — it now sees end-of-file instead of racing the transport for
1878-
protocol bytes; there was never a meaningful value to read there. Likewise, bytes
1879-
something buffered out of `sys.stdin` before the server started no longer reach the
1880-
transport (they never reliably did). Passing explicit `stdin=`/`stdout=` streams to
1881-
`stdio_server(...)` skips the descriptor changes entirely, as does any environment
1882-
where `sys.stdin` is not backed by the process's real fd 0.
1876+
which passing `stdin=subprocess.DEVNULL` on every spawn (and capturing the child's
1877+
stdout) was the required workaround. On v2 the workaround is no longer needed, for
1878+
any spawn API, redirected or not.
1879+
1880+
To migrate: nothing, unless handler code used the standard streams directly during a
1881+
stdio session. Reading `sys.stdin` (or calling `input()`) now sees end-of-file
1882+
instead of racing the transport for protocol bytes; there was never a meaningful
1883+
value to read there. `print()` and other `sys.stdout` writes reach stderr instead of
1884+
corrupting the wire — code that deliberately wrote protocol frames to `sys.stdout`
1885+
must send them through the transport's write stream instead. A child that streams
1886+
a lot of output to its inherited stdout now streams it into the client's stderr
1887+
channel; capture output you don't want in the client's logs. Likewise, anything
1888+
that read ahead from `sys.stdin` before the server started keeps those bytes; they
1889+
no longer reach the transport (they never reliably did). Passing an explicit `stdin=`/`stdout=` stream to
1890+
`stdio_server(...)` skips the descriptor changes for that stream, as does any
1891+
environment where the sys stream is not backed by the process's real descriptor.
18831892

18841893
### WebSocket transport removed
18851894

docs/run/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ python server.py
3939

4040
Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first.
4141

42-
That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../handlers/logging.md)**.
42+
That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts stray output -- a `print()`, a subprocess writing to its inherited stdout -- to stderr, where it can't corrupt the stream. Output that reaches stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire. For output you actually want, the `logging` module is the right tool. That story is in **[Logging](../handlers/logging.md)**.
4343

4444
### Try it
4545

docs/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ There is no error string for this, which is exactly why it is hard to search. Th
137137
* **Is the tool on the `mcp` the host is running?** A second `MCPServer(...)` in another module is a different, empty server. Check which object the host's command actually imports.
138138
* **Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log.
139139
* **Is the host's list stale?** Adding a tool after startup only reaches clients that handle `notifications/tools/list_changed`. Restarting the host is the blunt fix.
140-
* **Did something write to `stdout`?** On a stdio transport, stdout *is* the protocol: one stray `print()` and the host drops the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
140+
* **Did something write to `stdout` before the server started serving?** While serving, the SDK diverts stray stdout to stderr (best-effort: an environment that replaces `sys.stdout`, or merges stderr into stdout on Windows, is served as-is), but output flushed to stdout earlier -- a wrapper script echoing, an import-time `print()` in an unbuffered process -- lands on the protocol stream, and one junk line can make the host drop the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**.
141141

142142
An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway.
143143

src/mcp/os/posix/utilities.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
1-
"""POSIX-specific functionality for stdio client operations."""
1+
"""POSIX-specific functionality for stdio transport operations."""
22

33
import logging
44
import os
55
import signal
6+
import sys
67
from contextlib import suppress
78

89
import anyio
910
from anyio.abc import Process
1011

1112
logger = logging.getLogger(__name__)
1213

14+
15+
def same_open_file(fd_a: int, fd_b: int) -> bool:
16+
"""Whether two descriptors refer to the same open file.
17+
18+
False on Windows - anonymous pipe handles carry no identity to compare -
19+
and False when either descriptor cannot be interrogated.
20+
"""
21+
if sys.platform == "win32":
22+
return False
23+
try:
24+
return os.path.sameopenfile(fd_a, fd_b)
25+
except OSError:
26+
return False
27+
28+
1329
# How often to probe for surviving group members between SIGTERM and SIGKILL.
1430
_GROUP_POLL_INTERVAL = 0.01
1531

0 commit comments

Comments
 (0)