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
+26Lines changed: 26 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1855,6 +1855,32 @@ 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
+
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
+
1858
1884
### WebSocket transport removed
1859
1885
1860
1886
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
-24Lines changed: 0 additions & 24 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -41,30 +41,6 @@ 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)**.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
-35Lines changed: 0 additions & 35 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -141,41 +141,6 @@ There is no error string for this, which is exactly why it is hard to search. Th
141
141
142
142
An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway.
143
143
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
-
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()
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
-
179
144
## `MCPError: Server returned an error response`
180
145
181
146
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.
0 commit comments