Skip to content

Commit 2d33ade

Browse files
committed
Raise CONNECTION_CLOSED for requests sent after the connection closed
send_raw_request raised RuntimeError both before run() and after the transport closed, contradicting the documented contract that connection loss surfaces as MCPError(CONNECTION_CLOSED). A closed flag now separates the two states: RuntimeError remains for use before run(), and a request after EOF gets the same CONNECTION_CLOSED error the in-flight waiters receive.
1 parent 421e65b commit 2d33ade

3 files changed

Lines changed: 29 additions & 3 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1186,7 +1186,7 @@ Behavior changes:
11861186
- **Unknown-id responses are ignored**, as the spec asks. v1 surfaced them to `message_handler` as a `RuntimeError`; nothing is surfaced now.
11871187
- **Error responses with a null `id`** — the JSON-RPC shape for a peer reporting a parse error — are now dropped with a debug log. v1 surfaced them to `message_handler` as an `MCPError`.
11881188
- **A raising request callback** is answered with `code=0` and the exception text. v1 flattened every callback exception to `INVALID_PARAMS`. Callbacks that want a specific error response should return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: a callback that raises pydantic's `ValidationError` is still answered with `INVALID_PARAMS` (`"Invalid request parameters"`, empty `data`) because the dispatcher cannot distinguish it from inbound-params validation — this conflation is pre-existing v1 behavior, and a revisit is pending.
1189-
- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. `send_notification` before entry still works.
1189+
- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed, `send_request` instead raises `MCPError` (`CONNECTION_CLOSED`), matching what an in-flight request receives — `RuntimeError` remains only for calls before entry. `send_notification` before entry still works.
11901190
- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** The hint was never serialized by any client transport in v1 or v2 — it exists for the server's streamable-HTTP stream routing. Progress and response correlation via `progressToken` and the request id is unaffected.
11911191
- **The private `mcp.shared._context.RequestContext` generic is deleted.** Client callbacks now receive the concrete `mcp.client.ClientRequestContext`, whose `request_id` is always populated (the client only builds a context for inbound requests). Annotations spelled `RequestContext[ClientSession]` become `ClientRequestContext`.
11921192

src/mcp/shared/jsonrpc_dispatcher.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ def __init__(
252252
self._in_flight: dict[RequestId, _InFlight[TransportT]] = {}
253253
self._tg: anyio.abc.TaskGroup | None = None
254254
self._running = False
255+
self._closed = False
255256

256257
async def send_raw_request(
257258
self,
@@ -270,10 +271,13 @@ async def send_raw_request(
270271
MCPError: Peer error response; `REQUEST_TIMEOUT` if
271272
`opts["timeout"]` elapsed; `CONNECTION_CLOSED` if the
272273
transport closed or the dispatcher shut down.
273-
RuntimeError: Called outside `run()`.
274+
RuntimeError: Called before `run()`.
274275
"""
276+
# Post-close sends get the same CONNECTION_CLOSED contract as in-flight waiters.
277+
if self._closed:
278+
raise MCPError(code=CONNECTION_CLOSED, message="Connection closed")
275279
if not self._running:
276-
raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run() / after close")
280+
raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run()")
277281
opts = opts or {}
278282
request_id = self._allocate_id()
279283
out_params = dict(params) if params is not None else {}
@@ -399,6 +403,7 @@ async def run(
399403
logger.debug("read stream closed by transport; treating as EOF")
400404
# EOF: wake blocked `send_raw_request` waiters with CONNECTION_CLOSED.
401405
self._running = False
406+
self._closed = True
402407
self._fan_out_closed()
403408
finally:
404409
# Cancel in-flight handlers; otherwise the task-group join
@@ -407,6 +412,7 @@ async def run(
407412
finally:
408413
# Covers cancel/crash paths that skip the inline fan-out; idempotent.
409414
self._running = False
415+
self._closed = True
410416
self._tg = None
411417
self._fan_out_closed()
412418

tests/shared/test_jsonrpc_dispatcher.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,26 @@ async def test_send_raw_request_before_run_raises_runtimeerror():
12211221
s.close()
12221222

12231223

1224+
@pytest.mark.anyio
1225+
async def test_send_raw_request_after_connection_close_raises_connection_closed():
1226+
"""Sending after run() saw EOF raises MCPError(CONNECTION_CLOSED) — the same contract
1227+
in-flight waiters get — not RuntimeError (SDK-defined)."""
1228+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
1229+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
1230+
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
1231+
on_request, on_notify = echo_handlers(Recorder())
1232+
try:
1233+
s2c_send.close() # peer drops: run() sees immediate EOF and returns
1234+
with anyio.fail_after(5):
1235+
await client.run(on_request, on_notify)
1236+
with pytest.raises(MCPError) as exc:
1237+
await client.send_raw_request("ping", None)
1238+
assert exc.value.error.code == CONNECTION_CLOSED
1239+
finally:
1240+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
1241+
s.close()
1242+
1243+
12241244
@pytest.mark.anyio
12251245
async def test_transport_exception_in_read_stream_is_logged_and_dropped():
12261246
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4)

0 commit comments

Comments
 (0)