Skip to content

Commit 8d62b84

Browse files
author
Jianke LIN
committed
fix(server): opt-in drain on read EOF
1 parent 95d8a99 commit 8d62b84

4 files changed

Lines changed: 36 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
@@ -641,6 +641,15 @@ async def run(
641641
# but also make tracing exceptions much easier during testing and when using
642642
# in-process servers.
643643
raise_exceptions: bool = False,
644+
# When True, the server is stateless and
645+
# clients can perform initialization with any node. The client must still follow
646+
# the initialization lifecycle, but can do so with any available node
647+
# rather than requiring initialization for each connection.
648+
stateless: bool = False,
649+
# When True, treat read EOF as a half-close and allow in-flight handlers
650+
# to drain their responses via the still-open write stream (e.g. stdio
651+
# with bash-redirected stdin).
652+
drain_on_read_close: bool = False,
644653
) -> None:
645654
"""Serve a single connection over the given streams until the read side closes.
646655
@@ -649,15 +658,20 @@ async def run(
649658
(the streamable-HTTP manager) call `serve_loop` directly instead.
650659
"""
651660
async with self.lifespan(self) as lifespan_context:
652-
await serve_loop(
653-
self,
654-
read_stream,
655-
write_stream,
656-
lifespan_state=lifespan_context,
657-
init_options=initialization_options,
658-
raise_exceptions=raise_exceptions,
659-
close_write_stream_on_read_close=False,
660-
)
661+
try:
662+
await serve_loop(
663+
self,
664+
read_stream,
665+
write_stream,
666+
lifespan_state=lifespan_context,
667+
init_options=initialization_options,
668+
raise_exceptions=raise_exceptions,
669+
session_id=None,
670+
close_write_stream_on_read_close=not drain_on_read_close,
671+
)
672+
finally:
673+
if drain_on_read_close:
674+
await write_stream.aclose()
661675

662676
def streamable_http_app(
663677
self,

src/mcp/server/mcpserver/server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,7 @@ async def run_stdio_async(self) -> None:
893893
read_stream,
894894
write_stream,
895895
self._lowlevel_server.create_initialization_options(),
896+
drain_on_read_close=True,
896897
)
897898

898899
async def run_sse_async( # pragma: no cover

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
@@ -200,7 +200,7 @@ async def test_stdio_server_drains_in_flight_responses_on_stdin_eof():
200200
allow_tools_to_finish = anyio.Event()
201201

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

205205
async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
206206
nonlocal tool_started_count
@@ -247,7 +247,16 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar
247247
):
248248
with anyio.fail_after(5):
249249
async with anyio.create_task_group() as tg: # pragma: no branch
250-
tg.start_soon(server.run, read_stream, write_stream, server.create_initialization_options())
250+
251+
async def run_server() -> None:
252+
await server.run(
253+
read_stream,
254+
write_stream,
255+
server.create_initialization_options(),
256+
drain_on_read_close=True,
257+
)
258+
259+
tg.start_soon(run_server)
251260
await both_tools_started.wait()
252261
allow_tools_to_finish.set()
253262

0 commit comments

Comments
 (0)