Skip to content

Commit a97b9e5

Browse files
author
Jianke LIN
committed
fix(server): opt-in drain on read EOF
1 parent bdf081c commit a97b9e5

5 files changed

Lines changed: 38 additions & 12 deletions

File tree

src/mcp/server/lowlevel/server.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -686,6 +686,15 @@ async def run(
686686
# but also make tracing exceptions much easier during testing and when using
687687
# in-process servers.
688688
raise_exceptions: bool = False,
689+
# When True, the server is stateless and
690+
# clients can perform initialization with any node. The client must still follow
691+
# the initialization lifecycle, but can do so with any available node
692+
# rather than requiring initialization for each connection.
693+
stateless: bool = False,
694+
# When True, treat read EOF as a half-close and allow in-flight handlers
695+
# to drain their responses via the still-open write stream (e.g. stdio
696+
# with bash-redirected stdin).
697+
drain_on_read_close: bool = False,
689698
) -> None:
690699
"""Serve a single connection over the given streams until the read side closes.
691700
@@ -696,15 +705,20 @@ async def run(
696705
streamable-HTTP manager) call `serve_loop` directly instead.
697706
"""
698707
async with self.lifespan(self) as lifespan_context:
699-
await serve_dual_era_loop(
700-
self,
701-
read_stream,
702-
write_stream,
703-
lifespan_state=lifespan_context,
704-
init_options=initialization_options,
705-
raise_exceptions=raise_exceptions,
706-
close_write_stream_on_read_close=False,
707-
)
708+
try:
709+
await serve_dual_era_loop(
710+
self,
711+
read_stream,
712+
write_stream,
713+
lifespan_state=lifespan_context,
714+
init_options=initialization_options,
715+
raise_exceptions=raise_exceptions,
716+
session_id=None,
717+
close_write_stream_on_read_close=not drain_on_read_close,
718+
)
719+
finally:
720+
if drain_on_read_close:
721+
await write_stream.aclose()
708722

709723
def streamable_http_app(
710724
self,

src/mcp/server/mcpserver/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,6 +1010,7 @@ async def run_stdio_async(self) -> None:
10101010
read_stream,
10111011
write_stream,
10121012
self._lowlevel_server.create_initialization_options(),
1013+
drain_on_read_close=True,
10131014
)
10141015

10151016
async def run_sse_async( # pragma: no cover

src/mcp/server/runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ async def serve_dual_era_loop(
547547
session_id: str | None = None,
548548
init_options: InitializationOptions | None = None,
549549
raise_exceptions: bool = False,
550+
close_write_stream_on_read_close: bool = True,
550551
) -> None:
551552
"""Drive `server` over a duplex stream pair, serving both protocol eras.
552553
@@ -595,6 +596,7 @@ async def serve_dual_era_loop(
595596
# `server/discover` inline so the modern era lock commits before the
596597
# next pipelined message is read.
597598
inline_methods=frozenset({"initialize", "server/discover"}),
599+
close_write_stream_on_read_close=close_write_stream_on_read_close,
598600
)
599601
loop_connection = Connection.for_loop(dispatcher, session_id=session_id)
600602
loop_runner = ServerRunner(server, loop_connection, lifespan_state, init_options=init_options)

tests/server/test_cancel_handling.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar
120120
server_write, from_server = anyio.create_memory_object_stream[SessionMessage](10)
121121

122122
async def run_server():
123-
await server.run(server_read, server_write, server.create_initialization_options())
123+
await server.run(server_read, server_write, server.create_initialization_options(), drain_on_read_close=True)
124124
server_run_returned.set()
125125

126126
init_req = JSONRPCRequest(

tests/server/test_stdio.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,7 @@ async def test_stdio_server_drains_in_flight_responses_on_stdin_eof():
300300
allow_tools_to_finish = anyio.Event()
301301

302302
async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
303-
return ListToolsResult(tools=[Tool(name="slow", description="test", input_schema={})])
303+
return ListToolsResult(tools=[Tool(name="slow", description="test", input_schema={"type": "object"})])
304304

305305
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
306306
nonlocal tool_started_count
@@ -347,7 +347,16 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar
347347
):
348348
with anyio.fail_after(5):
349349
async with anyio.create_task_group() as tg: # pragma: no branch
350-
tg.start_soon(server.run, read_stream, write_stream, server.create_initialization_options())
350+
351+
async def run_server() -> None:
352+
await server.run(
353+
read_stream,
354+
write_stream,
355+
server.create_initialization_options(),
356+
drain_on_read_close=True,
357+
)
358+
359+
tg.start_soon(run_server)
351360
await both_tools_started.wait()
352361
allow_tools_to_finish.set()
353362

0 commit comments

Comments
 (0)