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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
"""`docs/handlers/elicitation.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 (
ElicitCompleteNotification,
ElicitRequestFormParams,
ElicitRequestParams,
ElicitRequestURLParams,
ElicitResult,
TextContent,
)
from pydantic import BaseModel
from docs_src.elicitation import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
# 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_an_accepted_answer_resumes_the_tool() -> None:
"""tutorial001: the user's answer comes back into the same call as a validated model."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-26"})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert not result.is_error
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-26.")]
async def test_an_alternative_that_is_also_full_is_asked_about_again() -> None:
"""tutorial001: the accepted date goes back through `book_table`, so a full date is re-asked, not booked."""
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params.message)
date = "2025-12-25" if len(asked) == 1 else "2025-12-27"
return ElicitResult(action="accept", content={"accept_alternative": True, "date": date})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
assert asked == [
"No tables for 2 on 2025-12-25. Would you like to try another date?",
"No tables for 2 on 2025-12-25. Would you like to try another date?",
]
async def test_the_client_receives_the_message_and_the_generated_schema() -> None:
"""tutorial001: form mode sends your message plus a JSON Schema built from the Pydantic model."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"accept_alternative": False})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.message == "No tables for 2 on 2025-12-25. Would you like to try another date?"
assert params.requested_schema == snapshot(
{
"properties": {
"accept_alternative": {
"description": "Try another date?",
"title": "Accept Alternative",
"type": "boolean",
},
"date": {
"default": "2025-12-26",
"description": "Alternative date (YYYY-MM-DD)",
"title": "Date",
"type": "string",
},
},
"required": ["accept_alternative"],
"title": "AlternativeDate",
"type": "object",
}
)
async def test_decline_and_cancel_are_ordinary_return_values() -> None:
"""tutorial001: a refusal is not an error; the tool sees the action and answers the model normally."""
async def on_decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async def on_cancel(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="cancel")
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_decline) as client:
declined = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_cancel) as client:
cancelled = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert declined.content == [TextContent(type="text", text="No booking made.")]
assert not declined.is_error
assert cancelled.content == [TextContent(type="text", text="No booking made.")]
async def test_a_tool_that_does_not_ask_needs_nothing_from_the_client() -> None:
"""tutorial001: the elicitation only happens on the path that needs it."""
async with Client(tutorial001.mcp, mode="legacy") as client:
result = await client.call_tool("book_table", {"date": "2025-12-30", "party_size": 4})
assert result.content == [TextContent(type="text", text="Booked a table for 4 on 2025-12-30.")]
async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_code() -> None:
"""`!!! tip`: the client's content is validated against the model; a mismatch fails the call."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="accept", content={"accept_alternative": "maybe"})
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert "does not match the requested schema" in result.content[0].text
class Address(BaseModel):
city: str
class Applicant(BaseModel):
name: str
address: Address
class Seating(BaseModel):
area: Literal["inside", "terrace"]
schema_gate_server = MCPServer("Bistro")
"""The `!!! warning` claims: what the elicitation schema gate accepts and rejects."""
@schema_gate_server.tool()
async def sign_up(ctx: Context) -> str:
"""Collect the new customer's details."""
return str(await ctx.elicit(message="Who are you?", schema=Applicant))
@schema_gate_server.tool()
async def choose_seating(ctx: Context) -> str:
"""Ask where the party wants to sit."""
result = await ctx.elicit(message="Where would you like to sit?", schema=Seating)
assert result.action == "accept"
return result.data.area
async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None:
"""`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message."""
async with Client(schema_gate_server, mode="legacy") as client:
result = await client.call_tool("sign_up", {})
assert result.is_error
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == (
"Error executing tool sign_up: Elicitation schema field 'address' rendered as "
"{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition"
)
async def test_a_literal_field_passes_the_gate_as_an_enum() -> None:
"""`!!! warning`: a `Literal[...]` of strings renders as a JSON Schema `enum`, which the spec allows."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept", content={"area": "terrace"})
async with Client(schema_gate_server, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("choose_seating", {})
assert result.content == [TextContent(type="text", text="terrace")]
(params,) = received
assert isinstance(params, ElicitRequestFormParams)
assert params.requested_schema["properties"]["area"] == snapshot(
{"enum": ["inside", "terrace"], "title": "Area", "type": "string"}
)
async def test_url_mode_sends_a_url_and_gets_consent_back_not_data() -> None:
"""tutorial002: the client receives the URL and the elicitation id; only the action comes back."""
received: list[ElicitRequestParams] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
received.append(params)
return ElicitResult(action="accept")
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="Complete the payment in your browser.")]
(params,) = received
assert isinstance(params, ElicitRequestURLParams)
assert params.url == "https://pay.example.com/deposit/b42"
assert params.elicitation_id == "deposit-b42"
async def test_a_declined_url_elicitation_is_an_ordinary_return_value() -> None:
"""tutorial002: the tool decides what a refusal means."""
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action="decline")
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="No deposit taken. The booking expires in one hour.")]
async def test_send_elicit_complete_notifies_the_client_with_the_same_id() -> None:
"""tutorial002: `send_elicit_complete` emits `notifications/elicitation/complete`."""
notifications: list[object] = []
async def on_message(message: object) -> None:
notifications.append(message)
async with Client(tutorial002.mcp, mode="legacy", message_handler=on_message) as client:
result = await client.call_tool("confirm_deposit", {"booking_id": "b42"})
assert result.content == [TextContent(type="text", text="Deposit received for booking b42.")]
(notification,) = notifications
assert isinstance(notification, ElicitCompleteNotification)
assert notification.params.elicitation_id == "deposit-b42"
async def test_the_docs_client_callback_handles_both_modes() -> None:
"""tutorial003: one `elicitation_callback` answers the form and the URL consent."""
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
booked = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
paid = await client.call_tool("pay_deposit", {"booking_id": "b42"})
assert booked.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
assert paid.content == [TextContent(type="text", text="Complete the payment in your browser.")]
async def test_a_client_without_the_callback_cannot_be_asked() -> None:
"""`!!! check`: no `elicitation_callback` means no `elicitation` capability; the call is a protocol error."""
async with Client(tutorial001.mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Elicitation not supported"):
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
async def test_resolver_asks_only_when_the_folder_is_not_empty() -> None:
"""tutorial004: `confirm_delete` resolves an empty folder directly and elicits otherwise."""
tutorial004._FOLDERS.update({"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]})
asked: list[str] = []
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
assert isinstance(params, ElicitRequestFormParams)
asked.append(params.message)
return ElicitResult(action="accept", content={"ok": True})
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
empty = await client.call_tool("delete_folder", {"path": "/tmp/empty"})
non_empty = await client.call_tool("delete_folder", {"path": "/tmp/project"})
assert empty.content == [TextContent(type="text", text="deleted /tmp/empty")]
assert non_empty.content == [TextContent(type="text", text="deleted /tmp/project")]
assert asked == ["/tmp/project has 2 file(s). Delete anyway?"] # the empty folder was not queried
async def test_the_resolved_parameter_is_hidden_from_the_tool_schema() -> None:
"""tutorial004: the `Resolve`-filled parameter never appears in the client-facing input schema."""
async with Client(tutorial004.mcp, mode="legacy") as client:
(tool,) = (await client.list_tools()).tools
assert tool.name == "delete_folder"
assert set(tool.input_schema["properties"]) == {"path"}
@pytest.mark.parametrize(
("action", "content", "expected"),
[
("accept", {"ok": False}, "kept the folder"),
("decline", None, "declined: folder not deleted"),
("cancel", None, "cancelled: folder not deleted"),
],
)
async def test_the_tool_branches_on_every_elicitation_outcome(
action: Literal["accept", "decline", "cancel"],
content: dict[str, str | int | float | bool | list[str] | None] | None,
expected: str,
) -> None:
"""tutorial004: annotating the result union lets the tool handle accept/decline/cancel."""
tutorial004._FOLDERS["/tmp/project"] = ["main.py", "README.md"]
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
return ElicitResult(action=action, content=content)
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
result = await client.call_tool("delete_folder", {"path": "/tmp/project"})
assert result.content == [TextContent(type="text", text=expected)]