Skip to content

Commit fa20352

Browse files
committed
fix: notification handlers and progress callbacks cannot crash the connection
Both run as bare tasks in the dispatcher's task group; an uncaught exception cancels every sibling (read loop + in-flight requests) and tears down run(). The previous receive-loop swallowed and logged both. ServerRunner._on_notify: ValidationError -> warning + drop; handler Exception -> logger.exception + swallow. JSONRPCDispatcher: user on_progress callbacks are wrapped so a raise is logged and swallowed instead of cascading. Regression tests confirmed to fail before the fix.
1 parent c1c851f commit fa20352

5 files changed

Lines changed: 86 additions & 6 deletions

File tree

‎src/mcp/server/runner.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323

2424
import anyio.abc
2525
from opentelemetry.trace import SpanKind, StatusCode
26-
from pydantic import BaseModel
26+
from pydantic import BaseModel, ValidationError
2727
from typing_extensions import TypeVar
2828

2929
from mcp.server.connection import Connection
@@ -237,9 +237,20 @@ async def _on_notify(
237237
return
238238
# Absent wire params reach the handler as `None`, not an empty model
239239
# (matches the existing `Server._handle_notification`).
240-
typed_params = entry.params_type.model_validate(params) if params is not None else None
240+
try:
241+
typed_params = entry.params_type.model_validate(params) if params is not None else None
242+
except ValidationError:
243+
logger.warning("dropped %r: malformed params", method)
244+
return
241245
ctx = self._make_context(dctx, typed_params)
242-
await entry.handler(ctx, typed_params)
246+
try:
247+
await entry.handler(ctx, typed_params)
248+
except Exception:
249+
# Top-level boundary: a notification handler crashing must not
250+
# tear down the connection (it runs as a bare task in the
251+
# dispatcher's task group; an uncaught exception would cancel
252+
# every sibling, including the read loop and in-flight requests).
253+
logger.exception("notification handler for %r raised", method)
243254

244255
def _make_context(
245256
self, dctx: DispatchContext[TransportContext], typed_params: BaseModel | None

‎src/mcp/shared/jsonrpc_dispatcher.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,24 @@ def _default_transport_builder(_meta: MessageMetadata) -> TransportContext:
162162
return TransportContext(kind="jsonrpc", can_send_request=True)
163163

164164

165+
def _shielded_progress(fn: ProgressFnT) -> ProgressFnT:
166+
"""Wrap a user progress callback so it can't crash the dispatcher.
167+
168+
The callback runs as a bare task in the dispatcher's task group; an
169+
uncaught exception would cancel every sibling (the read loop and all
170+
in-flight requests). Swallow and log instead, matching the previous
171+
receive-loop's behavior.
172+
"""
173+
174+
async def _wrapped(progress: float, total: float | None, message: str | None) -> None:
175+
try:
176+
await fn(progress, total, message)
177+
except Exception:
178+
logger.exception("progress callback raised")
179+
180+
return _wrapped
181+
182+
165183
def _outbound_metadata(related_request_id: RequestId | None, opts: CallOptions | None) -> MessageMetadata:
166184
"""Choose the `SessionMessage.metadata` for an outgoing request/notification.
167185
@@ -471,7 +489,7 @@ def _dispatch_notification(
471489
total = msg.params.get("total")
472490
message = msg.params.get("message")
473491
self._spawn(
474-
pending.on_progress,
492+
_shielded_progress(pending.on_progress),
475493
float(progress),
476494
float(total) if isinstance(total, int | float) else None,
477495
message if isinstance(message, str) else None,

‎tests/server/conftest.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,5 +44,3 @@ def spans(capfire: CaptureLogfire) -> Iterator[SpanCapture]:
4444
capture.clear()
4545
yield capture
4646
capture.clear()
47-
48-

‎tests/server/test_runner.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,39 @@ async def on_roots_changed(ctx: Ctx, params: NotificationParams | None) -> None:
220220
assert isinstance(seen[1][1], NotificationParams)
221221

222222

223+
@pytest.mark.anyio
224+
async def test_runner_on_notify_handler_exception_is_swallowed_and_logged(
225+
server: SrvT, caplog: pytest.LogCaptureFixture
226+
):
227+
"""A notification handler crashing must not tear down the connection."""
228+
229+
async def boom(ctx: Ctx, params: NotificationParams | None) -> None:
230+
raise RuntimeError("notification handler boom")
231+
232+
server.add_notification_handler("notifications/roots/list_changed", NotificationParams, boom)
233+
async with connected_runner(server) as (client, _):
234+
await client.notify("notifications/roots/list_changed", None)
235+
# Connection still alive: a request after the crashing handler succeeds.
236+
result = await client.send_raw_request("tools/list", None)
237+
assert result["tools"][0]["name"] == "t"
238+
assert "notification handler for 'notifications/roots/list_changed' raised" in caplog.text
239+
240+
241+
@pytest.mark.anyio
242+
async def test_runner_on_notify_drops_malformed_params(server: SrvT, caplog: pytest.LogCaptureFixture):
243+
"""Malformed notification params are logged and dropped, not raised."""
244+
245+
async def on_level(ctx: Ctx, params: SetLevelRequestParams) -> None:
246+
raise NotImplementedError
247+
248+
server.add_notification_handler("notifications/roots/list_changed", SetLevelRequestParams, on_level)
249+
async with connected_runner(server) as (client, _):
250+
await client.notify("notifications/roots/list_changed", {"level": "not-a-level"})
251+
result = await client.send_raw_request("tools/list", None)
252+
assert result["tools"][0]["name"] == "t"
253+
assert "dropped 'notifications/roots/list_changed': malformed params" in caplog.text
254+
255+
223256
@pytest.mark.anyio
224257
async def test_runner_on_notify_drops_before_init_and_unknown_methods(server: SrvT):
225258
async with connected_runner(server, initialized=False) as (client, _):

‎tests/shared/test_jsonrpc_dispatcher.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,26 @@ async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] |
441441
assert received == [(0.25, None, None)]
442442

443443

444+
@pytest.mark.anyio
445+
async def test_progress_callback_exception_is_swallowed_and_logged(caplog: pytest.LogCaptureFixture):
446+
"""A user progress callback raising must not crash the dispatcher."""
447+
448+
async def boom(progress: float, total: float | None, message: str | None) -> None:
449+
raise RuntimeError("progress callback boom")
450+
451+
async def server_on_request(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> dict[str, Any]:
452+
await ctx.progress(0.5)
453+
return {"ok": True}
454+
455+
opts: CallOptions = {"on_progress": boom}
456+
async with running_pair(jsonrpc_pair, server_on_request=server_on_request) as (client, *_):
457+
with anyio.fail_after(5):
458+
result = await client.send_raw_request("t", None, opts)
459+
# Request still completes; the callback's crash was swallowed.
460+
assert result == {"ok": True}
461+
assert "progress callback raised" in caplog.text
462+
463+
444464
@pytest.mark.anyio
445465
async def test_send_raw_request_always_carries_meta_on_the_wire():
446466
"""Outbound requests always include `params._meta` (otel injection per SEP-414).

0 commit comments

Comments
 (0)