"""Test the elicitation feature over the in-memory client transport.""" from typing import Any, Literal import mcp_types as types import pytest from mcp_types import ElicitRequestParams, ElicitResult, TextContent from pydantic import BaseModel, Field from mcp import Client from mcp.client import ClientRequestContext from mcp.client.session import ElicitationFnT from mcp.server.mcpserver import Context, MCPServer # Shared schema for basic tests class AnswerSchema(BaseModel): answer: str = Field(description="The user's answer to the question") def create_ask_user_tool(mcp: MCPServer): """Create a standard ask_user tool that handles all elicitation responses.""" @mcp.tool(description="A tool that uses elicitation") async def ask_user(prompt: str, ctx: Context) -> str: result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema) if result.action == "accept" and result.data: return f"User answered: {result.data.answer}" elif result.action == "decline": return "User declined to answer" else: # pragma: no cover return "User cancelled" return ask_user async def call_tool_and_assert( mcp: MCPServer, elicitation_callback: ElicitationFnT, tool_name: str, args: dict[str, Any], expected_text: str | None = None, text_contains: list[str] | None = None, ): """Helper to create session, call tool, and assert result.""" async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client: result = await client.call_tool(tool_name, args) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) if expected_text is not None: assert result.content[0].text == expected_text elif text_contains is not None: # pragma: no branch for substring in text_contains: assert substring in result.content[0].text return result @pytest.mark.anyio async def test_elicitation_accept_returns_the_users_answer_to_the_tool(): """An accepted elicitation delivers the user's content back to the requesting tool.""" mcp = MCPServer(name="ElicitationServer") create_ask_user_tool(mcp) # Create a custom handler for elicitation requests async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): if params.message == "Tool wants to ask: What is your name?": return ElicitResult(action="accept", content={"answer": "Test User"}) else: # pragma: no cover raise ValueError(f"Unexpected elicitation message: {params.message}") await call_tool_and_assert( mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User answered: Test User" ) @pytest.mark.anyio async def test_elicitation_decline_reaches_the_tool_without_content(): """A declined elicitation reports the decline to the tool, with no content attached.""" mcp = MCPServer(name="ElicitationDeclineServer") create_ask_user_tool(mcp) async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): return ElicitResult(action="decline") await call_tool_and_assert( mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User declined to answer" ) @pytest.mark.anyio async def test_elicitation_schema_validation(): """Test that elicitation schemas must only contain primitive types.""" mcp = MCPServer(name="ValidationTestServer") def create_validation_tool(name: str, schema_class: type[BaseModel]): @mcp.tool(name=name, description=f"Tool testing {name}") async def tool(ctx: Context) -> str: try: await ctx.elicit(message="This should fail validation", schema=schema_class) return "Should not reach here" # pragma: no cover except TypeError as e: return f"Validation failed as expected: {str(e)}" return tool # Test cases for invalid schemas class InvalidListSchema(BaseModel): numbers: list[int] = Field(description="List of numbers") class NestedModel(BaseModel): value: str class InvalidNestedSchema(BaseModel): nested: NestedModel = Field(description="Nested model") create_validation_tool("invalid_list", InvalidListSchema) create_validation_tool("nested_model", InvalidNestedSchema) # Dummy callback (won't be called due to validation failure) async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover return ElicitResult(action="accept", content={}) async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client: # Test both invalid schemas for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]: result = await client.call_tool(tool_name, {}) assert len(result.content) == 1 assert isinstance(result.content[0], TextContent) assert "Validation failed as expected" in result.content[0].text assert field_name in result.content[0].text @pytest.mark.anyio async def test_elicitation_with_optional_fields(): """Test that Optional fields work correctly in elicitation schemas.""" mcp = MCPServer(name="OptionalFieldServer") class OptionalSchema(BaseModel): required_name: str = Field(description="Your name (required)") optional_age: int | None = Field(default=None, description="Your age (optional)") optional_email: str | None = Field(default=None, description="Your email (optional)") subscribe: bool | None = Field(default=False, description="Subscribe to newsletter?") @mcp.tool(description="Tool with optional fields") async def optional_tool(ctx: Context) -> str: result = await ctx.elicit(message="Please provide your information", schema=OptionalSchema) if result.action == "accept" and result.data: info = [f"Name: {result.data.required_name}"] if result.data.optional_age is not None: info.append(f"Age: {result.data.optional_age}") if result.data.optional_email is not None: info.append(f"Email: {result.data.optional_email}") info.append(f"Subscribe: {result.data.subscribe}") return ", ".join(info) else: # pragma: no cover return f"User {result.action}" # Test cases with different field combinations test_cases: list[tuple[dict[str, Any], str]] = [ ( # All fields provided {"required_name": "John Doe", "optional_age": 30, "optional_email": "john@example.com", "subscribe": True}, "Name: John Doe, Age: 30, Email: john@example.com, Subscribe: True", ), ( # Only required fields {"required_name": "Jane Smith"}, "Name: Jane Smith, Subscribe: False", ), ] for content, expected in test_cases: async def callback(context: ClientRequestContext, params: ElicitRequestParams): assert isinstance(params, types.ElicitRequestFormParams) # Optional fields render as the bare primitive (no anyOf), absent from `required`. assert params.requested_schema["properties"]["optional_age"] == { "type": "integer", "title": "Optional Age", "description": "Your age (optional)", } assert params.requested_schema["required"] == ["required_name"] return ElicitResult(action="accept", content=content) await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected) # Test invalid optional field class InvalidOptionalSchema(BaseModel): name: str = Field(description="Name") optional_list: list[int] | None = Field(default=None, description="Invalid optional list") @mcp.tool(description="Tool with invalid optional field") async def invalid_optional_tool(ctx: Context) -> str: try: await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema) return "Should not reach here" # pragma: no cover except TypeError as e: return f"Validation failed: {str(e)}" async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams): # pragma: no cover return ElicitResult(action="accept", content={}) await call_tool_and_assert( mcp, elicitation_callback, "invalid_optional_tool", {}, text_contains=["Validation failed:", "optional_list"], ) # Bare `list[str]` renders without enum items and so is not a spec MultiSelectEnumSchema. class BareListSchema(BaseModel): name: str = Field(description="Name") tags: list[str] = Field(description="Tags") def make_reject_tool(tool_name: str, schema_cls: type[BaseModel]) -> None: @mcp.tool(name=tool_name, description="Tool with a rejected field") async def _tool(ctx: Context) -> str: try: await ctx.elicit(message="Provide value", schema=schema_cls) except TypeError as e: return f"Validation failed: {str(e)}" raise NotImplementedError make_reject_tool("bare_list_tool", BareListSchema) await call_tool_and_assert( mcp, elicitation_callback, "bare_list_tool", {}, text_contains=["Validation failed:", "tags"] ) # A union of two primitives renders as `anyOf`, outside `PrimitiveSchemaDefinition`. class MultiPrimitiveSchema(BaseModel): value: int | str = Field(description="Value") make_reject_tool("multi_primitive_tool", MultiPrimitiveSchema) await call_tool_and_assert( mcp, elicitation_callback, "multi_primitive_tool", {}, text_contains=["Validation failed:", "value"] ) @pytest.mark.anyio async def test_elicitation_with_default_values(): """Test that default values work correctly in elicitation schemas and are included in JSON.""" mcp = MCPServer(name="DefaultValuesServer") class DefaultsSchema(BaseModel): name: str = Field(default="Guest", description="User name") age: int = Field(default=18, description="User age") subscribe: bool = Field(default=True, description="Subscribe to newsletter") email: str = Field(description="Email address (required)") @mcp.tool(description="Tool with default values") async def defaults_tool(ctx: Context) -> str: result = await ctx.elicit(message="Please provide your information", schema=DefaultsSchema) if result.action == "accept" and result.data: return ( f"Name: {result.data.name}, Age: {result.data.age}, " f"Subscribe: {result.data.subscribe}, Email: {result.data.email}" ) else: # pragma: no cover return f"User {result.action}" # First verify that defaults are present in the JSON schema sent to clients async def callback_schema_verify(context: ClientRequestContext, params: ElicitRequestParams): # Verify the schema includes defaults assert isinstance(params, types.ElicitRequestFormParams), "Expected form mode elicitation" schema = params.requested_schema props = schema["properties"] assert props["name"]["default"] == "Guest" assert props["age"]["default"] == 18 assert props["subscribe"]["default"] is True assert "default" not in props["email"] # Required field has no default return ElicitResult(action="accept", content={"email": "test@example.com"}) await call_tool_and_assert( mcp, callback_schema_verify, "defaults_tool", {}, "Name: Guest, Age: 18, Subscribe: True, Email: test@example.com", ) # Test overriding defaults async def callback_override(context: ClientRequestContext, params: ElicitRequestParams): return ElicitResult( action="accept", content={"email": "john@example.com", "name": "John", "age": 25, "subscribe": False} ) await call_tool_and_assert( mcp, callback_override, "defaults_tool", {}, "Name: John, Age: 25, Subscribe: False, Email: john@example.com" ) @pytest.mark.anyio async def test_elicitation_with_enum_titles(): """Test elicitation with enum schemas using oneOf/anyOf for titles.""" mcp = MCPServer(name="ColorPreferencesApp") # Test single-select with titles using oneOf class FavoriteColorSchema(BaseModel): user_name: str = Field(description="Your name") favorite_color: str = Field( description="Select your favorite color", json_schema_extra={ "oneOf": [ {"const": "red", "title": "Red"}, {"const": "green", "title": "Green"}, {"const": "blue", "title": "Blue"}, {"const": "yellow", "title": "Yellow"}, ] }, ) @mcp.tool(description="Single color selection") async def select_favorite_color(ctx: Context) -> str: result = await ctx.elicit(message="Select your favorite color", schema=FavoriteColorSchema) if result.action == "accept" and result.data: return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}" return f"User {result.action}" # pragma: no cover # Test legacy enumNames format class LegacyColorSchema(BaseModel): user_name: str = Field(description="Your name") color: str = Field( description="Select a color", json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]}, ) @mcp.tool(description="Legacy enum format") async def select_color_legacy(ctx: Context) -> str: result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema) if result.action == "accept" and result.data: return f"User: {result.data.user_name}, Color: {result.data.color}" return f"User {result.action}" # pragma: no cover # Test multi-select with titles using items.anyOf class FavoriteColorsSchema(BaseModel): user_name: str = Field(description="Your name") favorite_colors: list[str] = Field( description="Select your favorite colors", json_schema_extra={ "items": { "anyOf": [ {"const": "red", "title": "Red"}, {"const": "green", "title": "Green"}, {"const": "blue", "title": "Blue"}, {"const": "yellow", "title": "Yellow"}, ] } }, ) @mcp.tool(description="Multiple color selection") async def select_favorite_colors(ctx: Context) -> str: result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema) if result.action == "accept" and result.data: return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}" raise NotImplementedError async def enum_callback(context: ClientRequestContext, params: ElicitRequestParams): if "colors" in params.message: return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]}) if "legacy" in params.message: return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"}) return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"}) # Test single-select with titles await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue") # Test multi-select with titles await call_tool_and_assert(mcp, enum_callback, "select_favorite_colors", {}, "User: Bob, Colors: red, green") # Test legacy enumNames format await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green") @pytest.mark.anyio async def test_elicitation_literal_field_renders_as_a_spec_enum_schema(): """`Literal[...]` and `list[Literal[...]]` render as the spec's enum schemas and pass the gate.""" mcp = MCPServer(name="LiteralServer") class LiteralSchema(BaseModel): size: Literal["s", "m", "l"] = Field(description="Size") extras: list[Literal["a", "b"]] = Field(description="Extras") @mcp.tool(description="Literal selection") async def pick(ctx: Context) -> str: result = await ctx.elicit(message="Pick", schema=LiteralSchema) if result.action == "accept" and result.data: return f"{result.data.size}:{','.join(result.data.extras)}" raise NotImplementedError async def callback(context: ClientRequestContext, params: ElicitRequestParams): assert isinstance(params, types.ElicitRequestFormParams) assert params.requested_schema["properties"]["size"]["enum"] == ["s", "m", "l"] assert params.requested_schema["properties"]["extras"]["items"]["enum"] == ["a", "b"] return ElicitResult(action="accept", content={"size": "m", "extras": ["a"]}) await call_tool_and_assert(mcp, callback, "pick", {}, "m:a")