Skip to content

Isolate the stdio server's stdin and stdout from handler subprocesses#3117

Open
maxisbey wants to merge 4 commits into
mainfrom
stdio-stdin-isolation
Open

Isolate the stdio server's stdin and stdout from handler subprocesses#3117
maxisbey wants to merge 4 commits into
mainfrom
stdio-stdin-isolation

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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 design refuses abnormal processes instead of repairing them. The claim engages only when the sys stream is backed by its real descriptor and fds 0-2 are all open (which makes it arithmetically impossible for the private duplicates to land in the standard range). Anything else (replaced streams, incomplete descriptor tables, a failed dup) is served in place exactly as v1 was. A second concurrent stdio_server() on the same real streams raises RuntimeError rather than contending for the wire.

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 MCP Tool execution hangs indefinitely in stdio mode when calling external Python scripts #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

@maxisbey
maxisbey marked this pull request as ready for review July 16, 2026 19:25
@maxisbey
maxisbey force-pushed the stdio-stdin-isolation branch from 118422b to 1eec799 Compare July 16, 2026 19:26
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3117.mcp-python-docs.pages.dev
Deployment https://1ccb2c64.mcp-python-docs.pages.dev
Commit ac17129
Triggered by @maxisbey
Updated 2026-07-20 17:43:25 UTC

@maxisbey
maxisbey force-pushed the stdio-stdin-isolation branch from 1eec799 to e1d6e64 Compare July 16, 2026 19:29

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mcp/server/stdio.py">

<violation number="1" location="src/mcp/server/stdio.py:91">
P2: Each completed default `stdio_server()` session leaks its duplicated protocol descriptor. `closefd=False` prevents wrapper cleanup from closing `private_fd`, and restoration duplicates it back onto fd 0 without closing the source. Repeated start/stop sessions can therefore exhaust the process descriptor limit; close the private duplicate after restoration (the task group has already finished the reader before this `finally` runs).</violation>
</file>

<file name="docs_src/troubleshooting/tutorial009.py">

