Skip to content

Commit 41b9faa

Browse files
committed
Reshape the stream claim into an explicit state machine
The claim registry maps each standard descriptor to at most one owning transport; the wire duplicate is allocated where it cannot land in the standard range (F_DUPFD_CLOEXEC above fd 2 on POSIX) and recorded on the claim before the descriptor is moved; release restores with a single dup2 and deregisters only on success. Every failure, modeled or not, lands on the safe side: the claim is retained and later transports are refused rather than handed a diverted descriptor. Deleted by the same design: the roll-forward path (its premise, a reliably reportable dup2 outcome, does not exist on Windows), and the restore-time flush (user flush() side effects can destroy the wire; unflushed stray output now drains after the session instead). The design was validated through three adversarial verification passes; the write-up with invariants and accepted residues is on the PR.
1 parent 8ed5083 commit 41b9faa

2 files changed

Lines changed: 109 additions & 123 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 75 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ async def run_server():
1616
import threading
1717
from collections.abc import Callable
1818
from contextlib import asynccontextmanager, suppress
19+
from dataclasses import dataclass
1920
from io import TextIOWrapper
2021
from typing import BinaryIO, Literal, TextIO
2122

@@ -27,9 +28,25 @@ async def run_server():
2728
from mcp.shared._context_streams import create_context_streams
2829
from mcp.shared.message import SessionMessage
2930

30-
# fds whose real stream a serving transport owns, whether diverted or served in place.
31-
_claimed: set[int] = set()
32-
_claimed_lock = threading.Lock()
31+
if sys.platform != "win32": # pragma: no branch
32+
import fcntl
33+
34+
# Stream-claim contract (design and attack log in PR #3117):
35+
# - _claims is the single authority for who owns fd 0/1; mutated only under the
36+
# lock, only by acquire's insert and release's deregister.
37+
# - private_fd is recorded the instant the wire duplicate exists, before fd is
38+
# ever moved, and is never closed while the claim is registered.
39+
# - Release deregisters only after dup2(private_fd, fd) restores the wire; a
40+
# failed release keeps the claim, so successors are refused, never fed a
41+
# diverted descriptor. Every failure lands on that safe side.
42+
_claims: dict[int, "_StreamClaim"] = {}
43+
_claims_lock = threading.Lock()
44+
45+
46+
@dataclass
47+
class _StreamClaim:
48+
fd: int
49+
private_fd: int | None = None
3350

3451

3552
def _is_backed_by_fd(stream: TextIO, fd: int) -> bool:
@@ -39,14 +56,15 @@ def _is_backed_by_fd(stream: TextIO, fd: int) -> bool:
3956
return False
4057

4158

42-
def _std_descriptors_open() -> bool:
43-
"""Whether fds 0-2 are all open, so a dup cannot land in the standard range."""
44-
try:
45-
for fd in (0, 1, 2):
46-
os.fstat(fd)
47-
except OSError:
48-
return False
49-
return True
59+
def _dup_above_std(fd: int) -> int:
60+
"""Duplicate fd onto a descriptor that cannot land in the standard range."""
61+
if sys.platform == "win32": # pragma: no cover
62+
duplicate = os.dup(fd)
63+
if duplicate <= 2:
64+
os.close(duplicate)
65+
raise OSError(f"duplicate of fd {fd} landed in the standard range")
66+
return duplicate
67+
return fcntl.fcntl(fd, fcntl.F_DUPFD_CLOEXEC, 3)
5068

5169

5270
def _open_stdin_diversion() -> int:
@@ -60,68 +78,69 @@ def _open_stdout_diversion() -> int:
6078
return os.open(os.devnull, os.O_WRONLY)
6179

6280

81+
def _restore_fd(fd: int, private_fd: int) -> bool:
82+
"""Point fd back at the wire; the Windows handle rebind never affects the outcome."""
83+
try:
84+
os.dup2(private_fd, fd)
85+
except OSError:
86+
return False
87+
if sys.platform == "win32": # pragma: no cover
88+
with suppress(OSError):
89+
rebind_std_handle_to_fd(fd)
90+
return True
91+
92+
6393
def _claim_fd(
6494
fd: int, stream: TextIO, mode: Literal["rb", "wb"], open_diversion: Callable[[], int]
6595
) -> tuple[BinaryIO, Callable[[], None] | None]:
66-
"""Move the protocol pipe to a private descriptor and divert fd while serving.
96+
"""Claim a standard stream: divert fd and serve the wire from a private duplicate.
97+
98+
Best-effort: when descriptors cannot be duplicated or diverted, serves the
99+
sys stream's buffer in place, exactly as v1 did, with the claim held.
67100
68101
Raises:
69102
RuntimeError: fd is already claimed by another transport in this process.
70103
"""
71104
if not _is_backed_by_fd(stream, fd):
72105
return stream.buffer, None
73-
with _claimed_lock:
74-
if fd in _claimed:
106+
claim = _StreamClaim(fd)
107+
with _claims_lock:
108+
if fd in _claims:
75109
raise RuntimeError(f"another stdio_server() in this process has already claimed fd {fd}")
76-
_claimed.add(fd)
110+
_claims[fd] = claim
77111

78-
def unclaim() -> None:
79-
with _claimed_lock:
80-
_claimed.discard(fd)
112+
def release() -> None:
113+
if claim.private_fd is None or _restore_fd(fd, claim.private_fd):
114+
with _claims_lock:
115+
del _claims[fd]
81116

82-
if not _std_descriptors_open():
83-
return stream.buffer, unclaim
84-
private_fd = None
85117
try:
86-
private_fd = os.dup(fd)
87-
diversion_fd = open_diversion()
88-
try:
89-
os.dup2(diversion_fd, fd)
90-
finally:
91-
os.close(diversion_fd)
92-
if sys.platform == "win32": # pragma: no cover
93-
rebind_std_handle_to_fd(fd)
118+
private_fd = _dup_above_std(fd)
94119
except OSError:
95-
# Roll back and serve in place; if the rollback itself fails, fall
96-
# through and roll forward instead: fd is stuck on the diversion, but
97-
# the private duplicate still holds the wire, so serve from it with
98-
# the claim held (the restore at exit gets another chance).
99-
if private_fd is None or _restore_fd(fd, private_fd):
100-
if private_fd is not None:
101-
os.close(private_fd)
102-
return stream.buffer, unclaim
103-
104-
def restore() -> None:
105-
# Drain text buffered during the claim (a stray print) to the diversion.
106-
with suppress(OSError, ValueError):
107-
stream.flush()
108-
# A failed restore leaves fd diverted; keep it claimed so later transports are refused.
109-
if _restore_fd(fd, private_fd):
110-
unclaim()
111-
112-
# closefd=False: a blocked worker thread may still read this descriptor after exit.
113-
return os.fdopen(private_fd, mode, closefd=False), restore
120+
return stream.buffer, release
121+
claim.private_fd = private_fd
114122

115-
116-
def _restore_fd(fd: int, private_fd: int) -> bool:
117-
"""Point fd back at the protocol pipe; a failure never masks the transport's exit."""
118123
try:
119-
os.dup2(private_fd, fd)
120-
if sys.platform == "win32": # pragma: no cover
121-
rebind_std_handle_to_fd(fd)
124+
diversion_fd = open_diversion()
122125
except OSError:
123-
return False
124-
return True
126+
return stream.buffer, release
127+
try:
128+
os.dup2(diversion_fd, fd)
129+
except OSError:
130+
# The divert did not land; fd still carries the wire, so serve it in
131+
# place through the shared buffer (two writers on one pipe tear frames).
132+
with suppress(OSError):
133+
os.close(diversion_fd)
134+
return stream.buffer, release
135+
with suppress(OSError):
136+
os.close(diversion_fd)
137+
if sys.platform == "win32": # pragma: no cover
138+
with suppress(OSError):
139+
rebind_std_handle_to_fd(fd)
140+
141+
# closefd=False: a worker thread can still block on this descriptor after
142+
# the transport exits, so it must never be closed and recycled under it.
143+
return os.fdopen(private_fd, mode, closefd=False), release
125144

126145

127146
@asynccontextmanager

tests/server/test_stdio.py

