"""`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)]