docs: stop presenting the in-memory client as the way to connect (#3443)
This commit is contained in:
parent
0c9136841f
commit
7bb486a10f
9 changed files with 803 additions and 598 deletions
|
|
@ -5,9 +5,8 @@ from typing import Any
|
|||
import pytest
|
||||
from mcp_types import TextContent, TextResourceContents
|
||||
|
||||
from docs_src.apps import tutorial001, tutorial002, tutorial003
|
||||
from docs_src.apps import tutorial001, tutorial001_client, tutorial002, tutorial003
|
||||
from mcp import Client
|
||||
from mcp.client import advertise
|
||||
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
|
|
@ -33,11 +32,10 @@ async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None:
|
|||
|
||||
|
||||
async def test_one_tool_two_answers() -> None:
|
||||
"""tutorial001: the canonical degradation pattern: raw data for a client that
|
||||
negotiated Apps, a human sentence for one that did not."""
|
||||
async with Client(
|
||||
tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
|
||||
) as ui_client:
|
||||
"""tutorial001_client's `APPS_SUPPORT` declaration, driven in-process against
|
||||
tutorial001's server: the client that negotiated Apps (with the required
|
||||
`mimeTypes`) gets raw data, one that did not gets the human sentence."""
|
||||
async with Client(tutorial001.mcp, extensions=[tutorial001_client.APPS_SUPPORT]) as ui_client:
|
||||
rich = await ui_client.call_tool("get_time", {})
|
||||
async with Client(tutorial001.mcp) as text_client:
|
||||
plain = await text_client.call_tool("get_time", {})
|
||||
|
|
@ -45,13 +43,6 @@ async def test_one_tool_two_answers() -> None:
|
|||
assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")]
|
||||
|
||||
|
||||
async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial001: `main()` declares Apps support with the required `mimeTypes` and
|
||||
receives the rich answer the page promises."""
|
||||
await tutorial001.main()
|
||||
assert "2026-06-26T12:00:00Z" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_capability_advertised_under_server_extensions() -> None:
|
||||
"""tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
|
|
|
|||
|
|
@ -64,9 +64,13 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None:
|
|||
async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches."""
|
||||
await tutorial003.main()
|
||||
assert capsys.readouterr().out == "4 calls, 3 fetches\n"
|
||||
"""tutorial003's four calls, driven in-process against tutorial002's server with the demo's own
|
||||
clock: a cache hit, an expiry, and `cache_mode="refresh"` make them cost three fetches, each one
|
||||
a line the server printed. The first result carries the handler's TTL and the map's scope."""
|
||||
async with Client(tutorial002.server, cache=CacheConfig(clock=lambda: tutorial003.clock.now)) as client:
|
||||
tools = await tutorial003.run(client)
|
||||
assert (tools.ttl_ms, tools.cache_scope) == (1_000, "public")
|
||||
assert capsys.readouterr().out == "tools/list served\n" * 3
|
||||
|
||||
|
||||
def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
"""`docs/client/index.md`: every claim the page makes, proved against the real SDK."""
|
||||
"""`docs/client/index.md`: every claim the page makes, proved against the real SDK.
|
||||
|
||||
`tutorial001` is the page's `server.py`. The other snippets are `client.py` programs that reach it
|
||||
by URL, so each test drives the same calls in-process against `tutorial001.mcp` instead.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool
|
||||
|
||||
from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007
|
||||
from docs_src.client import tutorial001, tutorial007
|
||||
from mcp import Client, MCPDeprecationWarning, MCPError
|
||||
from mcp.shared.metadata_utils import get_display_name
|
||||
|
||||
|
|
@ -12,31 +16,23 @@ from mcp.shared.metadata_utils import get_display_name
|
|||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Each `main()` is the literal client program shown on the page; all seven run clean in-memory."""
|
||||
await tutorial001.main()
|
||||
await tutorial002.main()
|
||||
await tutorial003.main()
|
||||
await tutorial004.main()
|
||||
await tutorial005.main()
|
||||
await tutorial006.main()
|
||||
await tutorial007.main()
|
||||
assert "Bookshop" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_connected_properties_are_populated_inside_the_block() -> None:
|
||||
"""tutorial001: server_info, server_capabilities, protocol_version and instructions are just there."""
|
||||
"""tutorial001_client's four prints, in-process against server.py: server_info, capabilities, protocol_version
|
||||
and instructions are just there, and a capability the server lacks is None."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_info is not None
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.instructions == "Search the catalog before recommending a book."
|
||||
assert client.server_capabilities.tools is not None
|
||||
assert client.server_capabilities.resources is not None
|
||||
assert client.server_capabilities.prompts is not None
|
||||
assert client.server_capabilities.completions is not None
|
||||
assert client.server_capabilities.logging is None
|
||||
|
||||
|
||||
async def test_a_client_is_not_reusable_after_the_block_ends() -> None:
|
||||
"""tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection."""
|
||||
"""The lifecycle bullet: `async with` is the whole lifecycle. Construct a new Client per connection."""
|
||||
client = Client(tutorial001.mcp)
|
||||
async with client:
|
||||
assert client.server_info is not None
|
||||
|
|
@ -46,13 +42,14 @@ async def test_a_client_is_not_reusable_after_the_block_ends() -> None:
|
|||
|
||||
|
||||
async def test_list_tools_returns_the_full_definition() -> None:
|
||||
"""tutorial002: each listed tool carries its name, title, description and the derived input schema."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "search_books"
|
||||
assert tool.title == "Search the catalog"
|
||||
assert tool.description == "Search the catalog by title or author."
|
||||
assert tool.input_schema == snapshot(
|
||||
"""tutorial002's listing, in-process: each tool carries its name, title, description and the derived
|
||||
input schema, and a tool registered without `title=` lists with title None."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(search, lookup) = (await client.list_tools()).tools
|
||||
assert search.name == "search_books"
|
||||
assert search.title == "Search the catalog"
|
||||
assert search.description == "Search the catalog by title or author."
|
||||
assert search.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -63,6 +60,8 @@ async def test_list_tools_returns_the_full_definition() -> None:
|
|||
"title": "search_booksArguments",
|
||||
}
|
||||
)
|
||||
assert lookup.name == "lookup_book"
|
||||
assert lookup.title is None
|
||||
|
||||
|
||||
def test_get_display_name_prefers_the_title() -> None:
|
||||
|
|
@ -74,8 +73,8 @@ def test_get_display_name_prefers_the_title() -> None:
|
|||
|
||||
|
||||
async def test_call_tool_result_has_three_things_to_read() -> None:
|
||||
"""tutorial003: content for the model, structured_content for code, is_error for both."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
"""tutorial003's call, in-process: content for the model, structured_content for code, is_error for both."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("lookup_book", {"title": "Dune"})
|
||||
assert not result.is_error
|
||||
(block,) = result.content
|
||||
|
|
@ -85,8 +84,8 @@ async def test_call_tool_result_has_three_things_to_read() -> None:
|
|||
|
||||
|
||||
async def test_a_raising_tool_is_a_result_not_an_exception() -> None:
|
||||
"""tutorial003 `!!! check`: the ToolError's message comes back in content with is_error=True."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
"""The `!!! check`: the ToolError's message comes back in content with is_error=True."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("lookup_book", {"title": "Solaris"})
|
||||
assert result.is_error
|
||||
(block,) = result.content
|
||||
|
|
@ -97,7 +96,7 @@ async def test_a_raising_tool_is_a_result_not_an_exception() -> None:
|
|||
|
||||
async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None:
|
||||
"""The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("does_not_exist", {})
|
||||
assert result.is_error
|
||||
(block,) = result.content
|
||||
|
|
@ -107,8 +106,9 @@ async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None:
|
|||
|
||||
|
||||
async def test_resources_and_templates_are_two_separate_lists() -> None:
|
||||
"""tutorial004: concrete resources and parameterised templates come back from different verbs."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
"""tutorial004's two listings, in-process: concrete resources and parameterised templates come back
|
||||
from different verbs."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(resource,) = (await client.list_resources()).resources
|
||||
assert resource.uri == "catalog://genres"
|
||||
(template,) = (await client.list_resource_templates()).resource_templates
|
||||
|
|
@ -116,8 +116,9 @@ async def test_resources_and_templates_are_two_separate_lists() -> None:
|
|||
|
||||
|
||||
async def test_read_resource_fills_in_a_template() -> None:
|
||||
"""tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
"""tutorial004's read, in-process: read_resource takes a plain str URI, the server matches it to the
|
||||
template, and the contents narrow with isinstance."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(contents,) = (await client.read_resource("catalog://genres/poetry")).contents
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.text == "3 books filed under poetry."
|
||||
|
|
@ -126,7 +127,7 @@ async def test_read_resource_fills_in_a_template() -> None:
|
|||
async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None:
|
||||
"""The Resources section: at 2026-07-28 `resources.subscribe` is True (served via
|
||||
subscriptions/listen) while the legacy subscribe_resource verb answers -32601."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_capabilities.resources is not None
|
||||
assert client.server_capabilities.resources.subscribe is True
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
|
|
@ -138,8 +139,8 @@ async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> N
|
|||
|
||||
|
||||
async def test_list_prompts_describes_the_arguments() -> None:
|
||||
"""tutorial005: a listed prompt carries its name, title and the arguments it needs."""
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
"""tutorial005's listing, in-process: a listed prompt carries its name, title and the arguments it needs."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(prompt,) = (await client.list_prompts()).prompts
|
||||
assert prompt == snapshot(
|
||||
Prompt(
|
||||
|
|
@ -152,8 +153,8 @@ async def test_list_prompts_describes_the_arguments() -> None:
|
|||
|
||||
|
||||
async def test_get_prompt_renders_the_messages() -> None:
|
||||
"""tutorial005: get_prompt returns the rendered messages a host hands to the model."""
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
"""tutorial005's render, in-process: get_prompt returns the messages a host hands to the model."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.get_prompt("recommend", {"genre": "poetry"})
|
||||
(message,) = result.messages
|
||||
assert message.role == "user"
|
||||
|
|
@ -163,8 +164,9 @@ async def test_get_prompt_renders_the_messages() -> None:
|
|||
|
||||
|
||||
async def test_complete_suggests_values_for_an_argument() -> None:
|
||||
"""tutorial006: complete takes a ref and a name/value pair and returns the matching values."""
|
||||
async with Client(tutorial006.mcp) as client:
|
||||
"""tutorial006's call, in-process: complete takes a ref and a name/value pair and returns the matching
|
||||
values."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.complete(
|
||||
ref=PromptReference(type="ref/prompt", name="recommend"),
|
||||
argument={"name": "genre", "value": "p"},
|
||||
|
|
@ -172,16 +174,17 @@ async def test_complete_suggests_values_for_an_argument() -> None:
|
|||
assert result.completion.values == ["poetry"]
|
||||
|
||||
|
||||
async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None:
|
||||
"""tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left."""
|
||||
async with Client(tutorial007.mcp) as client:
|
||||
page = await client.list_tools(cursor=None)
|
||||
assert page.next_cursor is None
|
||||
assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"]
|
||||
async def test_the_pagination_loop_collects_every_tool_from_a_single_page_server() -> None:
|
||||
"""tutorial007's `list_all_tools`, driven in-process against server.py: MCPServer answers in one page,
|
||||
so the loop ends on the first `next_cursor is None` with every tool collected."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
tools = await tutorial007.list_all_tools(client)
|
||||
assert [tool.name for tool in tools] == ["search_books", "lookup_book"]
|
||||
assert (await client.list_tools(cursor=None)).next_cursor is None
|
||||
|
||||
|
||||
async def test_raise_exceptions_is_a_constructor_flag() -> None:
|
||||
"""The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client."""
|
||||
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,12 @@ from docs_src.extensions import (
|
|||
tutorial002,
|
||||
tutorial003,
|
||||
tutorial004,
|
||||
tutorial004_client,
|
||||
tutorial005,
|
||||
tutorial006,
|
||||
tutorial006_client,
|
||||
tutorial007,
|
||||
tutorial007_client,
|
||||
)
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import advertise
|
||||
|
|
@ -42,13 +45,15 @@ def test_a_prefixless_identifier_fails_at_class_definition() -> None:
|
|||
|
||||
|
||||
async def test_extension_settings_advertised_under_capabilities() -> None:
|
||||
"""tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`."""
|
||||
"""tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`,
|
||||
which is the first line tutorial003_client prints."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}}
|
||||
|
||||
|
||||
async def test_contributed_tool_is_listed_and_callable() -> None:
|
||||
"""tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable."""
|
||||
"""tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable,
|
||||
with the content tutorial003_client prints."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
assert [tool.name for tool in listed.tools] == ["stamp"]
|
||||
|
|
@ -56,28 +61,23 @@ async def test_contributed_tool_is_listed_and_callable() -> None:
|
|||
assert result.content == [TextContent(type="text", text="[stamped] hello")]
|
||||
|
||||
|
||||
async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial003: `main()` is the literal client program on the page; both printed
|
||||
lines match the page's comments."""
|
||||
await tutorial003.main()
|
||||
out = capsys.readouterr().out
|
||||
assert "{'com.example/stamps': {'sealed': True}}" in out
|
||||
assert "[stamped] hello" in out
|
||||
|
||||
|
||||
async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004: `main()` declares the extension and gets the vendor method's result."""
|
||||
await tutorial004.main()
|
||||
assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out
|
||||
async def test_declaring_client_gets_the_vendor_method_result() -> None:
|
||||
"""tutorial004_client's request against tutorial004's server, driven in-process: a client
|
||||
that advertises the extension gets the vendor method's typed result, and the client's
|
||||
own copy of the wire types agrees with the server's."""
|
||||
async with Client(tutorial004.mcp, extensions=[advertise(tutorial004_client.EXTENSION_ID)]) as client:
|
||||
request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp", limit=3))
|
||||
result = await client.session.send_request(request, tutorial004_client.SearchResult)
|
||||
assert result.items == ["mcp-0", "mcp-1", "mcp-2"]
|
||||
|
||||
|
||||
async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None:
|
||||
"""tutorial004: `require_client_extension` answers a non-declaring client with `-32021`
|
||||
and the machine-readable `requiredCapabilities` payload."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
|
||||
request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp"))
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.session.send_request(request, tutorial004.SearchResult)
|
||||
await client.session.send_request(request, tutorial004_client.SearchResult)
|
||||
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}}
|
||||
|
||||
|
|
@ -85,10 +85,12 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None
|
|||
async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None:
|
||||
"""tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND
|
||||
at any other wire version; for a legacy client it doesn't exist."""
|
||||
async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client:
|
||||
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
|
||||
async with Client(
|
||||
tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004_client.EXTENSION_ID)]
|
||||
) as client:
|
||||
request = tutorial004_client.SearchRequest(params=tutorial004_client.SearchParams(query="mcp"))
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.session.send_request(request, tutorial004.SearchResult)
|
||||
await client.session.send_request(request, tutorial004_client.SearchResult)
|
||||
assert exc_info.value.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
|
|
@ -104,10 +106,12 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through(
|
|||
assert messages == ["tool 'add' called"]
|
||||
|
||||
|
||||
async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape."""
|
||||
await tutorial006.main()
|
||||
assert "goods for r-117" in capsys.readouterr().out
|
||||
async def test_declaring_client_receives_the_redeemed_result_not_the_claimed_shape() -> None:
|
||||
"""tutorial006_client's `Receipts` against tutorial006's server, driven in-process:
|
||||
`call_tool("buy")` returns what the resolver redeemed, never the claimed receipt shape."""
|
||||
async with Client(tutorial006.mcp, extensions=[tutorial006_client.Receipts()]) as client:
|
||||
result = await client.call_tool("buy", {"item": "lamp"})
|
||||
assert result.content == [TextContent(type="text", text="goods for r-117")]
|
||||
|
||||
|
||||
async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
|
||||
|
|
@ -120,13 +124,17 @@ async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
|
|||
|
||||
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
|
||||
"""The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result."""
|
||||
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
|
||||
async with Client(tutorial006.mcp, extensions=[tutorial006_client.Receipts()]) as client:
|
||||
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
|
||||
assert isinstance(result, tutorial006.ReceiptResult)
|
||||
assert isinstance(result, tutorial006_client.ReceiptResult)
|
||||
assert result.receipt_token == "r-117"
|
||||
|
||||
|
||||
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration."""
|
||||
await tutorial007.main()
|
||||
assert "job-7 is running" in capsys.readouterr().out
|
||||
async def test_name_param_request_round_trips_with_no_client_registration() -> None:
|
||||
"""tutorial007_client's `JobStatusRequest` against tutorial007's server, driven in-process:
|
||||
a vendor request declaring `name_param` round-trips `send_request` with no client-side
|
||||
registration."""
|
||||
async with Client(tutorial007.mcp, extensions=[advertise(tutorial007_client.EXTENSION_ID)]) as client:
|
||||
request = tutorial007_client.JobStatusRequest(params=tutorial007_client.JobParams(job_id="job-7"))
|
||||
result = await client.session.send_request(request, tutorial007_client.JobStatus)
|
||||
assert result.status == "job-7 is running"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import httpx2
|
|||
import pytest
|
||||
from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
|
||||
|
||||
from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003
|
||||
from docs_src.legacy_clients import tutorial001, tutorial001_client, tutorial002, tutorial003
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import MCPServer
|
||||
|
|
@ -25,14 +25,21 @@ MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type":
|
|||
URL = "http://localhost:8000/mcp"
|
||||
|
||||
|
||||
async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer."""
|
||||
await tutorial001.main()
|
||||
assert capsys.readouterr().out == (
|
||||
"""2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n"""
|
||||
)
|
||||
async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once() -> None:
|
||||
"""tutorial001_client's flow, driven in-process against tutorial001's server: two eras of client open at
|
||||
once with the page's `answer` callback, one `Resolve` tool, and the two lines the page prints."""
|
||||
lines: list[tuple[str, object]] = []
|
||||
async with (
|
||||
Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial001_client.answer) as legacy,
|
||||
Client(tutorial001.mcp, elicitation_callback=tutorial001_client.answer) as modern,
|
||||
):
|
||||
for client in (legacy, modern):
|
||||
result = await client.call_tool("reserve", {"title": "Dune"})
|
||||
lines.append((client.protocol_version, result.structured_content))
|
||||
assert lines == [
|
||||
("2025-11-25", {"result": "Reserved 2 of 'Dune'."}),
|
||||
("2026-07-28", {"result": "Reserved 2 of 'Dune'."}),
|
||||
]
|
||||
|
||||
|
||||
async def test_neither_era_of_client_sees_the_resolved_parameter() -> None:
|
||||
|
|
@ -114,13 +121,13 @@ async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_
|
|||
httpx2.AsyncClient(transport=transport) as http,
|
||||
):
|
||||
modern_target = streamable_http_client(URL, http_client=http)
|
||||
async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:
|
||||
async with Client(modern_target, elicitation_callback=tutorial001_client.answer) as modern:
|
||||
assert modern.protocol_version == "2026-07-28"
|
||||
result = await modern.call_tool("reserve", {"title": "Dune"})
|
||||
assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")]
|
||||
|
||||
legacy_target = streamable_http_client(URL, http_client=http)
|
||||
async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy:
|
||||
async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001_client.answer) as legacy:
|
||||
assert legacy.protocol_version == "2025-11-25"
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await legacy.call_tool("reserve", {"title": "Dune"})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from mcp_types import (
|
|||
RequestParams,
|
||||
TextContent,
|
||||
)
|
||||
from starlette.routing import Route
|
||||
|
||||
from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
|
||||
from mcp import Client, MCPError
|
||||
|
|
@ -45,6 +46,13 @@ async def test_the_client_does_not_care_which_server_class_it_connects_to() -> N
|
|||
assert result.structured_content is None
|
||||
|
||||
|
||||
def test_the_last_line_is_an_asgi_app_uvicorn_can_serve() -> None:
|
||||
"""tutorial001: the Try-it's `uvicorn server:app` target is a Starlette app with one route at `/mcp`."""
|
||||
(route,) = tutorial001.app.routes
|
||||
assert isinstance(route, Route)
|
||||
assert route.path == "/mcp"
|
||||
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"""`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from mcp_types import Resource
|
||||
|
||||
from docs_src.pagination import tutorial001, tutorial002
|
||||
from mcp import Client, MCPError
|
||||
|
|
@ -48,27 +47,20 @@ async def test_the_last_page_carries_no_cursor() -> None:
|
|||
assert page.next_cursor is None
|
||||
|
||||
|
||||
async def test_the_loop_collects_all_one_hundred() -> None:
|
||||
"""tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog."""
|
||||
async def test_the_client_loop_collects_all_one_hundred_in_order() -> None:
|
||||
"""tutorial002's `list_all_resources()`, driven in-process against tutorial001's server: the `cursor=` loop
|
||||
stitches the pages back into the whole catalog, in order, with no gaps and no repeats."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
resources: list[Resource] = []
|
||||
cursor: str | None = None
|
||||
pages = 0
|
||||
while True:
|
||||
page = await client.list_resources(cursor=cursor)
|
||||
resources.extend(page.resources)
|
||||
pages += 1
|
||||
if page.next_cursor is None:
|
||||
break
|
||||
cursor = page.next_cursor
|
||||
assert pages == 10
|
||||
assert len({resource.uri for resource in resources}) == 100
|
||||
resources = await tutorial002.list_all_resources(client)
|
||||
assert [resource.name for resource in resources] == tutorial001.BOOKS
|
||||
|
||||
|
||||
async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial002: `main()` is the literal client program on the page and prints the stitched total."""
|
||||
await tutorial002.main()
|
||||
assert capsys.readouterr().out == "100 resources\n"
|
||||
async def test_the_client_loop_runs_once_against_a_server_that_does_not_page() -> None:
|
||||
"""tutorial002's loop against the `MCPServer` above: `next_cursor` is `None` on the first response, so one
|
||||
pass returns the whole catalog."""
|
||||
async with Client(mcp) as client:
|
||||
resources = await tutorial002.list_all_resources(client)
|
||||
assert [resource.name for resource in resources] == [f"book-{n}" for n in range(1, 101)]
|
||||
|
||||
|
||||
async def test_an_invented_cursor_is_an_error() -> None:
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ async def test_each_report_becomes_one_callback_invocation_in_order() -> None:
|
|||
|
||||
|
||||
async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None:
|
||||
"""The `!!! info`: only the in-memory connection runs the callback inline.
|
||||
"""The `!!! info`: only the in-process connection runs the callback inline.
|
||||
|
||||
On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so
|
||||
`call_tool` can return while a slow callback is still running. The callbacks below block on an
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK."""
|
||||
"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK.
|
||||
|
||||
The page's snippets are URL clients for the Bookshop `server.py` from The Client page
|
||||
(`docs_src/client/tutorial001.py`). These tests open the same connections in-process
|
||||
against that server object, so each `mode=` is exercised without a port.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from mcp_types import SERVER_INFO_META_KEY, DiscoverResult, Implementation, ServerCapabilities
|
||||
|
||||
from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from docs_src.client import tutorial001 as bookshop
|
||||
from mcp import Client
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
|
|
@ -13,8 +18,9 @@ pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDepre
|
|||
|
||||
|
||||
async def test_auto_lands_on_the_modern_version() -> None:
|
||||
"""tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
"""tutorial001's connection, in-process: the default `mode="auto"` probes `server/discover`
|
||||
and adopts the result, so the page's `2026-07-28` output block is what it prints."""
|
||||
async with Client(bookshop.mcp) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.server_info is not None
|
||||
assert client.server_info.name == "Bookshop"
|
||||
|
|
@ -23,8 +29,9 @@ async def test_auto_lands_on_the_modern_version() -> None:
|
|||
|
||||
|
||||
async def test_legacy_forces_the_initialize_handshake() -> None:
|
||||
"""tutorial002: `mode="legacy"` runs `initialize` against the very same server."""
|
||||
async with Client(tutorial002.mcp, mode="legacy") as client:
|
||||
"""tutorial002's connection, in-process: `mode="legacy"` runs `initialize` against the very
|
||||
same server and lands on the newest handshake-era version."""
|
||||
async with Client(bookshop.mcp, mode="legacy") as client:
|
||||
assert client.protocol_version == "2025-11-25"
|
||||
assert client.server_info is not None
|
||||
assert client.server_info.name == "Bookshop"
|
||||
|
|
@ -33,14 +40,16 @@ async def test_legacy_forces_the_initialize_handshake() -> None:
|
|||
|
||||
|
||||
async def test_version_pin_sends_nothing_and_knows_nothing() -> None:
|
||||
"""tutorial003: a pin adopts the version locally; `server_info` is None and capabilities are blank."""
|
||||
async with Client(tutorial003.mcp, mode="2026-07-28") as client:
|
||||
"""tutorial003's connection, in-process: a pin adopts the version locally, so `server_info`
|
||||
is None and every capability is blank, yet a tool call still round-trips."""
|
||||
async with Client(bookshop.mcp, mode="2026-07-28") as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.session.initialize_result is None
|
||||
# The `!!! check` fence is the literal `print(client.server_info)` output: None.
|
||||
assert client.server_info is None
|
||||
assert client.server_capabilities == ServerCapabilities()
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."}
|
||||
|
||||
|
||||
def test_handshake_era_version_is_not_a_valid_pin() -> None:
|
||||
|
|
@ -52,18 +61,20 @@ def test_handshake_era_version_is_not_a_valid_pin() -> None:
|
|||
"got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')"
|
||||
),
|
||||
):
|
||||
Client(tutorial003.mcp, mode="2025-06-18")
|
||||
Client(bookshop.mcp, mode="2025-06-18")
|
||||
|
||||
|
||||
async def test_prior_discover_round_trips() -> None:
|
||||
"""tutorial004: save `discover_result`, reconnect with it, and the identity comes back."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
"""tutorial004's flow, driven in-process against the Bookshop server: save `discover_result`,
|
||||
reconnect pinned with it, and the identity comes back without any negotiation."""
|
||||
async with Client(bookshop.mcp) as client:
|
||||
saved = client.session.discover_result
|
||||
assert saved is not None
|
||||
assert saved.supported_versions == ["2026-07-28"]
|
||||
|
||||
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client:
|
||||
async with Client(bookshop.mcp, mode="2026-07-28", prior_discover=saved) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.session.discover_result is saved
|
||||
assert client.server_info is not None
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.server_capabilities.tools is not None
|
||||
|
|
@ -71,14 +82,14 @@ async def test_prior_discover_round_trips() -> None:
|
|||
|
||||
async def test_discover_result_survives_json() -> None:
|
||||
"""`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
async with Client(bookshop.mcp) as client:
|
||||
saved = client.session.discover_result
|
||||
assert saved is not None
|
||||
|
||||
restored = DiscoverResult.model_validate_json(saved.model_dump_json())
|
||||
assert restored == saved
|
||||
|
||||
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client:
|
||||
async with Client(bookshop.mcp, mode="2026-07-28", prior_discover=restored) as client:
|
||||
assert client.server_info is not None
|
||||
assert client.server_info.name == "Bookshop"
|
||||
|
||||
|
|
@ -94,9 +105,9 @@ async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None:
|
|||
)
|
||||
},
|
||||
)
|
||||
async with Client(tutorial004.mcp, prior_discover=stale) as client:
|
||||
async with Client(bookshop.mcp, prior_discover=stale) as client:
|
||||
assert client.server_info is not None
|
||||
assert client.server_info.name == "Bookshop"
|
||||
async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client:
|
||||
async with Client(bookshop.mcp, mode="legacy", prior_discover=stale) as client:
|
||||
assert client.session.discover_result is None
|
||||
assert client.protocol_version == "2025-11-25"
|
||||
|
|
|
|||
Loading…
Reference in a new issue