<violation number="1" location="docs_src/troubleshooting/tutorial009.py:15">
P1: This example exposes arbitrary Python execution to every MCP client that can call `run_script`, because the client-controlled `path` is passed directly to the interpreter. The subprocess example can retain the stdin guidance without teaching an unrestricted code-runner; use a server-configured script path or validate against a narrowly scoped allowlist before spawning.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread docs_src/troubleshooting/tutorial009.py Outdated
"""Run a Python script and return what it printed."""
process = await asyncio.create_subprocess_exec(
sys.executable,
path,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This example exposes arbitrary Python execution to every MCP client that can call run_script, because the client-controlled path is passed directly to the interpreter. The subprocess example can retain the stdin guidance without teaching an unrestricted code-runner; use a server-configured script path or validate against a narrowly scoped allowlist before spawning.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs_src/troubleshooting/tutorial009.py, line 15:

<comment>This example exposes arbitrary Python execution to every MCP client that can call `run_script`, because the client-controlled `path` is passed directly to the interpreter. The subprocess example can retain the stdin guidance without teaching an unrestricted code-runner; use a server-configured script path or validate against a narrowly scoped allowlist before spawning.</comment>

<file context>
@@ -0,0 +1,23 @@
+    """Run a Python script and return what it printed."""
+    process = await asyncio.create_subprocess_exec(
+        sys.executable,
+        path,
+        stdin=subprocess.DEVNULL,
+        stdout=subprocess.PIPE,
</file context>

Comment thread docs/migration.md Outdated
Comment thread docs/run/index.md Outdated
Comment thread src/mcp/server/stdio.py Outdated
if private_fd is not None:
# A completed dup2 is undone; an untouched fd 0 is re-pointed at
# the same pipe it already holds, which is harmless.
_restore_fd(0, private_fd)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Each completed default stdio_server() session leaks its duplicated protocol descriptor. closefd=False prevents wrapper cleanup from closing private_fd, and restoration duplicates it back onto fd 0 without closing the source. Repeated start/stop sessions can therefore exhaust the process descriptor limit; close the private duplicate after restoration (the task group has already finished the reader before this finally runs).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/stdio.py, line 91:

<comment>Each completed default `stdio_server()` session leaks its duplicated protocol descriptor. `closefd=False` prevents wrapper cleanup from closing `private_fd`, and restoration duplicates it back onto fd 0 without closing the source. Repeated start/stop sessions can therefore exhaust the process descriptor limit; close the private duplicate after restoration (the task group has already finished the reader before this `finally` runs).</comment>

<file context>
@@ -17,61 +17,159 @@ async def run_server():
+        if private_fd is not None:
+            # A completed dup2 is undone; an untouched fd 0 is re-pointed at
+            # the same pipe it already holds, which is harmless.
+            _restore_fd(0, private_fd)
+            os.close(private_fd)
+        return sys.stdin.buffer, None
</file context>
Suggested change
_restore_fd(0, private_fd)
_restore_fd(0, private_fd)
with suppress(OSError):
os.close(private_fd)

@maxisbey
maxisbey force-pushed the stdio-stdin-isolation branch from e1d6e64 to bf24e5e Compare July 16, 2026 19:40
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.
@maxisbey
maxisbey force-pushed the stdio-stdin-isolation branch from bf24e5e to 42ef19a Compare July 16, 2026 19:44
Comment thread src/mcp/server/stdio.py Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the nested-transport finding already flagged inline, this pass also examined and ruled out two candidates. The retained private_fd after restore() (the descriptor cubic's suggestion proposes closing) is deliberate, not a leak: an abandoned reader thread can still be blocked in read() on that descriptor past the transport's lifetime — the closefd=False comment documents this — so closing it would free the fd number for reuse under a live read; the cost is one retained fd per session, and a process typically runs one. Also checked the migration-guide claim that explicit streams skip the descriptor changes: the claim path is governed solely by the stdin= parameter, so the sentence holds for the both-streams injected usage it describes (an explicit stdout= alone does not skip it — a wording nit at most).

Extended reasoning...

This run's bug hunt surfaced no new findings; the prior inline finding on the nested-transport path stands unaddressed and no commits have landed since. The note above records what was additionally examined and refuted this pass — chiefly the private-fd retention, which two automated reviewers reached opposite conclusions on: closing it after restore would reintroduce the fd-recycling hazard the code's closefd=False comment guards against, since anyio's blocking file reads are abandoned (not cancelled) in worker threads and can outlive the transport. Recording this so the author and any later review pass don't re-litigate it or apply the suggested close.

While serving on the process's real stdout, stdio_server now moves the
protocol pipe to a private descriptor and points fd 1 - and, on
Windows, the standard output handle - at stderr, restoring it when the
transport exits. A stray print() in handler code or a child process
writing to its inherited stdout lands in the client's log instead of
corrupting the JSON-RPC stream. The null device stands in when stderr
is unusable or, on POSIX, is detected as merged into stdout (2>&1).

The stdin claim generalizes into the shared _claim_fd mechanism: one
lock-guarded sentinel table covers both descriptors, private wire
duplicates are forced above the standard descriptor range so a process
started with a standard descriptor closed cannot hand the wire out as
its "stderr", and a failed claim degrades to serving the sys stream's
buffer in place exactly as v1 did.

Docs now describe the guarded behavior with its remaining gaps (output
flushed before serving begins, injected streams, merged stderr on
Windows), and the transport:stdio:stream-purity divergence narrows
accordingly.
@maxisbey maxisbey changed the title Isolate the stdio server's stdin from handler subprocesses Isolate the stdio server's stdin and stdout from handler subprocesses Jul 16, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 12 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="docs/handlers/logging.md">

<violation number="1" location="docs/handlers/logging.md:37">
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).</violation>
</file>

<file name="docs/migration.md">

<violation number="1" location="docs/migration.md:1862">
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.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/server/stdio.py Outdated
Comment thread docs/handlers/logging.md
Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
line and the client is trying to parse it as JSON-RPC.
Don't `print()` in a stdio server. `print` writes to **stdout**, and stdout belongs to the protocol:
while serving, the SDK diverts stray stdout to stderr so it can't corrupt the wire, but that leaves

Copy link
Copy Markdown

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
Check if this issue is valid — if so, understand the root cause and fix it. At docs/handlers/logging.md, line 37:

<comment>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).</comment>

<file context>
@@ -33,8 +33,9 @@ For a **stdio** server this question matters more than usual. The host launched
-    Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray
-    line and the client is trying to parse it as JSON-RPC.
+    Don't `print()` in a stdio server. `print` writes to **stdout**, and stdout belongs to the protocol:
+    while serving, the SDK diverts stray stdout to stderr so it can't corrupt the wire, but that leaves
+    your line interleaved raw among the log output -- no level, no logger name, no way to filter it.
 
</file context>

Comment thread docs/run/index.md Outdated
Comment thread docs/migration.md Outdated

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

Copy link
Copy Markdown

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 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
Check if this issue is valid — if so, understand the root cause and fix it. At docs/migration.md, line 1862:

<comment>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.</comment>

<file context>
@@ -1855,31 +1855,40 @@ group (spawned with `start_new_session=True`); the `getpgid()` lookup and the
+
+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
+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
</file context>

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, two candidates were examined and ruled out this run: (1) the per-session private-descriptor "leak" (also raised in an earlier bot review) — restore() deliberately leaves the private fd open because a worker thread can still sit blocked on it past the transport's lifetime, a one-fd-per-stream-per-session tradeoff documented in the closefd=False comment, not an accidental leak; (2) _claim_fd crashing on injected sys streams lacking a .buffer attribute — _is_backed_by_fd catches the AttributeError for the probe, and the subsequent stream.buffer access matches v1's unconditional sys.stdin.buffer access, so no regression.

Extended reasoning...

This run's finders raised the descriptor-leak and missing-.buffer candidates and verifiers refuted both; I confirmed the refutations against the diff (the leak is a deliberate, code-documented design choice tied to blocked worker-thread reads, and the .buffer access shape is identical to v1). Recording them here so the author and later review passes don't re-explore them from scratch — the descriptor-leak in particular was independently raised by another bot review on this PR. This is informational only, not a correctness guarantee; the two inline nits from this run stand on their own.

Comment thread src/mcp/server/stdio.py Outdated
Comment thread src/mcp/server/stdio.py Outdated
Comment on lines +147 to +160
# the same pipe it already holds, which is harmless.
_restore_fd(fd, private_fd)
os.close(private_fd)
# fd still holds the protocol pipe, so the sys stream's buffer is
# target-consistent: serve it in place, exactly as v1 did - shared
# write ordering, no new descriptors to allocate in a process whose
# descriptor table is already failing.
return stream.buffer, None

def restore() -> None:
# Flush first: text buffered in the sys stream during the claim (a
# stray print() while stdout is claimed) drains to the diversion, not
# to the restored protocol pipe.
with suppress(OSError, ValueError):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When _claim_fd's descriptor rearrangement fails with OSError, the fallback path discards fd from _claimed even though the transport goes on serving the real fd 0/fd 1 in place for its whole lifetime — so a nested or concurrent stdio_server() entered afterward finds the fd unclaimed, claims it, and moves the wire out from under the outer transport (its reader sees EOF and the client's frames are delivered to the inner transport instead). Keeping fd in _claimed on the fallback path, with an undo that only discards the sentinel, makes a nested transport take the serve-in-place branch as the design intends.

