chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
"""`docs/advanced/middleware.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
CallToolRequestParams,
|
||||
ErrorData,
|
||||
RequestId,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.middleware import tutorial001
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.context import CallNext, HandlerResult
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
def _is_timing_record(record: logging.LogRecord) -> bool:
|
||||
"""A record emitted by tutorial001's `log_timing` middleware (and nothing else caplog caught)."""
|
||||
return record.name == tutorial001.logger.name
|
||||
|
||||
|
||||
def test_timing_record_predicate() -> None:
|
||||
"""The caplog filter keeps the middleware's own records and drops everyone else's."""
|
||||
args = (logging.INFO, __file__, 1, "msg", None, None)
|
||||
assert _is_timing_record(logging.LogRecord(tutorial001.logger.name, *args))
|
||||
assert not _is_timing_record(logging.LogRecord("somebody.elses.logger", *args))
|
||||
|
||||
|
||||
async def test_middleware_observes_every_inbound_message(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""tutorial001: two client calls produce three timed lines. `server/discover` is wrapped too."""
|
||||
with caplog.at_level(logging.INFO, logger=tutorial001.logger.name):
|
||||
async with Client(tutorial001.server) as client:
|
||||
await client.list_tools()
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
messages = [record.getMessage() for record in filter(_is_timing_record, caplog.records)]
|
||||
assert [message.split(" took ")[0] for message in messages] == ["server/discover", "tools/list", "tools/call"]
|
||||
assert re.fullmatch(r"tools/call took \d+\.\d ms", messages[-1])
|
||||
|
||||
|
||||
async def test_the_result_passes_through_unchanged() -> None:
|
||||
"""tutorial001: `log_timing` returns what `call_next` returned, so the client sees the real result."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
|
||||
|
||||
|
||||
async def test_a_notification_has_no_request_id() -> None:
|
||||
"""`ctx.request_id is None` is how middleware tells a notification from a request."""
|
||||
seen: list[tuple[str, RequestId | None]] = []
|
||||
|
||||
async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
|
||||
seen.append((ctx.method, ctx.request_id))
|
||||
return await call_next(ctx)
|
||||
|
||||
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
|
||||
server.middleware.append(spy)
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
assert seen == [("initialize", 1), ("notifications/initialized", None), ("tools/list", 2)]
|
||||
|
||||
|
||||
async def test_raising_before_call_next_refuses_the_message() -> None:
|
||||
"""A middleware that raises instead of calling `call_next` answers with a JSON-RPC error."""
|
||||
|
||||
async def gate(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
|
||||
if ctx.method == "tools/call":
|
||||
raise MCPError(code=INVALID_REQUEST, message="No calls on Sundays.")
|
||||
return await call_next(ctx)
|
||||
|
||||
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
|
||||
server.middleware.append(gate)
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
assert exc_info.value.error.message == "No calls on Sundays."
|
||||
assert len((await client.list_tools()).tools) == 1
|
||||
|
||||
|
||||
async def test_an_unhandled_method_raises_through_the_middleware() -> None:
|
||||
"""A method without a handler raises `METHOD_NOT_FOUND` out of `call_next`, through the middleware."""
|
||||
seen: list[tuple[str, int]] = []
|
||||
|
||||
async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
|
||||
try:
|
||||
return await call_next(ctx)
|
||||
except MCPError as exc:
|
||||
seen.append((ctx.method, exc.error.code))
|
||||
raise
|
||||
|
||||
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
|
||||
server.middleware.append(spy)
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("config://settings")
|
||||
assert exc_info.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/read")
|
||||
assert seen == [("resources/read", METHOD_NOT_FOUND)]
|
||||
|
||||
|
||||
async def test_initialize_cannot_be_replaced_only_wrapped() -> None:
|
||||
"""`add_request_handler("initialize", ...)` is rejected: middleware is the sanctioned hook."""
|
||||
expected = (
|
||||
"'initialize' is handled by the server runner and cannot be overridden; "
|
||||
"use Server.middleware to observe or wrap initialization"
|
||||
)
|
||||
with pytest.raises(ValueError, match=re.escape(expected)):
|
||||
tutorial001.server.add_request_handler("initialize", CallToolRequestParams, tutorial001.on_call_tool)
|
||||
Reference in New Issue
Block a user