Skip to content

Commit 629ca29

Browse files
authored
Isolate the stdio server's stdin and stdout from handler subprocesses (#3117)
1 parent 00a7014 commit 629ca29

12 files changed

Lines changed: 811 additions & 169 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 outside the diverted window.** On stdio, stdout *is* the protocol. The SDK diverts flushed stray output to stderr while serving, but output flushed to stdout before then (a wrapper script echoing, an import-time `print()` in an unbuffered process), or a buffered `print()` drained at interpreter exit, hands the host a corrupt message and it drops the connection. Log with the default `logging` configuration, whose stderr handler flushes each record; custom handlers must also avoid stdout. **[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: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,11 @@ 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 stdout that is actually *flushed* to stderr, so it can't corrupt the
38+
wire, but a `print()` in a block-buffered process usually sits unflushed in `sys.stdout`'s buffer
39+
until the interpreter drains it at exit, straight onto the protocol stream. Even when it is diverted,
40+
the line lands raw among the log output, with no level, no logger name, and no way to filter it.
3841

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

@@ -72,7 +75,7 @@ went to standard error: the terminal, not the wire.
7275
* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
7376
* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern.
7477
* 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.
78+
* Standard error is yours; stdout belongs to the protocol. The SDK diverts flushed stray stdout to stderr while serving, but an unflushed `print()` can still drain onto the wire at exit, and diverted lines arrive unlabeled; use `logging`, whose handler flushes every record.
7679
* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone.
7780

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

docs/migration.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,6 +1863,36 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
18631863
per-process terminate/kill fallback are gone. The win32 utilities logger is now
18641864
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
18651865

1866+
### `stdio_server` keeps the protocol streams on private descriptors
1867+
1868+
While serving, the stdio transport moves the wire to private descriptors and points
1869+
fd 0 at the null device and fd 1 at stderr, restoring both on exit. Subprocesses and
1870+
handler code can no longer read protocol bytes or write into the stream (the
1871+
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) fix). Ordinary
1872+
servers have nothing to do, and code that inspects or manipulates fd 0/1 directly
1873+
during a session now sees the diversions, not the wire.
1874+
1875+
One pattern needs migrating: watchdog threads that watch fd 0 to detect a vanished
1876+
client (a POSIX-specific pattern; `select.poll` does not exist on Windows). The null
1877+
device does not behave like the old pipe: it never reports `POLLHUP` or `POLLERR`,
1878+
and it reports readable immediately and permanently (`POLLIN` from `poll()` on Linux,
1879+
plus `POLLOUT` under the default event mask; ready from `select()`; and macOS can
1880+
report `POLLNVAL` for devices). A watcher waiting for `POLLHUP` or `POLLERR` is
1881+
silently disarmed; a watcher that treats any event as "client gone" now fires at
1882+
startup instead of never. Watch the parent process instead: on POSIX, exit
1883+
when `os.getppid()` changes, which happens when the client dies because orphaned
1884+
processes are reparented. That works on both v1 and v2 and does not depend on
1885+
descriptor layout.
1886+
1887+
Also new: a second concurrent `stdio_server()` on the process's default streams now
1888+
raises `RuntimeError` instead of silently contending for stdin, a configuration that
1889+
never worked (there is one stdin).
1890+
1891+
Also worth knowing: a child process that streams large output to its inherited
1892+
stdout now streams it into the client's stderr channel. Capture output you do not
1893+
want in the client's logs, and be aware that a client which never drains its stderr
1894+
pipe applies back-pressure to the server (true of stderr logging on v1 as well).
1895+
18661896
### WebSocket transport removed
18671897

18681898
The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP.

