"""`docs/advanced/low-level-server.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 from mcp import Client, MCPError from mcp.server import Server, ServerRequestContext # 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")] async def test_the_input_schema_on_the_wire_is_the_dict_you_wrote() -> None: """tutorial001: nothing is derived. `tools/list` returns the literal `input_schema` dict.""" async with Client(tutorial001.server) as client: (tool,) = (await client.list_tools()).tools assert tool.name == "search_books" assert tool.description == "Search the catalog by title or author." assert tool.input_schema == snapshot( { "type": "object", "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["query", "limit"], } ) assert tool.output_schema is None async def test_the_client_does_not_care_which_server_class_it_connects_to() -> None: """tutorial001: `Client(server)` accepts a low-level `Server` and the call answers like **Tools**.""" async with Client(tutorial001.server) as client: result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) assert not result.is_error assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")] assert result.structured_content is None async def test_only_the_handlers_you_passed_become_capabilities() -> None: """tutorial001: two tool handlers advertise `tools` and nothing else.""" async with Client(tutorial001.server) as client: assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}}) async def test_arguments_are_not_validated_against_your_schema() -> None: """tutorial001: a call missing a `required` argument still reaches the handler and blows up there.""" async with Client(tutorial001.server) as client: with pytest.raises(MCPError) as exc_info: await client.call_tool("search_books", {"query": "dune"}) assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None) async def test_one_handler_routes_every_tool() -> None: """tutorial002: `on_call_tool` is the single entry point; it dispatches on `params.name`.""" async with Client(tutorial002.server) as client: assert [tool.name for tool in (await client.list_tools()).tools] == ["search_books", "add_book"] result = await client.call_tool("add_book", {"title": "Dune", "author": "Frank Herbert", "year": 1965}) assert result.content == [TextContent(type="text", text="Added 'Dune' by Frank Herbert (1965).")] async def test_an_unknown_tool_name_becomes_a_protocol_error_not_a_tool_error() -> None: """tutorial002: raising from a handler is a `-32603` JSON-RPC error, never an `is_error` result.""" async with Client(tutorial002.server) as client: with pytest.raises(MCPError) as exc_info: await client.call_tool("does_not_exist", {}) assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None) async def test_output_schema_and_structured_content_are_both_yours_to_build() -> None: """tutorial003: you declare the schema on the `Tool` and you build the matching payload.""" async with Client(tutorial003.server) as client: (tool,) = (await client.list_tools()).tools assert tool.output_schema == snapshot( { "type": "object", "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, "required": ["matches", "query"], } ) result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] assert result.structured_content == {"matches": 3, "query": "dune"} async def test_the_client_checks_the_schema_you_promised() -> None: """The page's warning: a `structured_content` that violates your `output_schema` fails in `call_tool`.""" async def promise_breaker(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: return CallToolResult(content=[TextContent(type="text", text="oops")], structured_content={"matches": "three"}) lying = Server("Bookshop", on_list_tools=tutorial003.list_tools, on_call_tool=promise_breaker) async with Client(lying) as client: with pytest.raises(RuntimeError, match="Invalid structured content returned by tool search_books"): await client.call_tool("search_books", {"query": "dune", "limit": 5}) async def test_meta_reaches_the_client_application() -> None: """tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`.""" async with Client(tutorial004.server) as client: result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]} assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( { "_meta": {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}, "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], "structuredContent": {"matches": 3, "query": "dune"}, "isError": False, "resultType": "complete", } ) async def test_the_lifespan_object_reaches_every_handler_with_its_type() -> None: """tutorial005: what the lifespan yields is `ctx.lifespan_context`, typed by `Server[Catalog]`.""" async with Client(tutorial005.server) as client: result = await client.call_tool("search_books", {"query": "dune"}) assert result.content == [TextContent(type="text", text="Found 3 books: Dune, Dune Messiah, Children of Dune.")] async def test_add_request_handler_registers_a_method_the_constructor_does_not_know() -> None: """tutorial006: the registry holds the handler and the params model it validates against.""" entry = tutorial006.server.get_request_handler("bookshop/reindex") assert entry is not None assert entry.params_type is tutorial006.ReindexParams assert tutorial006.server.get_request_handler("bookshop/burn") is None async def test_a_custom_method_never_changes_the_advertised_capabilities() -> None: """tutorial006: only the spec's method families map to capabilities. `bookshop/reindex` is invisible.""" async with Client(tutorial006.server) as client: assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}}) def test_initialize_is_reserved() -> None: """The page's `ValueError`: the handshake belongs to the runner, not to `add_request_handler`.""" server = Server("Bookshop") async def grab_the_handshake(ctx: ServerRequestContext, params: RequestParams) -> None: raise NotImplementedError with pytest.raises(ValueError, match="'initialize' is handled by the server runner"): server.add_request_handler("initialize", RequestParams, grab_the_handshake)