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
300 lines
14 KiB
Python
300 lines
14 KiB
Python
"""`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)]
|