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
3 changed files with 234 additions and 28 deletions

View file

@ -16,11 +16,16 @@ from docs_src.subscriptions import (
tutorial004_asyncio,
tutorial004_trio,
tutorial005,
tutorial006,
)
from mcp import Client
from mcp.server.auth.middleware.auth_context import auth_context_var
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
from mcp.shared.exceptions import MCPError
_ReadResource = Callable[
[ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult]
@ -301,3 +306,30 @@ async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.Capt
printed = capsys.readouterr().out
assert "[x] design\n[ ] build" in printed # first stream, after design
assert "[x] design\n[x] build" in printed # second stream, after build
def _signed_in_as(subject: str) -> Any:
"""Stand in for the auth middleware: put this user's token in the auth context."""
token = AccessToken(token="demo", client_id="docs-client", scopes=[], subject=subject)
return auth_context_var.set(AuthenticatedUser(token))
async def test_the_middleware_refuses_a_listen_the_caller_could_not_read() -> None:
"""tutorial006: one `can_access` gates both `resources/read` and `subscriptions/listen`.
Alice may read (and so watch) the report, and is refused the payroll file on both
paths - the listen refusal is in-band, before any acknowledgment.
"""
reset = _signed_in_as("alice")
try:
async with Client(tutorial006.mcp, mode="2026-07-28") as client:
async with client.listen(resource_subscriptions=["files://report.pdf"]) as sub:
assert sub.honored.resource_subscriptions == ["files://report.pdf"]
with pytest.raises(MCPError) as listen_error:
async with client.listen(resource_subscriptions=["files://report.pdf", "files://payroll.csv"]):
pass # pragma: no cover - the refusal precedes the stream
assert listen_error.value.error.message == "not permitted to watch the requested resources"
with pytest.raises(MCPError):
await client.read_resource("files://payroll.csv")
finally:
auth_context_var.reset(reset)

View file

@ -10,6 +10,7 @@ from inline_snapshot import snapshot
from mcp_types import (
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
MISSING_REQUIRED_CLIENT_CAPABILITY,
AudioContent,
BlobResourceContents,
@ -2345,3 +2346,53 @@ def test_remove_prompt_removes_and_unknown_name_raises() -> None:
assert mcp._prompt_manager.list_prompts() == []
with pytest.raises(ValueError, match="Unknown prompt: greeting"):
mcp.remove_prompt("greeting")
@pytest.mark.anyio
async def test_middleware_kwarg_and_property_share_the_low_level_chain() -> None:
"""SDK-defined: `MCPServer(middleware=[...])` appends to the low-level chain after
the SDK's built-ins, and `mcp.middleware` is that same live list, so a
middleware appended later still wraps requests."""
seen: list[str] = []
async def from_ctor(ctx: ServerRequestContext[Any, Any], call_next: Any) -> Any:
seen.append(f"ctor:{ctx.method}")
return await call_next(ctx)
async def appended(ctx: ServerRequestContext[Any, Any], call_next: Any) -> Any:
seen.append(f"appended:{ctx.method}")
return await call_next(ctx)
mcp = MCPServer("mw", middleware=[from_ctor])
assert mcp.middleware is mcp._lowlevel_server.middleware
assert mcp.middleware[-1] is from_ctor # after the built-ins, outermost-first
mcp.middleware.append(appended)
@mcp.tool()
def ping() -> str:
return "pong"
async with Client(mcp) as client:
await client.call_tool("ping", {})
assert "ctor:tools/call" in seen
assert seen.index("ctor:tools/call") < seen.index("appended:tools/call")
@pytest.mark.anyio
async def test_middleware_can_refuse_subscriptions_listen_before_the_ack() -> None:
"""Spec-adjacent: a middleware that raises on `subscriptions/listen` refuses the
request in-band - the client gets the error and no stream is opened."""
async def refuse_listen(ctx: ServerRequestContext[Any, Any], call_next: Any) -> Any:
if ctx.method == "subscriptions/listen":
raise MCPError(INVALID_REQUEST, "not permitted to watch the requested resources")
return await call_next(ctx)
mcp = MCPServer("mw", middleware=[refuse_listen])
async with Client(mcp) as client:
with pytest.raises(MCPError) as exc_info:
async with client.listen(resource_subscriptions=["files://payroll.csv"]):
pass # pragma: no cover - the refusal precedes the stream
assert exc_info.value.error.code == INVALID_REQUEST
assert exc_info.value.error.message == "not permitted to watch the requested resources"

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