Lines changed: 34 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -216,30 +216,26 @@ async def test_stdio_server_takes_stdin_off_the_descriptor_table_while_serving(
216216
async def test_stdio_server_reads_stdin_in_place_when_descriptor_isolation_fails(
217217
failing_call: str, monkeypatch: pytest.MonkeyPatch
218218
) -> None:
219-
"""A descriptor-table failure while claiming stdin degrades to reading sys.stdin in place.
219+
"""A descriptor failure while claiming stdin degrades to reading sys.stdin in place.
220220
221-
SDK-defined behavior: isolation is best-effort; the dup2 variant fails after the private duplicate exists.
221+
SDK-defined behavior: isolation is best-effort; when duplicating fd 0 or diverting
222+
it fails, the transport serves over the original stdin exactly as v1 did.
222223
"""
223224
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
224225
with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
225226
os.write(in_w, _frame(request))
226227
os.close(in_w)
227228
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
228229

229-
# Injectors fire once, then pass through: pytest capture also calls os.dup/os.dup2,
230-
# and a still-armed injector detonating there corrupts every later test's capture.
231230
if failing_call == "dup":
232-
real_dup = os.dup
233-
armed = [True]
234231

235-
def failing_dup(fd: int) -> int:
236-
if fd == 0 and armed[0]:
237-
armed[0] = False
238-
raise OSError("injected descriptor failure")
239-
return real_dup(fd)
232+
def failing_dup_above_std(fd: int) -> int:
233+
raise OSError("injected descriptor failure")
240234

241-
monkeypatch.setattr(os, "dup", failing_dup)
235+
monkeypatch.setattr("mcp.server.stdio._dup_above_std", failing_dup_above_std)
242236
else:
237+
# Fires once at the divert, then passes through: pytest's capture
238+
# machinery also calls os.dup2 at phase transitions.
243239
real_dup2 = os.dup2
244240
armed = [True]
245241

@@ -256,12 +252,6 @@ def failing_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
256252
async with read_stream: # pragma: no branch
257253
# Isolation was skipped: fd 0 is still the protocol pipe.
258254
assert os.path.sameopenfile(0, in_r)
259-
# In-place transports still own the stream: a second one is refused.
260-
with pytest.raises(RuntimeError, match="already claimed fd 0"):
261-
async with stdio_server():
262-
pytest.fail("unreachable") # pragma: no cover
263-
# The spent injector passes calls through untouched.
264-
os.close(os.dup(0))
265255
received = await read_stream.receive()
266256
assert isinstance(received, SessionMessage)
267257
assert received.message == request
@@ -278,8 +268,7 @@ async def test_stdio_server_exits_cleanly_when_the_stdin_restore_fails(
278268
still-diverted fd must refuse later transports rather than serve them the diversion.
279269
"""
280270
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
281-
fresh_claims: set[int] = set()
282-
monkeypatch.setattr("mcp.server.stdio._claimed", fresh_claims) # this test leaves fd 0 claimed
271+
monkeypatch.setattr("mcp.server.stdio._claims", {}) # this test leaves fd 0 claimed
283272
with _pipe_planted_on_fd0(monkeypatch) as (_, in_w):
284273
os.write(in_w, _frame(request))
285274
os.close(in_w)
@@ -374,6 +363,8 @@ def failing_dup(fd: int) -> int:
374363
with anyio.fail_after(5):
375364
async with stdio_server(stdin=anyio.AsyncFile(io.StringIO())) as (read_stream, write_stream):
376365
read_stream.close()
366+
# The spent injector passes later duplications through untouched.
367+
os.close(os.dup(0))
377368
devnull_probe = os.open(os.devnull, os.O_WRONLY)
378369
try:
379370
assert os.path.sameopenfile(1, devnull_probe)
@@ -443,12 +434,14 @@ async def test_a_refused_claim_releases_the_stream_it_already_took(
443434

444435

445436
@pytest.mark.anyio
446-
async def test_stdio_server_serves_in_place_when_the_standard_descriptors_are_incomplete(
437+
async def test_the_claim_engages_even_when_stderr_is_closed(
447438
monkeypatch: pytest.MonkeyPatch,
448439
) -> None:
449-
"""A process missing a standard descriptor is served in place, without surgery.
440+
"""A process missing fd 2 still gets full isolation.
450441
451-
With fd 2 closed, a duplicate could land in the standard range: the transport must not touch the table.
442+
SDK-defined behavior: the wire duplicate is allocated above the standard range
443+
atomically, so a hole in the descriptor table cannot capture it; the stdout
444+
diversion falls back to the null device.
452445
"""
453446
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
454447
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
@@ -459,8 +452,12 @@ async def test_stdio_server_serves_in_place_when_the_standard_descriptors_are_in
459452
with anyio.fail_after(5):
460453
async with stdio_server() as (read_stream, write_stream):
461454
async with read_stream: # pragma: no branch
462-
assert os.path.sameopenfile(0, in_r)
463-
assert os.path.sameopenfile(1, out_w)
455+
# Claimed: fd 0 reads the null device, not the pipe.
456+
devnull_probe = os.open(os.devnull, os.O_RDONLY)
457+
try:
458+
assert os.path.sameopenfile(0, devnull_probe)
459+
finally:
460+
os.close(devnull_probe)
464461

465462
os.write(in_w, _frame(request))
466463
received = await read_stream.receive()
@@ -471,69 +468,39 @@ async def test_stdio_server_serves_in_place_when_the_standard_descriptors_are_in
471468
assert jsonrpc_message_adapter.validate_json(line.decode().strip()) == response
472469
os.close(in_w)
473470
await write_stream.aclose()
471+
472+
assert os.path.sameopenfile(0, in_r)
473+
assert os.path.sameopenfile(1, out_w)
474474
finally:
475475
os.dup2(saved2, 2)
476476
os.close(saved2)
477477

478478

479479
@pytest.mark.anyio
480-
async def test_a_claim_that_cannot_roll_back_serves_the_wire_from_the_private_duplicate(
480+
async def test_stdio_server_serves_in_place_when_the_diversion_cannot_be_opened(
481481
monkeypatch: pytest.MonkeyPatch,
482482
) -> None:
483-
"""A mid-claim failure whose rollback also fails rolls forward instead of unclaiming.
484-
485-
SDK-defined behavior: with fd 0 stuck on the diversion, the transport keeps the
486-
claim and serves the protocol from the private duplicate; the restore at exit
487-
still runs and puts fd 0 back on the pipe.
488-
"""
483+
"""A diversion that cannot be opened leaves fd 0 untouched and serves in place."""
489484
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
490485
with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
486+
os.write(in_w, _frame(request))
487+
os.close(in_w)
491488
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
492489

493-
# The first close (the diversion fd) and the second dup2 (the rollback)
494-
# fail: the claim can neither complete nor be undone. One-shot and
495-
# call-counted injectors, as elsewhere in this file.
496-
real_close = os.close
497-
close_armed = [True]
490+
def failing_diversion() -> int:
491+
raise OSError("injected diversion failure")
498492

499-
def failing_first_close(fd: int) -> None:
500-
if close_armed[0]:
501-
close_armed[0] = False
502-
raise OSError("injected close failure")
503-
real_close(fd)
504-
505-
real_dup2 = os.dup2
506-
dup2_calls: list[int] = []
507-
508-
def flaky_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
509-
dup2_calls.append(fd)
510-
if len(dup2_calls) == 2:
511-
raise OSError("injected rollback failure")
512-
return real_dup2(fd, fd2, inheritable)
513-
514-
monkeypatch.setattr(os, "close", failing_first_close)
515-
monkeypatch.setattr(os, "dup2", flaky_dup2)
493+
monkeypatch.setattr("mcp.server.stdio._open_stdin_diversion", failing_diversion)
516494

517495
with anyio.fail_after(5):
518-
async with stdio_server() as (read_stream, write_stream):
496+
async with stdio_server() as (read_stream, write_stream): # pragma: no branch
519497
async with read_stream: # pragma: no branch
520-
# fd 0 is stuck on the null device, yet the wire still flows.
521-
devnull_probe = os.open(os.devnull, os.O_RDONLY)
522-
try:
523-
assert os.path.sameopenfile(0, devnull_probe)
524-
finally:
525-
os.close(devnull_probe)
526-
527-
os.write(in_w, _frame(request))
498+
assert os.path.sameopenfile(0, in_r)
528499
received = await read_stream.receive()
529500
assert isinstance(received, SessionMessage)
530501
assert received.message == request
531-
os.close(in_w)
532502
await write_stream.aclose()
533503

534-
# The exit restore (third dup2) succeeded: fd 0 is back on the pipe.
535-
assert os.path.sameopenfile(0, in_r)
536-
537504

538505
class _GatedStdin(io.RawIOBase):
539506
"""Raw stdin double: serves its frames, then blocks until released before EOF.

0 commit comments

Comments
 (0)