Skip to content

Commit 664b540

Browse files
committed
Hold the stream claim for in-place fallbacks too
A transport that falls back to serving the real stdin or stdout in place (incomplete descriptor table, or a claim that failed with OSError) now keeps its fd in the claimed set for its lifetime, so a second concurrent stdio_server() is refused in every shape rather than only after a successful diversion. The sentinel now means a transport owns the stream, not that the fd is currently diverted.
1 parent ac17129 commit 664b540

2 files changed

Lines changed: 15 additions & 7 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ async def run_server():
2727
from mcp.shared._context_streams import create_context_streams
2828
from mcp.shared.message import SessionMessage
2929

30+
# fds whose real stream a serving transport owns, whether diverted or served in place.
3031
_claimed: set[int] = set()
3132
_claimed_lock = threading.Lock()
3233

@@ -67,12 +68,19 @@ def _claim_fd(
6768
Raises:
6869
RuntimeError: fd is already claimed by another transport in this process.
6970
"""
70-
if not _is_backed_by_fd(stream, fd) or not _std_descriptors_open():
71+
if not _is_backed_by_fd(stream, fd):
7172
return stream.buffer, None
7273
with _claimed_lock:
7374
if fd in _claimed:
7475
raise RuntimeError(f"another stdio_server() in this process has already claimed fd {fd}")
7576
_claimed.add(fd)
77+
78+
def unclaim() -> None:
79+
with _claimed_lock:
80+
_claimed.discard(fd)
81+
82+
if not _std_descriptors_open():
83+
return stream.buffer, unclaim
7684
private_fd = None
7785
try:
7886
private_fd = os.dup(fd)
@@ -84,21 +92,17 @@ def _claim_fd(
8492
if sys.platform == "win32": # pragma: no cover
8593
rebind_std_handle_to_fd(fd)
8694
except OSError:
87-
# Undo before unclaiming: fd stays claimed for as long as it is diverted.
8895
if private_fd is not None:
8996
_restore_fd(fd, private_fd)
9097
os.close(private_fd)
91-
with _claimed_lock:
92-
_claimed.discard(fd)
93-
return stream.buffer, None
98+
return stream.buffer, unclaim
9499

95100
def restore() -> None:
96101
# Drain text buffered during the claim (a stray print) to the diversion.
97102
with suppress(OSError, ValueError):
98103
stream.flush()
99104
_restore_fd(fd, private_fd)
100-
with _claimed_lock:
101-
_claimed.discard(fd)
105+
unclaim()
102106

103107
# closefd=False: a blocked worker thread may still read this descriptor after exit.
104108
return os.fdopen(private_fd, mode, closefd=False), restore

tests/server/test_stdio.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,10 @@ def failing_dup2(fd: int, fd2: int, inheritable: bool = True) -> int:
256256
async with read_stream: # pragma: no branch
257257
# Isolation was skipped: fd 0 is still the protocol pipe.
258258
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
259263
# The spent injector passes calls through untouched.
260264
os.close(os.dup(0))
261265
received = await read_stream.receive()

0 commit comments

Comments
 (0)