Expose the middleware chain on MCPServer and stop sending unrequested change notifications (#3201)

This commit is contained in:
Max 2026-07-28 12:24:23 +01:00 • committed by GitHub
commit c9c431b71a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -8,6 +8,7 @@ behaviour under test. Driver tests (`serve_connection`, `serve_one`,
"""
import contextvars
import logging
from collections.abc import AsyncIterator, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass, field, replace
@ -39,6 +40,8 @@ from mcp_types import (
InitializeRequestParams,
JSONRPCRequest,
ListToolsResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
NotificationParams,
PaginatedRequestParams,
ProgressNotificationParams,
@ -1783,17 +1786,23 @@ async def test_dual_era_loop_a_notifications_meta_never_opens_the_log_gate(serve
@pytest.mark.anyio
async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT):
"""A modern handler's standalone notification reaches the client over the
duplex stream - the notify-only outbound forwards it."""
duplex stream - the notify-only outbound forwards it. Change notifications
are the exception: at this era they reach clients only via
`subscriptions/listen` streams, so a bare one is dropped rather than sent
unrequested."""
async def emit(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
await ctx.session.send_tool_list_changed()
await ctx.session.send_tool_list_changed() # dropped: an unrequested change notification
await ctx.session.send_notification(
LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="hi"))
)
return {}
server.add_request_handler("x/emit", RequestParams, emit)
async with dual_era_client(server) as (client, recorder):
await client.send_raw_request("x/emit", _modern_params())
await recorder.notified.wait()
assert recorder.notifications[0][0] == "notifications/tools/list_changed"
assert [method for method, _ in recorder.notifications] == ["notifications/message"]
@pytest.mark.anyio
@ -1992,12 +2001,35 @@ def test_no_server_requests_dispatch_context_passes_an_already_denying_transport
async def test_notify_only_outbound_forwards_notifications_and_refuses_requests():
inner = _RecordingInnerDctx()
outbound = NotifyOnlyOutbound(inner)
await outbound.notify("notifications/tools/list_changed", None)
assert inner.notifies == ["notifications/tools/list_changed"]
await outbound.notify("notifications/message", None)
assert inner.notifies == ["notifications/message"]
with pytest.raises(NoBackChannelError):
await outbound.send_raw_request("ping", None)
@pytest.mark.anyio
@pytest.mark.parametrize(
"method",
[
"notifications/tools/list_changed",
"notifications/prompts/list_changed",
"notifications/resources/list_changed",
"notifications/resources/updated",
],
)
async def test_notify_only_outbound_drops_change_notifications(method: str, caplog: pytest.LogCaptureFixture):
"""Spec: the server never sends a notification type a subscription did not
request. At the modern era change notifications reach a client only through
a `subscriptions/listen` stream, so a bare copy on the shared channel would
be an unrequested notification - the standalone channel drops it."""
inner = _RecordingInnerDctx()
outbound = NotifyOnlyOutbound(inner)
with caplog.at_level(logging.DEBUG, logger="mcp.server.connection"):
await outbound.notify(method, None)
assert inner.notifies == []
assert f"dropped {method}: delivered via subscriptions/listen at this era" in caplog.text
@pytest.mark.anyio
async def test_dual_era_loop_carries_the_sender_context_through_the_replay():
"""A context-aware read stream's per-message sender context still reaches