Title: Isolate the stdio server's stdin and stdout from handler subprocesses by maxisbey · Pull Request #3117 · modelcontextprotocol/python-sdk · GitHub
Open Graph Title: Isolate the stdio server's stdin and stdout from handler subprocesses by maxisbey · Pull Request #3117 · modelcontextprotocol/python-sdk
X Title: Isolate the stdio server's stdin and stdout from handler subprocesses by maxisbey · Pull Request #3117 · modelcontextprotocol/python-sdk
Description: stdio_server() now serves the protocol from private duplicates of stdin and stdout and repoints the standard descriptors away from the wire while it runs, restoring both on exit: fd 0 (and the Windows standard input handle) reads the null device, and fd 1 (and the standard output handle) writes to stderr. Subprocesses spawned by tool code therefore inherit the diversions instead of the protocol pipes, and a stray print() lands in the client's log instead of corrupting the stream. Fixes #671. Motivation and Context stdin. The #671 hang is CPython gh-78961: Windows serializes operations on a synchronous pipe, a stdio server always has a blocking read pending on stdin, and a Python child that inherits that pipe freezes inside interpreter startup, before its first line of user code, until the pending read completes. That happens exactly when the next JSON-RPC message arrives, which is why hung tool calls "complete" once the client times out or sends another request. Skipping redirection entirely doesn't avoid it: Windows hands a console child the parent's standard handles even with bInheritHandles=FALSE, so a plain subprocess.run([...]) hangs too. With fd 0 on the null device while serving, there is no shared pipe to block on. It also fixes the cross-platform variant: a child that reads its inherited stdin was consuming protocol bytes on any platform. stdout. The same inheritance corrupts the other direction: a child writing to its inherited stdout (or a stray print() in handler code) writes straight into the JSON-RPC stream (the classic client-side symptom is Unexpected token ... is not valid JSON, e.g. #409). With fd 1 diverted to stderr while serving, that output shows up in the client's logs instead; every stdio client surveyed (this SDK, TypeScript, C#, Inspector, Claude Desktop, VS Code, Zed, ...) captures or passes through server stderr. This closes the documented transport:stdio:stream-purity divergence, and matches where go-sdk is heading for its v2 (modelcontextprotocol/go-sdk#572). Why the descriptor level. Children inherit descriptors, not Python objects, so no sys.stdout-level redirection can protect the wire from subprocesses. Node fixed its version of this inside libuv (uv_disable_stdio_inheritance()); Go's runtime hands children the null device for unset streams; Python gives a library neither, so the transport rearranges the table itself, using the same dup/dup2/restore mechanism as pytest's capfd. The claim is a small state machine with one safe failure direction. A registry maps each standard descriptor to at most one owning transport; a second concurrent stdio_server() raises RuntimeError rather than contending for the wire. The wire duplicate is allocated where it cannot land in the standard range (F_DUPFD_CLOEXEC above fd 2 on POSIX) and recorded before the descriptor is ever moved; release restores with a single dup2 and deregisters only on success. Every failure, modeled or not, therefore lands on the safe side: the claim is retained and later transports are refused, never handed a diverted descriptor. Failures to duplicate or divert degrade to serving the streams in place exactly as v1. The design was hardened through three adversarial verification rounds (about fifty attack scenarios, several confirmed and fixed, the rest refuted); the full state machine, invariants, and accepted residues are in the design write-up comment on this PR. Who is affected. A survey of real public MCP servers found no code that works today and would break: of 16 servers whose tools spawn children, 11 are exposed to the hang/theft today (none redirect stdin) and are fixed by this change, 5 fully redirect and are unaffected, 0 break. print() in stdio serving code is widespread and corrupts the wire today (e.g. PrefectHQ/fastmcp#3278, #1010); it now lands in the client's log. Real input()-in-a-tool under stdio does not exist in the wild (it never worked; it now fails fast with EOF instead of hanging). Accordingly there is no migration entry: no user has anything to do. The workaround documentation #3079 added is removed: v2 no longer exhibits the hang (stdin=subprocess.DEVNULL still works fine where users already pass it). How Has This Been Tested? Reproduced #671 on windows-latest runners against current main with an instrumented harness: the child's first line executes only when the next protocol message lands, on Python 3.10 through 3.14. With this change, the same unmodified repro returns in ~0.5 s. fd-level tests pin the descriptor-table contract: the wire moves to private descriptors, fd 0 reads EOF, fd 1 diverts to stderr (asserted by reading a planted stderr pipe), frames stay pure on the planted wire pipes, both descriptors restore on exit; plus the in-place path for incomplete descriptor tables, failure injection for the best-effort fallback and restore paths, and the concurrent-claim refusal. End-to-end regression tests spawn a real server whose tool spawns a real child: the Windows stdin-hang shapes (piped and bare) and a cross-platform noisy child whose junk line must arrive in the server's stderr, never the wire. Each verified to fail on unpatched main (POSIX and Windows runners) and pass with this change. Live interop against other SDKs: a TypeScript SDK (1.29.0) client driving this server through hostile traffic (noisy child, direct print(), post-pollution calls: all clean, all noise in the host-visible stderr), a Go SDK client doing the same, and this SDK's client against a TypeScript server. Shell-level probes: piped python server.py sessions with a pre-run banner print (drains to stderr, wire clean), post-run prints (stdout restored), python -u, and launches with stderr closed or merged. Full suite + 100% coverage gate, pyright, ruff, and the strict docs build pass on ubuntu and windows runners. Breaking Changes None requiring action. During a stdio session, handler code that read sys.stdin now sees EOF instead of racing the transport for protocol bytes, and print()/sys.stdout writes reach stderr instead of the wire; both were broken behaviors before, not working ones. Injected-stream usage (stdio_server(stdin=..., stdout=...)) and environments where the sys streams aren't the real descriptors are served exactly as before. Types of changes Bug fix (non-breaking change which fixes an issue) New feature (non-breaking change which adds functionality) Breaking change (fix or feature that would cause existing functionality to change) Documentation update Checklist I have read the MCP Documentation My code follows the repository's style guidelines New and existing tests pass locally I have added appropriate error handling I have added or updated documentation as needed Additional context Known residuals, deliberately at v1 parity: output flushed to stdout before the transport enters still precedes the first frame, and a process launched with stderr merged into stdout (2>&1) keeps its already-broken merged behavior. No surveyed host launches servers that way. AI disclosure AI assistance was used to investigate, implement, and validate this change; I reviewed the result and take responsibility for it. AI Disclaimer
Open Graph Description: stdio_server() now serves the protocol from private duplicates of stdin and stdout and repoints the standard descriptors away from the wire while it runs, restoring both on exit: fd 0 (and the Wind...
X Description: stdio_server() now serves the protocol from private duplicates of stdin and stdout and repoints the standard descriptors away from the wire while it runs, restoring both on exit: fd 0 (and the Wind...
Opengraph URL: https://github.com/modelcontextprotocol/python-sdk/pull/3117
X: @github
Domain: github.com
| route-pattern | /:user_id/:repository/pull/:id/files(.:format) |
| route-controller | pull_requests |
| route-action | files |
| fetch-nonce | v2:103142fa-b48c-714d-d12f-779d47a5d011 |
| current-catalog-service-hash | ae870bc5e265a340912cde392f23dad3671a0a881730ffdadd82f2f57d81641b |
| request-id | ABC6:2017E4:2BDA635:3D25B05:6A633D99 |
| html-safe-nonce | b7f526fe7bef599846671c806870b20882cebbdce20aae7147c20d3bd1828b16 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJBQkM2OjIwMTdFNDoyQkRBNjM1OjNEMjVCMDU6NkE2MzNEOTkiLCJ2aXNpdG9yX2lkIjoiNDA3ODg5MTE3MzkxMDEwMTQwMSIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9 |
| visitor-hmac | 70606e7d78c8cb7badff1cd64c7c6ada4f1ed7d66c54754f85fffd4558e5b385 |
| hovercard-subject-tag | pull_request:4071400397 |
| github-keyboard-shortcuts | repository,pull-request-list,pull-request-conversation,pull-request-files-changed,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/modelcontextprotocol/python-sdk/pull/3117/files |
| twitter:image | https://avatars.githubusercontent.com/u/224885523?s=400&v=4 |
| twitter:card | summary_large_image |
| og:image | https://avatars.githubusercontent.com/u/224885523?s=400&v=4 |
| og:image:alt | stdio_server() now serves the protocol from private duplicates of stdin and stdout and repoints the standard descriptors away from the wire while it runs, restoring both on exit: fd 0 (and the Wind... |
| og:site_name | GitHub |
| og:type | object |
| hostname | github.com |
| expected-hostname | github.com |
| None | 59e55daad7174ca59d63c6974d58276ccb5477442e550bebb3c035e1bef11c94 |
| turbo-cache-control | no-preview |
| diff-view | unified |
| go-import | github.com/modelcontextprotocol/python-sdk git https://github.com/modelcontextprotocol/python-sdk.git |
| octolytics-dimension-user_id | 182288589 |
| octolytics-dimension-user_login | modelcontextprotocol |
| octolytics-dimension-repository_id | 862584018 |
| octolytics-dimension-repository_nwo | modelcontextprotocol/python-sdk |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 862584018 |
| octolytics-dimension-repository_network_root_nwo | modelcontextprotocol/python-sdk |
| turbo-body-classes | logged-out env-production page-responsive full-width |
| disable-turbo | true |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | 990295d92a4cc7b63fbbd83a046217cd7d77d49c |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width