Files
modelcontextprotocol--pytho…/tests/docs_src/test_client.py
Max Isbey ab89da82ba Keep unexpected exception text out of tool results
A tool that crashed used to send the exception's own text to the client
as "Error executing tool <name>: <str(exc)>". That text can describe
server internals (or, for an output-schema failure, echo the tool's
return value), so a crash now reads just "Error executing tool <name>".
ToolError, ResourceError, and argument-validation messages still reach
the model unchanged, since those are the anticipated failures it can act
on. Closes the tool half of the leak that resources already avoided and
that prompts stopped doing earlier in this branch.

Related tidy-ups in the same direction:
- a crashing @mcp.completion() handler is logged once and answered with
  -32603 "Error completing argument <name>" instead of str(exc)
- the legacy resolver path reports a malformed elicitation answer as a
  ToolError, matching what the input_required path already did
- the INFO line for rejected arguments names the fields, not the values

Docs now teach ToolError as the way to talk to the model and describe a
plain exception as a crash the model sees generically; examples that
relied on ValueError text reaching the client raise ToolError instead.
2026-08-20 14:56:47 +00:00

188 lines
9.0 KiB
Python

"""`docs/client/index.md`: every claim the page makes, proved against the real SDK."""
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 mcp import Client, MCPDeprecationWarning, MCPError
from mcp.shared.metadata_utils import get_display_name
# 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_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."""
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.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."""
client = Client(tutorial001.mcp)
async with client:
assert client.server_info is not None
assert client.server_info.name == "Bookshop"
with pytest.raises(RuntimeError, match="cannot reenter"):
await client.__aenter__()
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(
{
"type": "object",
"properties": {
"query": {"title": "Query", "type": "string"},
"limit": {"default": 10, "title": "Limit", "type": "integer"},
},
"required": ["query"],
"title": "search_booksArguments",
}
)
def test_get_display_name_prefers_the_title() -> None:
"""The `!!! tip`: get_display_name returns the title when there is one and the name when there isn't."""
titled = Tool(name="search_books", title="Search the catalog", input_schema={"type": "object"})
untitled = Tool(name="search_books", input_schema={"type": "object"})
assert get_display_name(titled) == "Search the catalog"
assert get_display_name(untitled) == "search_books"
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:
result = await client.call_tool("lookup_book", {"title": "Dune"})
assert not result.is_error
(block,) = result.content
assert isinstance(block, TextContent)
assert block.text == '{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}'
assert result.structured_content == {"title": "Dune", "author": "Frank Herbert", "year": 1965}
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:
result = await client.call_tool("lookup_book", {"title": "Solaris"})
assert result.is_error
(block,) = result.content
assert isinstance(block, TextContent)
assert block.text == "Error executing tool lookup_book: No book titled 'Solaris' in the catalog."
assert result.structured_content is 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:
result = await client.call_tool("does_not_exist", {})
assert result.is_error
(block,) = result.content
assert isinstance(block, TextContent)
assert block.text == "Unknown tool: does_not_exist"
assert result.structured_content is 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:
(resource,) = (await client.list_resources()).resources
assert resource.uri == "catalog://genres"
(template,) = (await client.list_resource_templates()).resource_templates
assert template.uri_template == "catalog://genres/{genre}"
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:
(contents,) = (await client.read_resource("catalog://genres/poetry")).contents
assert isinstance(contents, TextResourceContents)
assert contents.text == "3 books filed under poetry."
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:
assert client.server_capabilities.resources is not None
assert client.server_capabilities.resources.subscribe is True
with pytest.raises(MCPError) as exc_info:
# The verb is itself deprecated; the modern wire also rejects it.
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
await client.subscribe_resource("catalog://genres") # pyright: ignore[reportDeprecated]
assert exc_info.value.error.code == -32601
assert exc_info.value.error.message == "Method not found"
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:
(prompt,) = (await client.list_prompts()).prompts
assert prompt == snapshot(
Prompt(
name="recommend",
title="Recommend a book",
description="Ask for a recommendation in a genre.",
arguments=[PromptArgument(name="genre", required=True)],
)
)
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:
result = await client.get_prompt("recommend", {"genre": "poetry"})
(message,) = result.messages
assert message.role == "user"
assert message.content == TextContent(
type="text", text="Recommend one poetry book from the catalog and say why."
)
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:
result = await client.complete(
ref=PromptReference(type="ref/prompt", name="recommend"),
argument={"name": "genre", "value": "p"},
)
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_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'."}