Extended reasoning...

What the bug is

The _claimed sentinel table exists — per the module comment at src/mcp/server/stdio.py:36-41 — so that "a second concurrent stdio_server() must not claim an fd again ... and its restore would clobber the first transport's." The best-effort fallback breaks that invariant: in _claim_fd's except OSError handler (src/mcp/server/stdio.py:147-160), _claimed.discard(fd) runs and stream.buffer — the real fd 0/fd 1, still holding the wire — is returned with restore=None. The transport then serves the standard descriptor in place for its whole lifetime with no sentinel protecting the descriptor it is actively serving. The _claimed check at lines 121-127 is the only guard in the module against a second transport re-claiming; once the sentinel is discarded, nothing prevents it.

Step-by-step proof (reproduced experimentally against the real _claim_fd on this branch)

  1. Transport A enters stdio_server(). Its claim of fd 0 fails transiently (e.g. an injected OSError on the first os.dup(0) — descriptor exhaustion that later clears, exactly the environment the fallback exists for). A takes the fallback: it serves sys.stdin.buffer (fd 0 = the wire) in place, and _claimed is empty.
  2. Handler or embedder code enters a nested/concurrent stdio_server() — the exact shape the sentinel is documented and tested to support (test_a_nested_stdio_server_does_not_clobber_the_first_transports_claim). The environment has recovered, so B's _claim_fd(0, ...) finds _is_backed_by_fd true and fd 0 unclaimed, and succeeds: the wire moves to B's private descriptor and fd 0 becomes the null device (verified via sameopenfile against a devnull probe).
  3. Consequences, all observed: (a) A's in-place reader on fd 0 immediately sees EOF — the outer stdin_reader ends and the outer session dies; (b) a JSON-RPC frame written to the wire for A's session was read by B's private descriptor while A got b'' — B literally steals A's inbound frames; (c) the stdout twin is symmetric: a fallback stdout transport writes fd 1 in place, and a nested claim of fd 1 diverts fd 1 to stderr, so the outer transport's responses land on stderr instead of the wire for the nested transport's lifetime.

