Skip to content

Commit fcf3dae

Browse files
committed
Close the coverage gaps in the rewritten transport
Removes now-dead code (the attach take-over branch, redundant containment around the notification handler, an unreachable None-connection arm, the channel-closing loop in run() teardown) and adds tests for the behaviours that were untested: terminating a session with a request in flight, client-posted progress for a server-initiated request, containment of a raising event store per request, concurrent POSTs sharing a request id, the channel attach/detach identity guard, and the serve_loop driver.
1 parent ef159e7 commit fcf3dae

5 files changed

Lines changed: 334 additions & 30 deletions

File tree

src/mcp/server/runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,9 +477,9 @@ async def serve_loop(
477477
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
478478
479479
Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
480-
`serve_connection`. The streamable-HTTP manager (which owns its lifespan
481-
and serves the modern era on the single-exchange entry instead) calls
482-
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
480+
`serve_connection`. For a transport that supplies a duplex message stream
481+
pair but owns its own lifespan (so `Server.run`'s lifespan entry is not
482+
wanted); `Server.run` drives `serve_dual_era_loop`, which extends the same
483483
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
484484
era routing.
485485
"""

src/mcp/server/streamable_http.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,8 @@ def attach(self) -> MemoryObjectReceiveStream[EventMessage]:
224224
(the standalone GET stream); re-attaching after a detach is how a
225225
`Last-Event-ID` reconnect resumes a live stream.
226226
"""
227+
assert self._writer is None, "every attach site checks `attached` first"
227228
writer, reader = anyio.create_memory_object_stream[EventMessage](REQUEST_STREAM_BUFFER_SIZE)
228-
if self._writer is not None:
229-
self._writer.close()
230229
self._writer = writer
231230
return reader
232231

@@ -262,8 +261,15 @@ def close(self) -> None:
262261
self.finished.set()
263262

264263
def finish(self) -> None:
265-
"""Mark the request as finished even if no terminal frame was recorded."""
264+
"""The request is over: mark it finished and end any response still attached.
265+
266+
Normally the terminal frame already ended the response; this covers a
267+
request whose terminal write never landed (e.g. the event store raised
268+
mid-stream), so the client sees the stream close rather than hanging.
269+
"""
266270
self.finished.set()
271+
if self._writer is not None:
272+
self._detach(self._writer)
267273

268274

269275
@dataclass
@@ -513,6 +519,9 @@ async def run(self, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STAT
513519
cancelling it (server shutdown or the idle timeout) cancels the
514520
handlers and tears the connection down.
515521
"""
522+
self._require_app()
523+
connection = self._connection
524+
assert connection is not None, "a session-bound transport always has a connection"
516525
try:
517526
async with anyio.create_task_group() as tg:
518527
self._task_group = tg
@@ -521,15 +530,12 @@ async def run(self, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STAT
521530
tg.cancel_scope.cancel()
522531
finally:
523532
self._task_group = None
524-
# End every still-open response stream and wake anything awaiting a
525-
# client answer; runs on termination and on manager shutdown alike.
526-
for channel in list(self._channels.values()):
527-
channel.close()
528-
self._channels.clear()
533+
# By now every request handler has finished and released its own
534+
# channel; end the standalone stream and wake anything awaiting a
535+
# client answer (runs on termination and manager shutdown alike).
529536
self._standalone.close()
530537
self._corr.close()
531-
if self._connection is not None:
532-
await aclose_shielded(self._connection)
538+
await aclose_shielded(connection)
533539

534540
def _build_message_metadata(
535541
self, request: Request, request_id: RequestId, protocol_version: str
@@ -650,7 +656,14 @@ def _sse_headers(self) -> dict[str, str]:
650656
}
651657

652658
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
653-
"""Application entry point that handles all HTTP requests."""
659+
"""Application entry point that handles all HTTP requests.
660+
661+
Raises:
662+
RuntimeError: The transport was constructed without a server to
663+
dispatch to (`app`); it is created and driven by
664+
`StreamableHTTPSessionManager`.
665+
"""
666+
self._require_app()
654667
request = Request(scope, receive)
655668

656669
# Validate request headers for DNS rebinding protection
@@ -807,8 +820,8 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
807820
return
808821

809822
def _session_runner(self) -> ServerRunner[Any]:
810-
"""The stateful session's handler kernel."""
811-
assert self._runner is not None, "stateful session was built without a server"
823+
"""The stateful session's handler kernel; built with the transport's server binding."""
824+
assert self._runner is not None
812825
return self._runner
813826

814827
def _stateless_runner(self, request: Request) -> ServerRunner[Any]:
@@ -853,10 +866,10 @@ async def _deliver_client_message(
853866
)
854867

855868
async def _run_notification() -> None:
869+
# `on_notify` contains handler exceptions itself, so a crashing
870+
# notification handler cannot take the session down.
856871
try:
857872
await runner.on_notify(dctx, message.method, message.params)
858-
except Exception: # a crashing notification handler must not take the session down
859-
logger.exception("notification handler for %r raised", message.method)
860873
finally:
861874
if connection is not None:
862875
await aclose_shielded(connection)

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import anyio
2020
import anyio.abc
2121
from mcp_types import (
22-
CONNECTION_CLOSED,
2322
INTERNAL_ERROR,
2423
ErrorData,
2524
JSONRPCError,
@@ -51,7 +50,7 @@
5150
as_request_id,
5251
run_notify_intercept,
5352
)
54-
from mcp.shared.exceptions import MCPError, NoBackChannelError
53+
from mcp.shared.exceptions import NoBackChannelError
5554
from mcp.shared.message import (
5655
ClientMessageMetadata,
5756
MessageMetadata,
@@ -271,10 +270,9 @@ async def send_raw_request(
271270
transport closed or the dispatcher shut down.
272271
RuntimeError: Called before `run()`.
273272
"""
274-
# Post-close sends get the same CONNECTION_CLOSED contract as in-flight waiters.
275-
if self._corr.closed:
276-
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed")
277-
if not self._running:
273+
# Post-close sends get the same CONNECTION_CLOSED contract as in-flight
274+
# waiters (raised by the correlator); only a never-run dispatcher is a usage error.
275+
if not self._running and not self._corr.closed:
278276
raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run()")
279277
plan = _plan_outbound(_related_request_id, opts)
280278
return await self._corr.call(

tests/server/test_runner.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
)
5454

5555
import mcp.server.runner
56+
from mcp.client.session import ClientSession
5657
from mcp.server.caching import CacheHint
5758
from mcp.server.connection import Connection, NotifyOnlyOutbound
5859
from mcp.server.context import ServerRequestContext
@@ -67,6 +68,7 @@
6768
aclose_shielded,
6869
serve_connection,
6970
serve_dual_era_loop,
71+
serve_loop,
7072
serve_one,
7173
)
7274
from mcp.server.session import ServerSession
@@ -75,6 +77,7 @@
7577
from mcp.shared.dispatcher import CallOptions
7678
from mcp.shared.exceptions import MCPError, NoBackChannelError
7779
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
80+
from mcp.shared.memory import create_client_server_memory_streams
7881
from mcp.shared.message import MessageMetadata, SessionMessage
7982
from mcp.shared.peer import dump_params
8083
from mcp.shared.transport_context import TransportContext
@@ -2056,3 +2059,22 @@ async def test_dual_era_client_propagates_body_exception_unwrapped(server: SrvT)
20562059
with pytest.raises(RuntimeError, match="boom"):
20572060
async with dual_era_client(server):
20582061
raise RuntimeError("boom")
2062+
2063+
2064+
@pytest.mark.anyio
2065+
async def test_serve_loop_serves_a_handshake_connection_over_a_stream_pair(server: SrvT) -> None:
2066+
"""`serve_loop`, the loop-mode driver for transports that own their own lifespan, round-trips
2067+
a handshake and a request over a duplex stream pair and returns when the channel closes."""
2068+
async with create_client_server_memory_streams() as ((client_read, client_write), (server_read, server_write)):
2069+
with anyio.fail_after(5):
2070+
async with anyio.create_task_group() as tg: # pragma: no branch
2071+
tg.start_soon(
2072+
partial(serve_loop, server, server_read, server_write, lifespan_state={}, session_id="loop-1")
2073+
)
2074+
async with ClientSession(client_read, client_write) as session:
2075+
initialized = await session.initialize()
2076+
tools = await session.list_tools()
2077+
assert initialized.server_info.name == "test-server"
2078+
assert [tool.name for tool in tools.tools] == ["t"]
2079+
# Closing the client's write side EOFs the loop and lets it return.
2080+
await client_write.aclose()

0 commit comments

Comments
 (0)