Skip to content

Commit bf24e5e

Browse files
committed
Isolate the stdio server's stdin from handler subprocesses
While serving on the process's real stdin, stdio_server() now reads the protocol from a private duplicate of fd 0 and points fd 0 (and, on Windows, the standard input handle) at the null device, restoring both on exit. Children spawned by handler code then inherit the null device instead of the protocol pipe. A child that inherited the pipe could consume protocol bytes on any platform, and on Windows a Python child hangs inside interpreter startup behind the transport's pending read (CPython gh-78961) until the next request arrives, so any tool that ran a subprocess without stdin=DEVNULL appeared to hang until timeout. Isolation engages only when sys.stdin is backed by the real fd 0, at most once per process, and degrades to reading stdin in place when the descriptor table cannot be rearranged. Fixes #671.
1 parent ebcc4dc commit bf24e5e

7 files changed

Lines changed: 479 additions & 99 deletions

File tree

docs/migration.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,6 +1855,32 @@ 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
1870+
the next request arrived: the
1871+
[#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.
1883+
18581884
### WebSocket transport removed
18591885

18601886
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: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -41,30 +41,6 @@ Nothing prints, and it doesn't return. It is waiting on stdin for a host to spea
4141

4242
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)**.
4343

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)**.
67-
6844
### Try it
6945

7046
```console

docs/troubleshooting.md

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -141,41 +141,6 @@ There is no error string for this, which is exactly why it is hard to search. Th
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: 26 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
@@ -20,14 +20,39 @@
2020
import pywintypes
2121
import win32api
2222
import win32con
23+
import win32file
2324
import win32job
2425
else:
2526
# Type stubs for non-Windows platforms
2627
win32api = None
2728
win32con = None
29+
win32file = None
2830
win32job = None
2931
pywintypes = None
3032

33+
34+
def rebind_std_handle_to_fd(fd: int) -> None:
35+
"""Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle.
36+
37+
os.dup2 updates only the C runtime's descriptor table; anything that resolves
38+
GetStdHandle — subprocess standard-handle inheritance, native code — reads the
39+
Win32 slot, so after retargeting a standard descriptor the slot must be
40+
repointed too.
41+
42+
Raises:
43+
OSError: The slot could not be set; callers treat descriptor
44+
rearrangement as best-effort and fall back on failure.
45+
"""
46+
if sys.platform != "win32" or not win32api or not win32file or not pywintypes:
47+
return
48+
std_ids = {0: win32api.STD_INPUT_HANDLE, 1: win32api.STD_OUTPUT_HANDLE, 2: win32api.STD_ERROR_HANDLE}
49+
try:
50+
win32api.SetStdHandle(std_ids[fd], win32file._get_osfhandle(fd))
51+
except pywintypes.error as exc:
52+
# Normalized so callers' OSError-based best-effort handling covers it.
53+
raise OSError(f"SetStdHandle failed for fd {fd}") from exc
54+
55+
3156
# How often FallbackProcess polls the underlying Popen for exit.
3257
_EXIT_POLL_INTERVAL = 0.01
3358

src/mcp/server/stdio.py

Lines changed: 134 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -17,61 +17,159 @@ async def run_server():
1717
```
1818
"""
1919

20+
import os
2021
import sys
21-
from contextlib import asynccontextmanager
22+
from collections.abc import Callable
23+
from contextlib import asynccontextmanager, suppress
2224
from io import TextIOWrapper
25+
from typing import BinaryIO, TextIO
2326

2427
import anyio
2528
import anyio.lowlevel
2629
import mcp_types as types
2730

31+
from mcp.os.win32.utilities import rebind_std_handle_to_fd
2832
from mcp.shared._context_streams import create_context_streams
2933
from mcp.shared.message import SessionMessage
3034

35+
# True while a transport in this process has fd 0 pointed at the null device.
36+
# A second concurrent stdio_server() must not claim again: it would duplicate
37+
# the null device instead of the protocol pipe, and its restore would clobber
38+
# the first transport's.
39+
_stdin_claimed = False
40+
41+
42+
def _is_backed_by_fd(stream: TextIO, fd: int) -> bool:
43+
"""Whether stream is a text wrapper over the process's real descriptor fd."""
44+
try:
45+
return stream.buffer.fileno() == fd
46+
except (AttributeError, OSError, ValueError):
47+
# In-memory or injected streams (tests, embedders) have no usable
48+
# descriptor; io.UnsupportedOperation is a subclass of OSError/ValueError.
49+
return False
50+
51+
52+
def _claim_stdin() -> tuple[BinaryIO, Callable[[], None] | None]:
53+
"""Returns the binary stream the transport reads the protocol from, and its undo.
54+
55+
When running on the process's real stdin, moves the protocol pipe to a
56+
private descriptor and points fd 0 (and, on Windows, the standard input
57+
handle) at the null device for the transport's lifetime. Child processes
58+
spawned by handlers then inherit the null device instead of the protocol
59+
pipe: a child holding the protocol pipe could consume protocol bytes, and
60+
on Windows a Python child hangs during interpreter startup while the
61+
transport's blocking read is pending on the shared pipe
62+
(https://github.com/python/cpython/issues/78961).
63+
64+
Isolation is best-effort: when the descriptors cannot be rearranged, or a
65+
transport in this process already claimed stdin, the returned stream is
66+
sys.stdin.buffer read in place (the pre-isolation behavior) and the undo
67+
callback is None.
68+
"""
69+
global _stdin_claimed
70+
if _stdin_claimed or not _is_backed_by_fd(sys.stdin, 0):
71+
return sys.stdin.buffer, None
72+
# Set before touching the descriptor table so a second transport entering
73+
# mid-claim serves in place instead of duplicating a half-moved fd 0.
74+
_stdin_claimed = True
75+
private_fd = None
76+
try:
77+
private_fd = os.dup(0)
78+
devnull_fd = os.open(os.devnull, os.O_RDONLY)
79+
try:
80+
os.dup2(devnull_fd, 0)
81+
finally:
82+
os.close(devnull_fd)
83+
if sys.platform == "win32": # pragma: no cover
84+
rebind_std_handle_to_fd(0)
85+
except OSError:
86+
_stdin_claimed = False
87+
# Isolation is best-effort: serve stdin in place, as before it existed.
88+
if private_fd is not None:
89+
# A completed dup2 is undone; an untouched fd 0 is re-pointed at
90+
# the same pipe it already holds, which is harmless.
91+
_restore_fd(0, private_fd)
92+
os.close(private_fd)
93+
return sys.stdin.buffer, None
94+
95+
def restore() -> None:
96+
global _stdin_claimed
97+
_restore_fd(0, private_fd)
98+
_stdin_claimed = False
99+
100+
# closefd=False: the reader may sit in a blocking read on this descriptor
101+
# in a worker thread past the transport's lifetime, so garbage collection
102+
# of the wrapper must never close (and free for reuse) the fd under it.
103+
return os.fdopen(private_fd, "rb", closefd=False), restore
104+
105+
106+
def _restore_fd(fd: int, private_fd: int) -> None:
107+
"""Points fd back at the protocol stream the transport claimed.
108+
109+
Best-effort: a failure must never mask whatever ended the transport, so it
110+
is swallowed rather than raised out of stdio_server's finally.
111+
"""
112+
with suppress(OSError):
113+
os.dup2(private_fd, fd)
114+
if sys.platform == "win32": # pragma: no cover
115+
rebind_std_handle_to_fd(fd)
116+
31117

