Skip to content

Commit 49ced5b

Browse files
committed
fix: scope EOF drain to stdio dispatcher mode
1 parent 5458c58 commit 49ced5b

5 files changed

Lines changed: 72 additions & 19 deletions

File tree

src/mcp/server/runner.py

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
INVALID_PARAMS,
2929
METHOD_NOT_FOUND,
3030
PROTOCOL_VERSION_META_KEY,
31-
CacheableResult,
3231
ErrorData,
3332
Implementation,
3433
InitializeRequestParams,
@@ -41,7 +40,6 @@
4140
from pydantic import BaseModel, ValidationError
4241
from typing_extensions import TypeVar
4342

44-
from mcp.server.caching import apply_cache_hint
4543
from mcp.server.connection import Connection
4644
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
4745
from mcp.server.models import InitializationOptions
@@ -198,12 +196,6 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult:
198196
if isinstance(result, ErrorData):
199197
# Raise inside the chain so middleware observes the failure.
200198
raise MCPError.from_error_data(result)
201-
# Fill cache hints on the typed result, before the serialize sieve
202-
# decides whether the negotiated version carries the fields at all.
203-
# `input_required` interim results are not `CacheableResult` models,
204-
# so the MRTR carve-out (no hints on them) holds by shape.
205-
if isinstance(result, CacheableResult) and (hint := self.server.cache_hints.get(method)) is not None:
206-
result = apply_cache_hint(result, hint)
207199
# Dump and serialize inside the chain so the OpenTelemetry span (the
208200
# outermost middleware) records a failing handler return shape too.
209201
return self._serialize(method, version, result)
@@ -417,6 +409,7 @@ async def serve_loop(
417409
# next request (spec: SHOULD NOT, not MUST NOT) sees the initialized
418410
# state instead of failing the init-gate.
419411
inline_methods=frozenset({"initialize"}),
412+
drain_inbound_on_read_eof=getattr(read_stream, "drain_inbound_on_read_eof", False),
420413
)
421414
connection = Connection.for_loop(dispatcher, session_id=session_id)
422415
await serve_connection(

src/mcp/server/stdio.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
4444
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
4545

4646
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
47+
# Redirected stdin reaches EOF immediately after the final JSON-RPC line.
48+
# Stdio must keep stdout alive long enough for already-accepted request
49+
# responses to flush; other transports keep immediate EOF cancellation.
50+
read_stream.drain_inbound_on_read_eof = True
4751
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
4852

4953
async def stdin_reader():

src/mcp/shared/_context_streams.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ async def __aexit__(
6161
class ContextReceiveStream(Generic[T]):
6262
"""Receive-side wrapper that yields ``T`` and stores the sender's context in ``last_context``."""
6363

64-
__slots__ = ("_inner", "last_context")
64+
__slots__ = ("_inner", "last_context", "drain_inbound_on_read_eof")
6565

6666
def __init__(self, inner: MemoryObjectReceiveStream[_Envelope[T]]) -> None:
6767
self._inner = inner
6868
self.last_context: contextvars.Context | None = None
69+
self.drain_inbound_on_read_eof = False
6970

7071
async def receive(self) -> T:
7172
ctx, item = await self._inner.receive()

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ def __init__(
257257
raise_handler_exceptions: bool = False,
258258
inline_methods: frozenset[str] = frozenset(),
259259
on_stream_exception: Callable[[Exception], Awaitable[None]] | None = None,
260+
drain_inbound_on_read_eof: bool = False,
260261
) -> None:
261262
"""Wire a dispatcher over a transport's `SessionMessage` stream pair.
262263
@@ -271,6 +272,10 @@ def __init__(
271272
on_stream_exception: Observer for `Exception` items on the read
272273
stream; without it they are debug-logged and dropped. Awaited
273274
inline in the read loop, so a slow observer stalls dispatch.
275+
drain_inbound_on_read_eof: Let already-accepted inbound request
276+
response writes finish after read EOF before cancelling the run
277+
task group. Intended for stdio EOF after redirected input;
278+
default transport-close semantics remain immediate cancellation.
274279
"""
275280
self._read_stream = read_stream
276281
self._write_stream = write_stream
@@ -287,6 +292,8 @@ def __init__(
287292
"""Observer for ``Exception`` items on the read stream. Mutable so a session can
288293
bind it after the dispatcher is built (e.g. ``ClientSession`` routing into
289294
``message_handler``); only consulted inside ``run()`` so pre-enter assignment is safe."""
295+
self._drain_inbound_on_read_eof = drain_inbound_on_read_eof
296+
290297

291298
self._next_id = 0
292299
self._pending: dict[RequestId, _Pending] = {}
@@ -478,7 +485,8 @@ async def run(
478485
self._running = False
479486
self._closed = True
480487
self._fan_out_closed()
481-
await self._drain_active_inbound_requests()
488+
if self._drain_inbound_on_read_eof:
489+
await self._drain_active_inbound_requests()
482490
finally:
483491
# Cancel in-flight handlers; otherwise the task-group join
484492
# waits on handlers whose callers are already gone.
@@ -533,17 +541,24 @@ async def _dispatch_request(
533541
sender_ctx: contextvars.Context | None,
534542
) -> None:
535543
progress_token = progress_token_from_params(req.params)
544+
self._active_inbound_requests += 1
536545
try:
537546
transport_ctx = self._transport_builder(metadata)
538547
except Exception:
539548
# A raising builder must cost only this message, not the connection.
549+
# Track its spawned error response so stdio EOF drain waits for this
550+
# already-accepted request outcome too.
540551
logger.exception("transport_builder raised; rejecting request %r", req.id)
541-
self._spawn(
542-
self._write_error,
543-
req.id,
544-
ErrorData(code=INTERNAL_ERROR, message="transport context unavailable"),
545-
sender_ctx=sender_ctx,
546-
)
552+
553+
async def _reject_builder_failure() -> None:
554+
try:
555+
await self._write_error(
556+
req.id, ErrorData(code=INTERNAL_ERROR, message="transport context unavailable")
557+
)
558+
finally:
559+
self._active_inbound_requests = max(0, self._active_inbound_requests - 1)
560+
561+
self._spawn(_reject_builder_failure, sender_ctx=sender_ctx)
547562
return
548563
dctx = _JSONRPCDispatchContext(
549564
transport=transport_ctx,
@@ -553,7 +568,6 @@ async def _dispatch_request(
553568
_progress_token=progress_token,
554569
)
555570
scope = anyio.CancelScope()
556-
self._active_inbound_requests += 1
557571
# TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit
558572
# rejecting with INVALID_REQUEST. Key coerced so a stringified
559573
# `notifications/cancelled` id still correlates.

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ async def caller() -> None:
253253

254254

255255
@pytest.mark.anyio
256-
async def test_read_eof_drains_accepted_inbound_request_response():
256+
async def test_opt_in_read_eof_drains_accepted_inbound_request_response():
257257
"""Read EOF must not cancel a request that was already accepted.
258258
259259
This covers redirected-stdin stdio servers: EOF can arrive immediately after
@@ -262,7 +262,9 @@ async def test_read_eof_drains_accepted_inbound_request_response():
262262
"""
263263
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
264264
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage](32)
265-
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send)
265+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
266+
c2s_recv, s2c_send, drain_inbound_on_read_eof=True
267+
)
266268
handler_started = anyio.Event()
267269

268270
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -292,6 +294,45 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) ->
292294
s.close()
293295

294296

297+
@pytest.mark.anyio
298+
async def test_opt_in_read_eof_drains_transport_builder_rejection_response():
299+
"""The stdio EOF drain also covers spawned rejection writes before a handler exists."""
300+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
301+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage](32)
302+
303+
def reject(_metadata: MessageMetadata) -> TransportContext:
304+
raise RuntimeError("no context")
305+
306+
server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
307+
c2s_recv,
308+
s2c_send,
309+
transport_builder=reject,
310+
drain_inbound_on_read_eof=True,
311+
)
312+
313+
async def on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
314+
raise NotImplementedError
315+
316+
async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> None:
317+
pass
318+
319+
try:
320+
async with anyio.create_task_group() as tg:
321+
await tg.start(server.run, on_request, on_notify)
322+
await c2s_send.send(SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=1, method="slow")))
323+
c2s_send.close()
324+
325+
with anyio.fail_after(5):
326+
response = await s2c_recv.receive()
327+
assert isinstance(response.message, JSONRPCError)
328+
assert response.message.id == 1
329+
assert response.message.error.code == INTERNAL_ERROR
330+
tg.cancel_scope.cancel()
331+
finally:
332+
for stream in (c2s_send, c2s_recv, s2c_send, s2c_recv):
333+
stream.close()
334+
335+
295336
@pytest.mark.anyio
296337
async def test_run_returns_cleanly_when_read_stream_receive_end_is_closed():
297338
"""Iterating a closed receive end is EOF, not a crash (stateless SHTTP closes it during teardown)."""

0 commit comments

Comments
 (0)