Why existing safeguards miss it

The nested-transport tests only cover the claim-succeeded outer shape: the outer transport holds a sentinel, so the inner one serves in place. No test enters a nested transport after a fallback, so the hole is untested. And this is not quite the v1-parity the fallback comment claims: v1 never repointed fd 0 at devnull, so a v1 nested transport raced the outer one for bytes rather than deterministically killing the outer session, moving fd 0 to the null device, and diverting every one of its inbound frames.

Addressing the refutation

One verifier argued this is a non-actionable known limitation: the trigger is a compound rarity, fallback mode is documented v1-parity where nesting was never safe, and the fix would merely have the inner transport open(fd, ...) on the actual wire, racing the outer one — v1's brokenness in another form. The rarity point is fair and is why this is a nit, not a blocker. But the other two points don't hold up: (1) the current behavior is strictly worse than v1 — a race for bytes can still deliver some frames to the right session, whereas the re-claim deterministically EOFs the outer reader and diverts all of its traffic; and (2) the fix does restore the module's stated contract — with the sentinel kept, the inner transport takes the serve-in-place branch (lines 121-127), which is precisely the degraded-but-documented shape the design already prescribes for nesting, and the outer transport's serving arrangement survives, which is the whole invariant the comment at lines 36-41 promises. Nesting on a shared wire is inherently degraded either way; the question is whether the sentinel's documented guarantee holds in the fallback shape, and today it doesn't.

The fix

On the fallback path, keep fd in _claimed for the transport's lifetime, returning a restore callback that only discards the sentinel (no descriptor work — there is nothing to restore). A nested transport then finds the fd claimed and serves in place, exactly as the design intends. The change is a few lines and slots naturally next to the existing restore() closure.

Severity

Nit: the trigger requires a transient OSError during descriptor rearrangement and a nested/concurrent stdio_server() in the same process and a descriptor table that recovers in between — no realistic deployment hits this. But nested/threaded transports are an explicitly supported scenario per this PR's own sentinel design and lock comment, the hole silently kills and hijacks the outer session when it does hit, and the fix is cheap.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 52d9409 redesign changes the frame of this finding but doesn't close the hole. Nested/concurrent transports are now refused with RuntimeError instead of served in place, and the fallback block was reordered with the invariant "fd stays claimed for as long as it is diverted" — but the except OSError path still ends with _claimed.discard(fd) while the transport goes on serving the real fd 0/fd 1 in place for its whole lifetime.

The consequence is the same as originally reported, now expressed against the new contract: after a transient claim failure, a concurrent stdio_server() entered once the descriptor table recovers finds the fd unclaimed, passes _is_backed_by_fd and _std_descriptors_open, and claims successfully — the wire moves to its private descriptor, fd 0 becomes the null device, the outer in-place reader EOFs, and the outer session's frames are delivered to the inner transport. That is exactly the contention the new design promises to refuse ("a second concurrent stdio_server() raises RuntimeError instead of contending for the streams"), except in the fallback shape it silently succeeds instead of raising.

