Skip to content

Commit 52d9409

Browse files
committed
Refuse abnormal processes instead of repairing them in stdio isolation
The stdin/stdout claim now engages only in a normal process: the sys stream backed by its real descriptor and fds 0-2 all open, which makes it impossible for the private wire duplicates to land in the standard range. Anything else - replaced streams, an incomplete descriptor table, a failed dup - is served in place exactly as v1 was, and a second concurrent stdio_server() raises RuntimeError instead of contending for the streams. This replaces the previous hardening (the dup-above-standard-range loop, stderr-merge detection, and nested transports serving into the diversion) with guards, and removes the migration entry: no working code changes behavior, so there is nothing to migrate. Comments and docstrings trimmed throughout the diff.
1 parent f74f608 commit 52d9409

9 files changed

Lines changed: 136 additions & 544 deletions

File tree

docs/migration.md

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,41 +1855,6 @@ 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 streams on private descriptors
1859-
1860-
While serving on the process's real stdin and stdout, the stdio server transport now
1861-
duplicates each protocol pipe to a private descriptor and points the standard
1862-
descriptors — with their Windows standard handles — away from the wire, restoring
1863-
both when the transport exits: fd 0 reads the null device, and fd 1 writes to stderr
1864-
(the null device if stderr is unusable). Subprocesses started by handler code
1865-
therefore inherit the diversions instead of the protocol pipes, and a stray
1866-
`print()` lands on stderr rather than the wire. (The claim is best-effort: in the
1867-
rare process whose descriptor table cannot be rearranged, the transport serves in
1868-
place, exactly as v1 did.)
1869-
1870-
In v1 a child inheriting the pipes could consume protocol bytes or corrupt the
1871-
outgoing stream with its own output, and on Windows it hung inside interpreter
1872-
startup — behind the transport's pending read on the shared pipe
1873-
([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — until
1874-
the next request arrived: the
1875-
[#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) hang, for
1876-
which passing `stdin=subprocess.DEVNULL` on every spawn (and capturing the child's
1877-
stdout) was the required workaround. On v2 the workaround is no longer needed, for
1878-
any spawn API, redirected or not.
1879-
1880-
To migrate: nothing, unless handler code used the standard streams directly during a
1881-
stdio session. Reading `sys.stdin` (or calling `input()`) now sees end-of-file
1882-
instead of racing the transport for protocol bytes; there was never a meaningful
1883-
value to read there. `print()` and other `sys.stdout` writes reach stderr instead of
1884-
corrupting the wire — code that deliberately wrote protocol frames to `sys.stdout`
1885-
must send them through the transport's write stream instead. A child that streams
1886-
a lot of output to its inherited stdout now streams it into the client's stderr
1887-
channel; capture output you don't want in the client's logs. Likewise, anything
1888-
that read ahead from `sys.stdin` before the server started keeps those bytes; they
1889-
no longer reach the transport (they never reliably did). Passing an explicit `stdin=`/`stdout=` stream to
1890-
`stdio_server(...)` skips the descriptor changes for that stream, as does any
1891-
environment where the sys stream is not backed by the process's real descriptor.
1892-
18931858
### WebSocket transport removed
18941859

18951860
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/troubleshooting.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ 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` before the server started serving?** While serving, the SDK diverts stray stdout to stderr (best-effort: an environment that replaces `sys.stdout`, or merges stderr into stdout on Windows, is served as-is), but output flushed to stdout earlier -- a wrapper script echoing, an import-time `print()` in an unbuffered process -- 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)**.
140+
* **Did something write to `stdout` before the server started serving?** While serving, the SDK diverts 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 -- 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

src/mcp/os/posix/utilities.py

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,15 @@
1-
"""POSIX-specific functionality for stdio transport operations."""
1+
"""POSIX-specific functionality for stdio client operations."""
22

33
import logging
44
import os
55
import signal
6-
import sys
76
from contextlib import suppress
87

98
import anyio
109
from anyio.abc import Process
1110

1211
logger = logging.getLogger(__name__)
1312

14-
15-
def same_open_file(fd_a: int, fd_b: int) -> bool:
16-
"""Whether two descriptors refer to the same open file.
17-
18-
False on Windows - anonymous pipe handles carry no identity to compare -
19-
and False when either descriptor cannot be interrogated.
20-
"""
21-
if sys.platform == "win32":
22-
return False
23-
try:
24-
return os.path.sameopenfile(fd_a, fd_b)
25-
except OSError:
26-
return False
27-
28-
2913
# How often to probe for surviving group members between SIGTERM and SIGKILL.
3014
_GROUP_POLL_INTERVAL = 0.01
3115

src/mcp/os/win32/utilities.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,11 @@
3434
def rebind_std_handle_to_fd(fd: int) -> None:
3535
"""Points the Win32 standard-handle slot for fd 0, 1, or 2 at fd's current OS handle.
3636
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.
37+
os.dup2 updates only the CRT descriptor table; subprocess handle inheritance
38+
reads the Win32 slot, so it must be repointed too.
4139
4240
Raises:
43-
OSError: The slot could not be set; callers treat descriptor
44-
rearrangement as best-effort and fall back on failure.
41+
OSError: The slot could not be set.
4542
"""
4643
if sys.platform != "win32" or not win32api or not win32file or not pywintypes:
4744
return

src/mcp/server/stdio.py

Lines changed: 31 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
1-
"""Stdio Server Transport Module
2-
3-
This module provides functionality for creating an stdio-based transport layer
4-
that can be used to communicate with an MCP client through standard input/output
5-
streams.
1+
"""Stdio server transport for MCP.
62
73
Example:
84
```python
95
async def run_server():
106
async with stdio_server() as (read_stream, write_stream):
11-
# read_stream contains incoming JSONRPCMessages from stdin
12-
# write_stream allows sending JSONRPCMessages to stdout
137
server = await create_my_server()
148
await server.run(read_stream, write_stream, init_options)
159
@@ -29,109 +23,59 @@ async def run_server():
2923
import anyio.lowlevel
3024
import mcp_types as types
3125

32-
from mcp.os.posix.utilities import same_open_file
3326
from mcp.os.win32.utilities import rebind_std_handle_to_fd
3427
from mcp.shared._context_streams import create_context_streams
3528
from mcp.shared.message import SessionMessage
3629

37-
# Descriptors a transport in this process currently has pointed away from
38-
# their protocol pipes. A second concurrent stdio_server() must not claim an
39-
# fd again: it would duplicate the diversion target instead of the protocol
40-
# pipe, and its restore would clobber the first transport's. The lock makes
41-
# the check-and-claim atomic for embedders running transports on threads.
4230
_claimed: set[int] = set()
4331
_claimed_lock = threading.Lock()
4432

4533

4634
def _is_backed_by_fd(stream: TextIO, fd: int) -> bool:
47-
"""Whether stream is a text wrapper over the process's real descriptor fd."""
4835
try:
4936
return stream.buffer.fileno() == fd
5037
except (AttributeError, OSError, ValueError):
51-
# In-memory or injected streams (tests, embedders) have no usable
52-
# descriptor; io.UnsupportedOperation is a subclass of OSError/ValueError.
5338
return False
5439

5540

56-
def _dup_above_std(fd: int) -> int:
57-
"""Duplicates fd onto a descriptor above the standard range (fd > 2).
58-
59-
os.dup hands out the lowest free slot, so in a process started with a
60-
standard descriptor closed the private wire duplicate would itself become
61-
fd 0, 1, or 2 - handed to children as a standard stream, and, when it
62-
lands on fd 2, silently made the target of the stdout diversion.
63-
64-
Raises:
65-
OSError: propagated from os.dup; nothing is leaked into the standard
66-
range - every duplicate made so far is closed first.
67-
"""
68-
duplicates = [os.dup(fd)]
41+
def _std_descriptors_open() -> bool:
42+
"""Whether fds 0-2 are all open, so a dup cannot land in the standard range."""
6943
try:
70-
while duplicates[-1] <= 2:
71-
duplicates.append(os.dup(fd))
44+
for fd in (0, 1, 2):
45+
os.fstat(fd)
7246
except OSError:
73-
for duplicate in duplicates:
74-
os.close(duplicate)
75-
raise
76-
for below_std in duplicates[:-1]:
77-
os.close(below_std)
78-
return duplicates[-1]
47+
return False
48+
return True
7949

8050

8151
def _open_stdin_diversion() -> int:
82-
"""What fd 0 reads while claimed: the null device, so readers see EOF."""
8352
return os.open(os.devnull, os.O_RDONLY)
8453

8554

8655
def _open_stdout_diversion() -> int:
87-
"""What fd 1 receives while claimed: stderr, where stray output is at least
88-
visible in the client's logs, or the null device when stderr is unusable
89-
or is itself the wire (stderr merged into stdout, the 2>&1 launch shape -
90-
detectable on POSIX; on Windows such a merge keeps its v1 behavior)."""
91-
if not same_open_file(2, 1):
92-
try:
93-
return os.dup(2)
94-
except OSError:
95-
pass
96-
return os.open(os.devnull, os.O_WRONLY)
56+
try:
57+
return os.dup(2)
58+
except OSError:
59+
return os.open(os.devnull, os.O_WRONLY)
9760

9861

9962
def _claim_fd(
10063
fd: int, stream: TextIO, mode: Literal["rb", "wb"], open_diversion: Callable[[], int]
10164
) -> tuple[BinaryIO, Callable[[], None] | None]:
102-
"""Returns the binary stream the transport uses for the protocol, and its undo.
103-
104-
When stream is the process's real standard stream, moves the protocol pipe
105-
to a private descriptor and points fd (and, on Windows, the matching
106-
standard handle) at the diversion for the transport's lifetime, so handler
107-
code and its child processes touch the diversion instead of the protocol
108-
pipe; stdio_server's docstring describes the resulting behavior.
109-
110-
The claim is best-effort: when the descriptors cannot be rearranged the
111-
transport serves the sys stream's buffer in place, exactly as before
112-
isolation existed, and when a transport in this process already claimed
113-
fd it serves a fresh buffered view of wherever fd currently points. In
114-
both cases the undo callback is None. A stream with no real descriptor
115-
is served through its own buffer.
65+
"""Move the protocol pipe to a private descriptor and divert fd while serving.
66+
67+
Raises:
68+
RuntimeError: fd is already claimed by another transport in this process.
11669
"""
117-
if not _is_backed_by_fd(stream, fd):
70+
if not _is_backed_by_fd(stream, fd) or not _std_descriptors_open():
11871
return stream.buffer, None
119-
# Claimed before touching the descriptor table so a second transport
120-
# entering mid-claim serves in place instead of duplicating a half-moved
121-
# descriptor.
12272
with _claimed_lock:
123-
already_claimed = fd in _claimed
124-
if not already_claimed:
125-
_claimed.add(fd)
126-
if already_claimed:
127-
# An enclosing transport owns the claim. Serve wherever fd currently
128-
# points - the diversion - through a fresh view rather than the sys
129-
# stream's buffer: its cached seekability describes the pre-claim
130-
# target, and interrogating it can fail on the retargeted descriptor.
131-
return open(fd, mode, closefd=False), None
73+
if fd in _claimed:
74+
raise RuntimeError(f"another stdio_server() in this process has already claimed fd {fd}")
75+
_claimed.add(fd)
13276
private_fd = None
13377
try:
134-
private_fd = _dup_above_std(fd)
78+
private_fd = os.dup(fd)
13579
diversion_fd = open_diversion()
13680
try:
13781
os.dup2(diversion_fd, fd)
@@ -140,43 +84,27 @@ def _claim_fd(
14084
if sys.platform == "win32": # pragma: no cover
14185
rebind_std_handle_to_fd(fd)
14286
except OSError:
143-
with _claimed_lock:
144-
_claimed.discard(fd)
87+
# Undo before unclaiming: fd stays claimed for as long as it is diverted.
14588
if private_fd is not None:
146-
# A completed dup2 is undone; an untouched fd is re-pointed at
147-
# the same pipe it already holds, which is harmless.
14889
_restore_fd(fd, private_fd)
14990
os.close(private_fd)
150-
# fd still holds the protocol pipe, so the sys stream's buffer is
151-
# target-consistent: serve it in place, exactly as v1 did - shared
152-
# write ordering, no new descriptors to allocate in a process whose
153-
# descriptor table is already failing.
91+
with _claimed_lock:
92+
_claimed.discard(fd)
15493
return stream.buffer, None
15594

15695
def restore() -> None:
157-
# Flush first: text buffered in the sys stream during the claim (a
158-
# stray print() while stdout is claimed) drains to the diversion, not
159-
# to the restored protocol pipe.
96+
# Drain text buffered during the claim (a stray print) to the diversion.
16097
with suppress(OSError, ValueError):
16198
stream.flush()
16299
_restore_fd(fd, private_fd)
163100
with _claimed_lock:
164101
_claimed.discard(fd)
165102

166-
# closefd=False: an I/O call may sit blocked on this descriptor in a
167-
# worker thread past the transport's lifetime, so garbage collection of
168-
# the wrapper must never close (and free for reuse) the fd under it. The
169-
# private descriptor is deliberately left open for the same reason - one
170-
# descriptor per stream per session, restore() only points fd back at it.
103+
# closefd=False: a blocked worker thread may still read this descriptor after exit.
171104
return os.fdopen(private_fd, mode, closefd=False), restore
172105

173106

174107
def _restore_fd(fd: int, private_fd: int) -> None:
175-
"""Points fd back at the protocol stream the transport claimed.
176-
177-
Best-effort: a failure must never mask whatever ended the transport, so it
178-
is swallowed rather than raised out of stdio_server's finally.
179-
"""
180108
with suppress(OSError):
181109
os.dup2(private_fd, fd)
182110
if sys.platform == "win32": # pragma: no cover
@@ -185,23 +113,13 @@ def _restore_fd(fd: int, private_fd: int) -> None:
185113

186114
@asynccontextmanager
187115
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
188-
"""Server transport for stdio: this communicates with an MCP client by reading
189-
from the current process' stdin and writing to stdout.
190-
191-
While serving on the process's real stdin and stdout, the transport claims
192-
them: each protocol pipe moves to a private descriptor, fd 0 (with the
193-
Windows standard input handle) reads the null device, and fd 1 (with the
194-
standard output handle) is diverted to stderr — the null device if stderr
195-
is unusable. Handler code and the child processes it spawns can therefore
196-
neither consume protocol bytes nor corrupt the outgoing stream: reads see
197-
end-of-file, and stray writes (a `print()`, a child's inherited stdout)
198-
land on stderr. Both descriptors are restored when the context exits.
199-
Passing an explicit stream skips the claim for that side.
116+
"""Serve MCP over the process's stdin and stdout.
117+
118+
While serving, fd 0 points at the null device and fd 1 at stderr — handlers
119+
and children read EOF and stray output misses the wire — both restored on exit.
120+
Explicit streams skip the claim; a second concurrent stdio_server() raises RuntimeError.
200121
"""
201-
# Purposely not using context managers for these, as we don't want to close
202-
# standard process handles. Encoding of stdin/stdout as text streams on
203-
# python is platform-dependent (Windows is particularly problematic), so we
204-
# re-wrap the underlying binary stream to ensure UTF-8.
122+
# Re-wrap the binary buffers as UTF-8 text; the std handles' platform encodings are unreliable.
205123
restore_stdin: Callable[[], None] | None = None
206124
restore_stdout: Callable[[], None] | None = None
207125
try:

tests/interaction/_requirements.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3919,9 +3919,9 @@ def __post_init__(self) -> None:
39193919
"While serving, stdio_server moves the wire to private descriptors and diverts fd 0/1, so "
39203920
"handler code and its child processes can neither read protocol bytes nor write into the "
39213921
"stream (pinned by tests/server/test_stdio.py). Remaining gaps: output flushed to stdout "
3922-
"before the transport enters can still precede the first frame; the claim is best-effort "
3923-
"and skipped for explicitly injected streams; and a stderr merged into stdout (2>&1) is "
3924-
"detected and neutralized only on POSIX - Windows pipe handles carry no identity."
3922+
"before the transport enters can still precede the first frame, and the claim is "
3923+
"best-effort - skipped for explicitly injected streams and for processes without "
3924+
"normal standard descriptors."
39253925
),
39263926
),
39273927
),

0 commit comments

Comments
 (0)