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
@@ -1863,6 +1863,32 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
1863
1863
per-process terminate/kill fallback are gone. The win32 utilities logger is now
1864
1864
named `mcp.os.win32.utilities` (was `client.stdio.win32`).
1865
1865
1866
+
### `stdio_server` keeps the protocol stdin on a private descriptor
1867
+
1868
+
While serving on the process's real stdin, the stdio server transport now duplicates
1869
+
the protocol pipe to a private descriptor and points fd 0 — and, on Windows, the
1870
+
standard input handle — at the null device, restoring both when the transport exits.
1871
+
Subprocesses started by handler code therefore inherit the null device instead of the
1872
+
protocol pipe. (The claim is best-effort: in the rare process whose descriptor table
1873
+
cannot be rearranged, the transport serves stdin in place, exactly as v1 did.)
1874
+
1875
+
In v1 a child inheriting the pipe could consume protocol bytes, and on Windows it
1876
+
hung inside interpreter startup — behind the transport's pending read on the shared
1877
+
pipe ([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — until
1878
+
the next request arrived: the
1879
+
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) hang, for
1880
+
which passing `stdin=subprocess.DEVNULL` on every spawn was the required
1881
+
workaround. On v2 the workaround is no longer needed, for any spawn API,
1882
+
redirected or not.
1883
+
1884
+
To migrate: nothing, unless handler code read `sys.stdin` (or called `input()`)
1885
+
during a stdio session — it now sees end-of-file instead of racing the transport for
1886
+
protocol bytes; there was never a meaningful value to read there. Likewise, bytes
1887
+
something buffered out of `sys.stdin` before the server started no longer reach the
1888
+
transport (they never reliably did). Passing explicit `stdin=`/`stdout=` streams to
1889
+
`stdio_server(...)` skips the descriptor changes entirely, as does any environment
1890
+
where `sys.stdin` is not backed by the process's real fd 0.
1891
+
1866
1892
### WebSocket transport removed
1867
1893
1868
1894
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