The fix is unchanged in spirit and now even simpler: on the fallback path, keep fd in _claimed for the transport's lifetime and return a restore callback that only discards the sentinel (no descriptor work — nothing was diverted). A second transport then gets the same RuntimeError refusal as in the normal shape. Still nit severity: the trigger remains a compound rarity (transient OSError during the claim plus a concurrent transport plus a recovered descriptor table).

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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline docstring nit, this run also examined and ruled out two candidate issues: (1) the apparent per-session "leak" of the private wire descriptors (also raised as a P2 in this thread) — closefd=False and the missing os.close(private_fd) on the normal exit path are deliberate per the comment at src/mcp/server/stdio.py:103: a worker thread may still be blocked reading that descriptor after exit, and closing it would let the fd number be recycled under the blocked read; the cost is one bounded fd per completed session, not growth within a serve. (2) The use of pywin32's win32file._get_osfhandle in rebind_std_handle_to_fd — it resolves the CRT fd to the OS handle as intended for repointing the Win32 std-handle slots (stdlib msvcrt.get_osfhandle would be an equivalent spelling, not a correctness difference).

Extended reasoning...

Bugs were found this run (a single documentation-convention nit posted inline), so no approval verdict is offered — the PR is in any case a complex descriptor-level change to the core stdio transport that warrants human review, which the maintainers are already giving it. This note only records what was additionally examined and refuted so later passes need not re-derive it: multiple independent finder agents flagged the un-closed private duplicates as a descriptor leak, and verification against the code showed it is a deliberate, commented, bounded tradeoff protecting a possibly-still-blocked reader thread from fd recycling; the pywin32 private-API concern was likewise verified to be functionally correct.

Comment thread src/mcp/server/stdio.py
Comment on lines 115 to 121
async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.AsyncFile[str] | None = None):
"""Server transport for stdio: this communicates with an MCP client by reading
from the current process' stdin and writing to stdout.
"""Serve MCP over the process's stdin and stdout.

While serving, fd 0 points at the null device and fd 1 at stderr — handlers
and children read EOF and stray output misses the wire — both restored on exit.
Explicit streams skip the claim; a second concurrent stdio_server() raises RuntimeError.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The public stdio_server() now raises RuntimeError when another stdio_server() in this process has already claimed fd 0 or 1, but the docstring only mentions this in prose without a Raises: section — the convention AGENTS.md requires for exceptions a caller would reasonably catch (and which the private _claim_fd helper in this same PR does carry). Moving the prose sentence into a Raises: RuntimeError: block would align the public entry point with the repo convention.

Extended reasoning...

What the issue is

This PR adds a new failure mode to the public stdio_server() API: when the transport goes to claim fd 0 or fd 1 and finds it already claimed by another stdio_server() in the same process, _claim_fd raises RuntimeError("another stdio_server() in this process has already claimed fd ...") (src/mcp/server/stdio.py:74-76), and that exception propagates out of stdio_server() to the caller. The docstring at src/mcp/server/stdio.py:115-121 documents this only in prose: "a second concurrent stdio_server() raises RuntimeError." There is no structured Raises: section.

Why this violates the repo convention

AGENTS.md (Code Quality) states: "Public APIs must have docstrings. When a public API raises exceptions a caller would reasonably catch, document them in a Raises: section." stdio_server is unambiguously public API — it is imported in src/mcp/__init__.py and listed in __all__ (line 141), which AGENTS.md defines as the deliberate public surface.

The "argument validation or programmer error" exemption does not apply here, both on the merits and by the repo's own practice. On the merits: the RuntimeError signals a runtime environmental condition (another transport in the process already owns the standard descriptors), not a malformed argument — an embedder coordinating multiple transports would plausibly catch it to surface a clean error or fall back. By repo practice: comparable misuse-shaped RuntimeErrors get structured Raises: sections throughout the codebase, e.g. ClientSession's "RuntimeError: Called before entering the context manager" (src/mcp/client/session.py) and MCPServer.session_manager's "RuntimeError: If called before streamable_http_app() has been called" (src/mcp/server/mcpserver/server.py).

The internal inconsistency

The strongest signal that this is an oversight rather than a choice: this same PR gives the private _claim_fd helper a proper Raises: section for this exact exception (src/mcp/server/stdio.py:67-68), while the public entry point — the function callers actually catch the error from — gets only prose. The author clearly knows and applies the convention; it just did not make it to the public docstring.

