Expose the middleware chain on MCPServer and stop sending unrequested change notifications (#3201)
This commit is contained in:
parent
528e366558
commit
c9c431b71a
3 changed files with 234 additions and 28 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue