Skip to content

Commit a45283b

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 8a076d0 commit a45283b

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
@@ -217,30 +217,26 @@ async def test_stdio_server_takes_stdin_off_the_descriptor_table_while_serving(
217217
async def test_stdio_server_reads_stdin_in_place_when_descriptor_isolation_fails(
218218
failing_call: str, monkeypatch: pytest.MonkeyPatch
219219
) -> None:
220-
"""A descriptor-table failure while claiming stdin degrades to reading sys.stdin in place.
220+
"""A descriptor failure while claiming stdin degrades to reading sys.stdin in place.
221221
222-
SDK-defined behavior: isolation is best-effort; the dup2 variant fails after the private duplicate exists.
222+
SDK-defined behavior: isolation is best-effort; when duplicating fd 0 or diverting
223+
it fails, the transport serves over the original stdin exactly as v1 did.
223224
"""
224225
request = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
225226
with _pipe_planted_on_fd0(monkeypatch) as (in_r, in_w):
226227
os.write(in_w, _frame(request))
227228
os.close(in_w)
228229
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
229230

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

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

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

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

445436

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

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

479479

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

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

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

518496
with anyio.fail_after(5):
519-
async with stdio_server() as (read_stream, write_stream):
497+
async with stdio_server() as (read_stream, write_stream): # pragma: no branch
520498
async with read_stream: # pragma: no branch
521-
# fd 0 is stuck on the null device, yet the wire still flows.
522-
devnull_probe = os.open(os.devnull, os.O_RDONLY)
523-
try:
524-
assert os.path.sameopenfile(0, devnull_probe)
525-
finally:
526-
os.close(devnull_probe)
527-
528-
os.write(in_w, _frame(request))
499+
assert os.path.sameopenfile(0, in_r)
529500
received = await read_stream.receive()
530501
assert isinstance(received, SessionMessage)
531502
assert received.message == request
532-
os.close(in_w)
533503
await write_stream.aclose()
534504

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

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

0 commit comments

Comments
 (0)