Step-by-step proof

  1. An embedder enters stdio_server() (default streams, real fd 0/1) — _claim_fd(0, ...) adds fd 0 to _claimed and the transport serves.
  2. While that transport is live, the same process enters a second stdio_server() (nested handler, embedding framework, test harness).
  3. The second call reaches _claim_fd(0, ...), finds 0 in _claimed, and raises RuntimeError("another stdio_server() in this process has already claimed fd 0") — exactly what test_a_second_stdio_server_on_the_same_process_streams_is_refused pins with pytest.raises(RuntimeError, match='already claimed fd 0').
  4. The exception propagates out of stdio_server().__aenter__ to the embedder, who consults the API docs for what to catch — and finds the contract only as a prose aside rather than in the structured Raises: block the rest of the SDK's public API uses (which doc tooling and IDEs also render specially).

How to fix

Move the prose sentence into a Raises: block, e.g.:

    Raises:
        RuntimeError: Another stdio_server() in this process has already
            claimed fd 0 or fd 1 (only when serving the default streams).

Impact and severity

Zero runtime impact — the behavior itself is deliberate, tested, and correct. This is purely a documentation-convention gap on the public API surface, so it should not block merge: nit.

No-Verification-Needed: docstring and docs prose only

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues from this pass, but this is descriptor-level surgery in the core stdio transport and my earlier inline comments still stand unaddressed, so it needs a human review.

What was reviewed this pass:

  • The reported per-session leak of the private wire descriptors (overlaps the open cubic P2): ruled out as a bug — leaving them open is a deliberate tradeoff (closefd=False, per the code comment) so a worker thread still blocked in a read after exit cannot hit a recycled fd; the suggested os.close(private_fd) after restore would reintroduce exactly that race.
  • The absence of a docs/migration.md entry: examined — the branch added an entry in f74f608 and deliberately removed it in 52d9409, matching the PR description's no-migration rationale (only previously-broken behaviors change). Whether that call is right is a maintainer judgment, not an oversight.
  • The docs' "can't corrupt the stream" claim vs a 2>&1 launch: examined — that launch shape keeps its v1 behavior and is named in the PR as a deliberate residual.
Extended reasoning...

Overview

The PR rewires stdio_server() to serve the protocol from private duplicates of fd 0/1 while diverting the standard descriptors (fd 0 to the null device, fd 1 to stderr), restoring both on exit, plus a Windows SetStdHandle rebind helper, extensive fd-level and end-to-end tests, and doc updates across four pages. It fixes the #671 Windows subprocess hang and the classic stray-print() wire corruption.

Security risks

No conventional security surface (no auth, crypto, or input parsing changes). The risk profile is correctness of process-global descriptor-table manipulation: a bug here can silently corrupt or steal the JSON-RPC wire for every stdio server built on this SDK. The claim/restore paths, the best-effort OSError fallback, and the Windows handle rebinding are all subtle and platform-sensitive.

Level of scrutiny

Maximum. This is the default transport for every local MCP server, the change deliberately alters process-global state (fd table, Win32 standard handle slots), and it introduces a new public failure mode (RuntimeError on concurrent transports). Prior review passes surfaced real design-level issues (a nested-transport wrapper-finalizer bug, a claim-window race) that drove the 52d9409 redesign; two of my findings from earlier today — the fallback path discarding the _claimed sentinel while still serving fd 0/1 in place, and the missing Raises: section on the public docstring — remain unaddressed with no commits since. That alone rules out approval.

Other factors

This run found no new bugs and refuted several candidates. Most usefully, the per-session descriptor 'leak' (raised by cubic as a P2 with a suggested os.close(private_fd) fix) is deliberate: the code comment explains closefd=False exists because a worker thread can still be blocked reading the private descriptor after exit, and closing it would let the fd number be recycled under that read — the suggested fix is unsafe. Realistic servers enter one stdio session per process, so the two retained descriptors are immaterial. The migration-entry omission is likewise deliberate (entry added in f74f608, removed in 52d9409, rationale in the PR description), though whether the changes count as 'breaking' under AGENTS.md is exactly the kind of judgment a maintainer should make. Test coverage is strong: fd-table contracts, failure injection, concurrency refusal, and real-subprocess regressions for both the Windows hang and the noisy-child case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP Tool execution hangs indefinitely in stdio mode when calling external Python scripts

1 participant