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) Has been cancelled
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) Has been cancelled
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"""`docs/handlers/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_REQUEST,
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestParams,
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
InputRequiredResult,
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import InvalidRequestState
|
||||
|
||||
# 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_first_call_returns_an_input_required_result() -> None:
|
||||
"""tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True)
|
||||
assert result == snapshot(
|
||||
InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"region": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=ElicitRequestFormParams(
|
||||
mode="form",
|
||||
message="Which region should the database live in?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"region": {"type": "string"}},
|
||||
"required": ["region"],
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
request_state="provision-v1",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_the_auto_loop_drives_the_call_to_completion() -> None:
|
||||
"""tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back."""
|
||||
async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client:
|
||||
result = await client.call_tool("provision", {"name": "orders"})
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
|
||||
)
|
||||
|
||||
|
||||
async def test_the_auto_loop_without_a_callback_raises_mcp_error() -> None:
|
||||
"""The page's `!!! check`: no `elicitation_callback` means the SDK's stand-in answers with an error."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.call_tool("provision", {"name": "orders"})
|
||||
assert exc.value.error.code == INVALID_REQUEST
|
||||
assert exc.value.error.message == "Elicitation not supported"
|
||||
|
||||
|
||||
async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None:
|
||||
"""tutorial001: the retry carries `input_responses` keyed like `input_requests` plus the echoed token."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool(
|
||||
"provision",
|
||||
{"name": "orders"},
|
||||
input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})},
|
||||
request_state="provision-v1",
|
||||
)
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
|
||||
)
|
||||
|
||||
|
||||
async def test_the_manual_loop_drives_the_call_to_completion() -> None:
|
||||
"""tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await tutorial002.provision(client, "billing")
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")])
|
||||
)
|
||||
|
||||
|
||||
async def test_the_in_memory_client_negotiates_2026_07_28() -> None:
|
||||
"""`InputRequiredResult` only exists at 2026-07-28; `Client(server)` lands there without being asked."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
|
||||
|
||||
async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None:
|
||||
"""The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`."""
|
||||
async with Client(tutorial001.server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.call_tool("provision", {"name": "orders"})
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert exc.value.error.message == "Handler returned an invalid result"
|
||||
|
||||
|
||||
def test_fulfil_refuses_a_request_it_cannot_answer() -> None:
|
||||
"""tutorial002: `fulfil` is the dispatch point. This client only knows how to answer an `ElicitRequest`."""
|
||||
request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64))
|
||||
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
|
||||
tutorial002.fulfil(request)
|
||||
|
||||
|
||||
async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> None:
|
||||
"""tutorial004: `prompts/get` participates in the same flow — the `@mcp.prompt()` function
|
||||
returns the `InputRequiredResult` itself."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
result = await client.session.get_prompt("briefing", allow_input_required=True)
|
||||
assert result == snapshot(
|
||||
InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"audience": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=ElicitRequestFormParams(
|
||||
mode="form",
|
||||
message="Who is the briefing for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"audience": {"type": "string"}},
|
||||
"required": ["audience"],
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _answer_audience(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="accept", content={"audience": "the board"})
|
||||
|
||||
|
||||
async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
|
||||
"""tutorial004 + the page's client-side claim: `get_prompt` drives the same loop, so the
|
||||
caller sees only the complete `GetPromptResult`."""
|
||||
async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client:
|
||||
result = await client.get_prompt("briefing")
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
description="Draft a briefing tuned to its audience.",
|
||||
messages=[
|
||||
PromptMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text="Write a briefing for the board."),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_a_custom_codec_round_trips_what_it_sealed() -> None:
|
||||
"""tutorial005: `unseal(seal(payload))` returns the payload; the token itself is opaque hex."""
|
||||
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
|
||||
token = codec.seal(b"round-1")
|
||||
assert token.startswith(tutorial005.PREFIX)
|
||||
assert b"round-1" not in token.encode()
|
||||
assert codec.unseal(token) == b"round-1"
|
||||
|
||||
|
||||
def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None:
|
||||
"""tutorial005: any token the codec did not mint intact raises `InvalidRequestState`."""
|
||||
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
|
||||
token = codec.seal(b"round-1")
|
||||
with pytest.raises(InvalidRequestState):
|
||||
codec.unseal(token + "00")
|
||||
with pytest.raises(InvalidRequestState):
|
||||
codec.unseal("not-a-token")
|
||||
|
||||
|
||||
def test_a_custom_codec_rejects_every_alias_of_a_minted_token() -> None:
|
||||
"""tutorial005: only the exact minted string verifies; rewritten spellings of it do not."""
|
||||
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
|
||||
token = codec.seal(b"round-1")
|
||||
body = token.removeprefix(tutorial005.PREFIX)
|
||||
for alias in (
|
||||
body, # prefix stripped
|
||||
tutorial005.PREFIX + body.upper(), # non-canonical hex case
|
||||
tutorial005.PREFIX + body[:8] + " " + body[8:], # whitespace bytes.fromhex would skip
|
||||
):
|
||||
with pytest.raises(InvalidRequestState):
|
||||
codec.unseal(alias)
|
||||
Reference in New Issue
Block a user