You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: docs/migration.md
+23Lines changed: 23 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1855,6 +1855,29 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
1855
1855
per-process terminate/kill fallback are gone. The win32 utilities logger is now
1856
1856
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
1857
1857
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
+
Previously a child inheriting the pipe could consume protocol bytes, and on Windows
1868
+
hung at interpreter startup until the next request arrived — the
1869
+
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) hang; the
1870
+
mechanism and the `stdin=subprocess.DEVNULL` workaround for older versions live in
1871
+
the [troubleshooting entry](troubleshooting.md#my-stdio-tool-hangs-when-it-starts-a-subprocess-on-windows).
1872
+
1873
+
To migrate: nothing, unless handler code read `sys.stdin` (or called `input()`)
1874
+
during a stdio session — it now sees end-of-file instead of racing the transport for
1875
+
protocol bytes; there was never a meaningful value to read there. Likewise, bytes
1876
+
something buffered out of `sys.stdin` before the server started no longer reach the
1877
+
transport (they never reliably did). Passing explicit `stdin=`/`stdout=` streams to
1878
+
`stdio_server(...)` skips the descriptor changes entirely, as does any environment
1879
+
where `sys.stdin` is not backed by the process's real fd 0.
1880
+
1858
1881
### WebSocket transport removed
1859
1882
1860
1883
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.
Copy file name to clipboardExpand all lines: docs/run/index.md
+1-23Lines changed: 1 addition & 23 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -41,29 +41,7 @@ Nothing prints, and it doesn't return. It is waiting on stdin for a host to spea
41
41
42
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
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
-
asyncdefrun_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
-
returnawait 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)**.
44
+
stdin, on the other hand, is guarded for you. While the server runs, the protocol's stdin lives on a private descriptor and fd 0 is the null device, so a subprocess your tool starts gets the null device instead of the protocol pipe — on older SDK versions that inheritance hung Python children on Windows. The child does still inherit stdout — the wire — unless you capture it, so pipe the child's output like the example in **[My stdio tool hangs when it starts a subprocess on Windows](../troubleshooting.md#my-stdio-tool-hangs-when-it-starts-a-subprocess-on-windows)**, which also has the mechanism and the redirection to use on older versions.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
+6-27Lines changed: 6 additions & 27 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -143,38 +143,17 @@ An "invalid" tool name is *not* on that list: a non-conforming name logs a warni
143
143
144
144
## My stdio tool hangs when it starts a subprocess on Windows
145
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.
146
+
Your server is running over `stdio`, and a tool starts another process — `subprocess.run`, `subprocess.Popen`, `asyncio.create_subprocess_exec`. On Windows the call never returns, while the same code works over an HTTP transport. And if the client sends *another* request, the stuck call completes at that exact moment: hangs-until-the-timeout-then-suddenly-ran is the signature of this one.
150
147
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.
148
+
The child inherited the server's stdin: the protocol pipe, which the transport always has a blocking read pending on. Windows serializes operations on a pipe like that, and a Python child touches its inherited stdin during interpreter startup ([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — so the child freezes *before your script's first line* until the pending read completes, which is exactly when the next JSON-RPC message arrives. Skipping the redirections entirely doesn't dodge it, either: Windows hands a console child the parent's standard handles even then.
155
149
156
-
If you do not intend to send input to the child, redirect its stdin:
150
+
Current versions of the SDK defuse the collision: while it serves, `stdio_server` moves the protocol pipe to a private descriptor and leaves the null device on fd 0 (and on the Windows standard input handle), so a child has nothing of the transport's to inherit. If you can reproduce this hang, you are on an older version — or wiring `stdio_server(stdin=..., stdout=...)` to explicit streams yourself, which skips the guard (as does the rare process whose descriptor table can't be rearranged; the transport then serves stdin in place, as older versions did). Upgrade, or redirect the child's stdin:
157
151
158
-
```python
159
-
import asyncio
160
-
import subprocess
161
-
import sys
162
-
163
-
164
-
asyncdefrun_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
-
returnawait process.communicate()
152
+
```python title="server.py" hl_lines="16"
153
+
--8<--"docs_src/troubleshooting/tutorial009.py"
173
154
```
174
155
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.
156
+
`stdin=subprocess.DEVNULL` is the line that matters, and the same argument works for `subprocess.run`, `subprocess.Popen`, and `asyncio.create_subprocess_shell`. It stays good hygiene on any version and any platform: a child you don't mean to feed input has no business holding your stdin. Capturing the child's stdout, as above, is its stdout-side twin — see the warning about the wire in **[Running your server](run/index.md#stdio)**.
0 commit comments