-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Isolate the stdio server's stdin and stdout from handler subprocesses #3117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1855,6 +1855,41 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the | |
| per-process terminate/kill fallback are gone. The win32 utilities logger is now | ||
| named `mcp.os.win32.utilities` (was `client.stdio.win32`). | ||
|
|
||
| ### `stdio_server` keeps the protocol streams on private descriptors | ||
|
|
||
| While serving on the process's real stdin and stdout, the stdio server transport now | ||
| duplicates each protocol pipe to a private descriptor and points the standard | ||
| descriptors — with their Windows standard handles — away from the wire, restoring | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Windows processes launched with stderr merged into stdout can still send stray handler or child output onto the protocol pipe, but this migration note says fd 1 is always diverted away from the wire and that capturing child stdout is no longer needed. Please document this Windows merge exception here (and retain the capture workaround for that launch shape), so users do not rely on isolation that the implementation deliberately cannot provide. Prompt for AI agents |
||
| both when the transport exits: fd 0 reads the null device, and fd 1 writes to stderr | ||
| (the null device if stderr is unusable). Subprocesses started by handler code | ||
| therefore inherit the diversions instead of the protocol pipes, and a stray | ||
| `print()` lands on stderr rather than the wire. (The claim is best-effort: in the | ||
| rare process whose descriptor table cannot be rearranged, the transport serves in | ||
| place, exactly as v1 did.) | ||
|
|
||
| In v1 a child inheriting the pipes could consume protocol bytes or corrupt the | ||
| outgoing stream with its own output, and on Windows it hung inside interpreter | ||
| startup — behind the transport's pending read on the shared pipe | ||
| ([CPython gh-78961](https://github.com/python/cpython/issues/78961)) — until | ||
| the next request arrived: the | ||
| [#671](https://github.com/modelcontextprotocol/python-sdk/issues/671) hang, for | ||
| which passing `stdin=subprocess.DEVNULL` on every spawn (and capturing the child's | ||
| stdout) was the required workaround. On v2 the workaround is no longer needed, for | ||
| any spawn API, redirected or not. | ||
|
|
||
| To migrate: nothing, unless handler code used the standard streams directly during a | ||
| stdio session. Reading `sys.stdin` (or calling `input()`) now sees end-of-file | ||
| instead of racing the transport for protocol bytes; there was never a meaningful | ||
| value to read there. `print()` and other `sys.stdout` writes reach stderr instead of | ||
| corrupting the wire — code that deliberately wrote protocol frames to `sys.stdout` | ||
| must send them through the transport's write stream instead. A child that streams | ||
| a lot of output to its inherited stdout now streams it into the client's stderr | ||
| channel; capture output you don't want in the client's logs. Likewise, anything | ||
| that read ahead from `sys.stdin` before the server started keeps those bytes; they | ||
| no longer reach the transport (they never reliably did). Passing an explicit `stdin=`/`stdout=` stream to | ||
| `stdio_server(...)` skips the descriptor changes for that stream, as does any | ||
| environment where the sys stream is not backed by the process's real descriptor. | ||
|
|
||
| ### WebSocket transport removed | ||
|
|
||
| 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. | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -39,31 +39,7 @@ python server.py | |||||
|
|
||||||
| Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first. | ||||||
|
|
||||||
| 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)**. | ||||||
|
|
||||||
| On Windows, the same rule applies to child processes your tools start. A child | ||||||
| that inherits the stdio server's stdin can block behind the server's protocol | ||||||
| reader. If your tool starts a subprocess and you do not intend to feed it input, | ||||||
| redirect the child's stdin: | ||||||
|
|
||||||
| ```python | ||||||
| import asyncio | ||||||
| import subprocess | ||||||
| import sys | ||||||
|
|
||||||
|
|
||||||
| async def run_script() -> tuple[bytes, bytes]: | ||||||
| process = await asyncio.create_subprocess_exec( | ||||||
| sys.executable, | ||||||
| "script.py", | ||||||
| stdin=subprocess.DEVNULL, | ||||||
| stdout=subprocess.PIPE, | ||||||
| stderr=subprocess.PIPE, | ||||||
| ) | ||||||
| return await process.communicate() | ||||||
| ``` | ||||||
|
|
||||||
| 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)**. | ||||||
| That also means stdout **is the wire**. While serving, the SDK moves the wire to a private descriptor and diverts stray output -- a `print()`, a subprocess writing to its inherited stdout -- to stderr, where it can't corrupt the stream. Output that reaches stdout *before* serving begins (a wrapper script echoing, an unbuffered import-time print) still lands on the wire. For output you actually want, the `logging` module is the right tool. That story is in **[Logging](../handlers/logging.md)**. | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Hosts that merge stderr into stdout can still receive diverted Prompt for AI agents
Suggested change
|
||||||
|
|
||||||
| ### Try it | ||||||
|
|
||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Windows processes launched with stderr merged into stdout can still send
print()output to the protocol pipe, so this guarantee is misleading. Qualify the claim with the merged-stream caveat (or direct readers to the migration caveats).Prompt for AI agents