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,143 @@
|
||||
"""`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)
|
||||
Reference in New Issue
Block a user