docs/run/index.md

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,31 +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)**.
43-
44-
On Windows, the same rule applies to child processes your tools start. A child
45-
that inherits the stdio server's stdin can block behind the server's protocol
46-
reader. If your tool starts a subprocess and you do not intend to feed it input,
47-
redirect the child's stdin:
48-
49-
```python
50-
import asyncio
51-
import subprocess
52-
import sys
53-
54-
55-
async def run_script() -> tuple[bytes, bytes]:
56-
process = await asyncio.create_subprocess_exec(
57-
sys.executable,
58-
"script.py",
59-
stdin=subprocess.DEVNULL,
60-
stdout=subprocess.PIPE,
61-
stderr=subprocess.PIPE,
62-
)
63-
return await process.communicate()
64-
```
65-
66-
The matching troubleshooting entry is **[My stdio tool hangs when it starts a subprocess on Windows](../troubleshooting.md#my-stdio-tool-hangs-when-it-starts-a-subprocess-on-windows)**.
42+
That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts output that is *flushed* to stdout (a subprocess writing to its inherited stdout, a flushed `print()`) to stderr, where it can't corrupt the stream. Output flushed to stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire, and so does a `print()` that stays buffered until the interpreter drains it at exit. For output you actually want, the `logging` module is the right tool: its handler flushes each record to stderr as it happens. That story is in **[Logging](../handlers/logging.md)**.
6743

6844
### Try it
6945

docs/troubleshooting.md

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -137,45 +137,10 @@ 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` outside the diverted window?** While serving, the SDK diverts *flushed* stray stdout to stderr (best-effort: an environment that replaces the standard streams is served as-is), but output flushed to stdout earlier (a wrapper script echoing, an import-time `print()` in an unbuffered process) or a buffered `print()` drained at interpreter exit 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

144-
## My stdio tool hangs when it starts a subprocess on Windows
145-
146-
Your server is running over `stdio`, and a tool starts another process with
147-
`asyncio.create_subprocess_exec`, `asyncio.create_subprocess_shell`, or
148-
`subprocess.Popen`. The tool call never returns on Windows, while the same code
149-
works over an HTTP transport.
150-
151-
The child inherited the server's stdin. In a stdio server, stdin is the protocol
152-
pipe and the server is already waiting on it for the next JSON-RPC message. A
153-
Python child process on Windows can block during startup when it inherits that
154-
same pipe.
155-
156-
If you do not intend to send input to the child, redirect its stdin:
157-
158-
```python
159-
import asyncio
160-
import subprocess
161-
import sys
162-
163-
164-
async def run_script() -> tuple[bytes, bytes]:
165-
process = await asyncio.create_subprocess_exec(
166-
sys.executable,
167-
"script.py",
168-
stdin=subprocess.DEVNULL,
169-
stdout=subprocess.PIPE,
170-
stderr=subprocess.PIPE,
171-
)
172-
return await process.communicate()
173-
```
174-
175-
Use the same idea with `subprocess.Popen(..., stdin=subprocess.DEVNULL)`. Also
176-
capture or redirect the child's stdout. The stdio server's stdout is the MCP
177-
wire, so a child that writes there can corrupt the connection.
178-
179144
## `MCPError: Server returned an error response`
180145

181146
The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in.

src/mcp/os/win32/utilities.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Windows-specific functionality for stdio client operations."""
1+
"""Windows-specific functionality for stdio transport operations."""
22

33
import logging
44
import shutil
@@ -17,6 +17,8 @@
1717

1818
# Windows-specific imports for Job Objects
1919
if sys.platform == "win32":
20+
import msvcrt
21+
2022
import pywintypes
2123
import win32api
2224
import win32con
@@ -25,9 +27,30 @@
2527
# Type stubs for non-Windows platforms
2628
win32api = None
2729
win32con = None
30+
msvcrt = None
2831
win32job = None
2932
pywintypes = None
3033

34+
35+
def rebind_std_handle_to_fd(fd: int) -> None:
36+
"""Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle.
37+
38+
os.dup2 updates only the CRT descriptor table; subprocess handle inheritance
39+
reads the Win32 slot, so it must be repointed too.
40+
41+
Raises:
42+
OSError: The slot could not be set.
43+
"""
44+
if sys.platform != "win32" or not win32api or not msvcrt or not pywintypes:
45+
return
46+
std_ids = {0: win32api.STD_INPUT_HANDLE, 1: win32api.STD_OUTPUT_HANDLE, 2: win32api.STD_ERROR_HANDLE}
47+
try:
48+
win32api.SetStdHandle(std_ids[fd], msvcrt.get_osfhandle(fd))
49+
except pywintypes.error as exc:
50+
# Normalized so callers' OSError-based best-effort handling covers it.
51+
raise OSError(f"SetStdHandle failed for fd {fd}") from exc
52+
53+
3154
# How often FallbackProcess polls the underlying Popen for exit.
3255
_EXIT_POLL_INTERVAL = 0.01
3356

0 commit comments

Comments
 (0)