49b9bb6724
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
159 lines
7.4 KiB
Python
159 lines
7.4 KiB
Python
"""`docs/handlers/dependencies.md`: every claim the page makes, proved against the real SDK."""
|
|
|
|
from typing import Literal
|
|
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import CreateMessageRequestParams, CreateMessageResult, ElicitRequestParams, ElicitResult, TextContent
|
|
|
|
from docs_src.dependencies import tutorial001, tutorial002, tutorial003, tutorial004
|
|
from mcp import Client
|
|
from mcp.client import ClientRequestContext
|
|
|
|
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
|
|
|
|
|
async def test_the_resolver_fills_the_parameter_from_the_tools_own_argument() -> None:
|
|
"""tutorial001: `check_stock` receives `title` by name and its return value becomes `stock`."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
in_stock = await client.call_tool("reserve_book", {"title": "Dune"})
|
|
sold_out = await client.call_tool("reserve_book", {"title": "Neuromancer"})
|
|
|
|
assert in_stock.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
|
|
assert sold_out.content == [TextContent(type="text", text="'Neuromancer' is out of stock.")]
|
|
|
|
|
|
async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
|
|
"""tutorial001: the input schema shown on the page is exactly what `tools/list` reports."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
(tool,) = (await client.list_tools()).tools
|
|
|
|
assert tool.input_schema == snapshot(
|
|
{
|
|
"type": "object",
|
|
"properties": {"title": {"title": "Title", "type": "string"}},
|
|
"required": ["title"],
|
|
"title": "reserve_bookArguments",
|
|
}
|
|
)
|
|
|
|
|
|
async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None:
|
|
"""tutorial001: the resolver's value is the only one the tool can receive."""
|
|
async with Client(tutorial001.mcp) as client:
|
|
result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}})
|
|
|
|
assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
|
|
|
|
|
|
async def test_a_resolver_can_depend_on_another_resolver() -> None:
|
|
"""tutorial002: `estimate_delivery` consumes `check_stock`'s result, and the tool gets both."""
|
|
async with Client(tutorial002.mcp) as client:
|
|
in_stock = await client.call_tool("order_book", {"title": "Dune"})
|
|
backorder = await client.call_tool("order_book", {"title": "Neuromancer"})
|
|
|
|
assert in_stock.content == [TextContent(type="text", text="Ordered 'Dune'; it arrives tomorrow.")]
|
|
assert backorder.content == [
|
|
TextContent(type="text", text="'Neuromancer' is on backorder; it would arrive in 2-3 weeks.")
|
|
]
|
|
|
|
|
|
async def test_a_shared_dependency_runs_once_per_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""tutorial002: `stock` and `delivery` both need `check_stock`; one call, one inventory lookup."""
|
|
|
|
class CountingInventory:
|
|
def __init__(self, data: dict[str, int]) -> None:
|
|
self.data = data
|
|
self.lookups: list[str] = []
|
|
|
|
def get(self, key: str, default: int) -> int:
|
|
self.lookups.append(key)
|
|
return self.data.get(key, default)
|
|
|
|
inventory = CountingInventory(dict(tutorial002.INVENTORY))
|
|
monkeypatch.setattr(tutorial002, "INVENTORY", inventory)
|
|
|
|
async with Client(tutorial002.mcp) as client:
|
|
await client.call_tool("order_book", {"title": "Dune"})
|
|
assert inventory.lookups == ["Dune"]
|
|
# Memoization is per call, not per server: the next call looks the title up again.
|
|
await client.call_tool("order_book", {"title": "Dune"})
|
|
assert inventory.lookups == ["Dune", "Dune"]
|
|
|
|
|
|
# The `!!! info` claims the tutorial003 behaviour is transport-independent, so each claim is
|
|
# proved on both: mode="legacy" elicits synchronously mid-call (2025-11-25 and earlier), while
|
|
# mode="auto" negotiates 2026-07-28, where the question rides a multi-round-trip `tools/call`
|
|
# and `Client` drives the retries.
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_an_in_stock_order_asks_no_question(mode: Literal["legacy", "auto"]) -> None:
|
|
"""tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip."""
|
|
|
|
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
|
raise AssertionError("an in-stock order must not elicit")
|
|
|
|
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=never) as client:
|
|
result = await client.call_tool("order_book", {"title": "Dune"})
|
|
|
|
assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
@pytest.mark.parametrize(
|
|
("confirm", "expected"),
|
|
[
|
|
(True, "Backordered 'Neuromancer'; it ships in 2-3 weeks."),
|
|
(False, "No order placed."),
|
|
],
|
|
)
|
|
async def test_an_out_of_stock_order_asks_and_honours_the_answer(
|
|
mode: Literal["legacy", "auto"], confirm: bool, expected: str
|
|
) -> None:
|
|
"""tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it."""
|
|
asked: list[str] = []
|
|
|
|
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
|
asked.append(params.message)
|
|
return ElicitResult(action="accept", content={"confirm": confirm})
|
|
|
|
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=on_elicit) as client:
|
|
result = await client.call_tool("order_book", {"title": "Neuromancer"})
|
|
|
|
assert result.content == [TextContent(type="text", text=expected)]
|
|
assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["legacy", "auto"]) -> None:
|
|
"""tutorial003: no answer, no order - the error text on the page is the real one."""
|
|
|
|
async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
|
return ElicitResult(action="decline")
|
|
|
|
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=decline) as client:
|
|
result = await client.call_tool("order_book", {"title": "Neuromancer"})
|
|
|
|
assert result.is_error
|
|
assert isinstance(result.content[0], TextContent)
|
|
assert result.content[0].text == (
|
|
"Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
|
async def test_a_resolver_can_sample_the_clients_llm(mode: Literal["legacy", "auto"]) -> None:
|
|
"""tutorial004: `suggest_title` runs through the client's sampling callback on both eras."""
|
|
prompts: list[str] = []
|
|
|
|
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
|
content = params.messages[0].content
|
|
assert isinstance(content, TextContent)
|
|
prompts.append(content.text)
|
|
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Dune"), model="m")
|
|
|
|
async with Client(tutorial004.mcp, mode=mode, sampling_callback=sampler) as client:
|
|
result = await client.call_tool("recommend_book", {"genre": "sci-fi"})
|
|
|
|
assert result.content == [TextContent(type="text", text="Today's sci-fi pick: Dune")]
|
|
assert prompts == ["Suggest one sci-fi book title. Answer with the title only."]
|