32118
@asynccontextmanager
33119
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
34120
"""Server transport for stdio: this communicates with an MCP client by reading
35121
from the current process' stdin and writing to stdout.
122+
123+
While serving on the process's real stdin, the transport claims it: the
124+
protocol pipe moves to a private descriptor and fd 0 (with the Windows
125+
standard input handle) reads the null device, so handler code and its
126+
child processes cannot touch the protocol stream. Both are restored when
127+
the context exits. Passing an explicit stdin skips this entirely.
36128
"""
37129
# Purposely not using context managers for these, as we don't want to close
38130
# standard process handles. Encoding of stdin/stdout as text streams on
39131
# python is platform-dependent (Windows is particularly problematic), so we
40132
# re-wrap the underlying binary stream to ensure UTF-8.
41-
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
43-
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
133+
restore_stdin: Callable[[], None] | None = None
134+
try:
135+
if not stdin:
136+
stdin_buffer, restore_stdin = _claim_stdin()
137+
stdin = anyio.wrap_file(TextIOWrapper(stdin_buffer, encoding="utf-8", errors="replace"))
138+
if not stdout:
139+
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
45140

46-
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
47-
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
141+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
142+
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
48143

49-
async def stdin_reader():
50-
try:
51-
async with read_stream_writer:
52-
async for line in stdin:
53-
try:
54-
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
55-
except Exception as exc:
56-
await read_stream_writer.send(exc)
57-
continue
58-
59-
session_message = SessionMessage(message)
60-
await read_stream_writer.send(session_message)
61-
except anyio.ClosedResourceError: # pragma: no cover
62-
await anyio.lowlevel.checkpoint()
63-
64-
async def stdout_writer():
65-
try:
66-
async with write_stream_reader:
67-
async for session_message in write_stream_reader:
68-
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
69-
await stdout.write(json + "\n")
70-
await stdout.flush()
71-
except anyio.ClosedResourceError: # pragma: no cover
72-
await anyio.lowlevel.checkpoint()
73-
74-
async with anyio.create_task_group() as tg:
75-
tg.start_soon(stdin_reader)
76-
tg.start_soon(stdout_writer)
77-
yield read_stream, write_stream
144+
async def stdin_reader():
145+
try:
146+
async with read_stream_writer:
147+
async for line in stdin:
148+
try:
149+
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
150+
except Exception as exc:
151+
await read_stream_writer.send(exc)
152+
continue
153+
154+
session_message = SessionMessage(message)
155+
await read_stream_writer.send(session_message)
156+
except anyio.ClosedResourceError: # pragma: no cover
157+
await anyio.lowlevel.checkpoint()
158+
159+
async def stdout_writer():
160+
try:
161+
async with write_stream_reader:
162+
async for session_message in write_stream_reader:
163+
json = session_message.message.model_dump_json(by_alias=True, exclude_unset=True)
164+
await stdout.write(json + "\n")
165+
await stdout.flush()
166+
except anyio.ClosedResourceError: # pragma: no cover
167+
await anyio.lowlevel.checkpoint()
168+
169+
async with anyio.create_task_group() as tg:
170+
tg.start_soon(stdin_reader)
171+
tg.start_soon(stdout_writer)
172+
yield read_stream, write_stream
173+
finally:
174+
if restore_stdin is not None:
175+
restore_stdin()

0 commit comments

Comments
 (0)