fastmcp/tests/client/test_elicitation.py
2025-09-07 11:11:44 -04:00

731 lines
25 KiB
Python

from dataclasses import asdict, dataclass
from enum import Enum
from typing import Literal
import pytest
from mcp.types import ElicitRequestParams
from pydantic import BaseModel
from typing_extensions import TypedDict
from fastmcp import Context, FastMCP
from fastmcp.client.client import Client
from fastmcp.client.elicitation import ElicitResult
from fastmcp.exceptions import ToolError
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
get_elicitation_schema,
validate_elicitation_json_schema,
)
from fastmcp.utilities.types import TypeAdapter
@pytest.fixture
def fastmcp_server():
mcp = FastMCP("TestServer")
@dataclass
class Person:
name: str
@mcp.tool
async def ask_for_name(context: Context) -> str:
result = await context.elicit(
message="What is your name?",
response_type=Person,
)
if result.action == "accept":
return f"Hello, {result.data.name}!" # type: ignore[attr-defined]
else:
return "No name provided."
@mcp.tool
def simple_test() -> str:
return "Hello!"
return mcp
async def test_elicitation_with_no_handler(fastmcp_server):
"""Test that elicitation works without a handler."""
async with Client(fastmcp_server) as client:
with pytest.raises(ToolError, match="Elicitation not supported"):
await client.call_tool("ask_for_name")
async def test_elicitation_accept_content(fastmcp_server):
"""Test basic elicitation functionality."""
async def elicitation_handler(message, response_type, params, ctx):
# Mock user providing their name
return ElicitResult(action="accept", content=response_type(name="Alice"))
async with Client(
fastmcp_server, elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "Hello, Alice!"
async def test_elicitation_decline(fastmcp_server):
"""Test that elicitation handler receives correct parameters."""
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
async with Client(
fastmcp_server, elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "No name provided."
async def test_elicitation_handler_parameters():
"""Test that elicitation handler receives correct parameters."""
mcp = FastMCP("TestServer")
captured_params = {}
@mcp.tool
async def test_tool(context: Context) -> str:
await context.elicit(
message="Test message",
response_type=int,
)
return "done"
async def elicitation_handler(message, response_type, params, ctx):
captured_params["message"] = message
captured_params["response_type"] = str(response_type)
captured_params["params"] = params
captured_params["ctx"] = ctx
return ElicitResult(action="accept", content={"value": 42})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
await client.call_tool("test_tool", {})
assert captured_params["message"] == "Test message"
assert "ScalarElicitationType" in str(captured_params["response_type"])
assert captured_params["params"].requestedSchema == {
"properties": {"value": {"title": "Value", "type": "integer"}},
"required": ["value"],
"title": "ScalarElicitationType",
"type": "object",
}
assert captured_params["ctx"] is not None
async def test_elicitation_cancel_action():
"""Test user canceling elicitation request."""
mcp = FastMCP("TestServer")
@mcp.tool
async def ask_for_optional_info(context: Context) -> str:
result = await context.elicit(
message="Optional: What's your age?", response_type=int
)
if result.action == "cancel":
return "Request was canceled"
elif result.action == "accept":
return f"Age: {result.data}" # type: ignore[attr-defined]
else:
return "No response provided"
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("ask_for_optional_info", {})
assert result.data == "Request was canceled"
class TestScalarResponseTypes:
async def test_elicitation_no_response(self):
"""Test elicitation with no response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> None:
result = await context.elicit(message="", response_type=None)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(
message, response_type, params: ElicitRequestParams, ctx
):
assert params.requestedSchema == {"type": "object", "properties": {}}
assert response_type is None
return ElicitResult(action="accept")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data is None
async def test_elicitation_empty_response(self):
"""Test elicitation with empty response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> None:
result = await context.elicit(message="", response_type=None)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(
message, response_type, params: ElicitRequestParams, ctx
):
return ElicitResult(action="accept", content={})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data is None
async def test_elicitation_response_when_no_response_requested(self):
"""Test elicitation with no response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> None:
result = await context.elicit(message="", response_type=None)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "hello"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
with pytest.raises(
ToolError, match="Elicitation expected an empty response"
):
await client.call_tool("my_tool", {})
async def test_elicitation_str_response(self):
"""Test elicitation with string schema."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> str:
result = await context.elicit(message="", response_type=str)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "hello"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "hello"
async def test_elicitation_int_response(self):
"""Test elicitation with number schema."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> int:
result = await context.elicit(message="", response_type=int)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": 42})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data == 42
async def test_elicitation_float_response(self):
"""Test elicitation with number schema."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> float:
result = await context.elicit(message="", response_type=float)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": 3.14})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data == 3.14
async def test_elicitation_bool_response(self):
"""Test elicitation with boolean schema."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> bool:
result = await context.elicit(message="", response_type=bool)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": True})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data is True
async def test_elicitation_literal_response(self):
"""Test elicitation with literal schema."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> Literal["x", "y"]:
result = await context.elicit(message="", response_type=Literal["x", "y"]) # type: ignore
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "x"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
async def test_elicitation_enum_response(self):
"""Test elicitation with enum schema."""
mcp = FastMCP("TestServer")
class ResponseEnum(Enum):
X = "x"
Y = "y"
@mcp.tool
async def my_tool(context: Context) -> ResponseEnum:
result = await context.elicit(message="", response_type=ResponseEnum)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "x"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
async def test_elicitation_list_of_strings_response(self):
"""Test elicitation with list schema."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> str:
result = await context.elicit(message="", response_type=["x", "y"])
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "x"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
async def test_elicitation_handler_error():
"""Test error handling in elicitation handler."""
mcp = FastMCP("TestServer")
@mcp.tool
async def failing_elicit(context: Context) -> str:
try:
result = await context.elicit(message="This will fail", response_type=str)
assert isinstance(result, AcceptedElicitation)
assert result.action == "accept"
return f"Got: {result.data}"
except Exception as e:
return f"Error: {str(e)}"
async def elicitation_handler(message, response_type, params, ctx):
raise ValueError("Handler failed!")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("failing_elicit", {})
assert "Error:" in result.data
async def test_elicitation_multiple_calls():
"""Test multiple elicitation calls in sequence."""
mcp = FastMCP("TestServer")
@mcp.tool
async def multi_step_form(context: Context) -> str:
# First question
name_result = await context.elicit(
message="What's your name?", response_type=str
)
assert isinstance(name_result, AcceptedElicitation)
if name_result.action != "accept":
return "Form abandoned"
# Second question
age_result = await context.elicit(message="What's your age?", response_type=int)
assert isinstance(age_result, AcceptedElicitation)
if age_result.action != "accept":
return f"Hello {name_result.data}, form incomplete"
return f"Hello {name_result.data}, you are {age_result.data} years old"
call_count = 0
async def elicitation_handler(message, response_type, params, ctx):
nonlocal call_count
call_count += 1
if call_count == 1:
return ElicitResult(action="accept", content={"value": "Bob"})
elif call_count == 2:
return ElicitResult(action="accept", content={"value": 25})
else:
raise ValueError("Unexpected call")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("multi_step_form", {})
assert result.data == "Hello Bob, you are 25 years old"
assert call_count == 2
@dataclass
class UserInfo:
name: str
age: int
class UserInfoTypedDict(TypedDict):
name: str
age: int
class UserInfoPydantic(BaseModel):
name: str
age: int
@pytest.mark.parametrize(
"structured_type", [UserInfo, UserInfoTypedDict, UserInfoPydantic]
)
async def test_structured_response_type(
structured_type: type[UserInfo | UserInfoTypedDict | UserInfoPydantic],
):
"""Test elicitation with dataclass response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def get_user_info(context: Context) -> str:
result = await context.elicit(
message="Please provide your information", response_type=structured_type
)
assert isinstance(result, AcceptedElicitation)
if result.action == "accept":
if isinstance(result.data, dict):
return f"User: {result.data['name']}, age: {result.data['age']}"
else:
return f"User: {result.data.name}, age: {result.data.age}"
return "No user info provided"
async def elicitation_handler(message, response_type, params, ctx):
# Verify we get the dataclass type
assert (
TypeAdapter(response_type).json_schema()
== TypeAdapter(structured_type).json_schema()
)
# Verify the schema has the dataclass fields (available in params)
schema = params.requestedSchema
assert schema["type"] == "object"
assert "name" in schema["properties"]
assert "age" in schema["properties"]
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["age"]["type"] == "integer"
return ElicitResult(action="accept", content=UserInfo(name="Alice", age=30))
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("get_user_info", {})
assert result.data == "User: Alice, age: 30"
async def test_all_primitive_field_types():
class DataEnum(Enum):
X = "x"
Y = "y"
@dataclass
class Data:
integer: int
float_: float
number: int | float
boolean: bool
string: str
constant: Literal["x"]
union: Literal["x"] | Literal["y"]
choice: Literal["x", "y"]
enum: DataEnum
mcp = FastMCP("TestServer")
@mcp.tool
async def get_data(context: Context) -> Data:
result = await context.elicit(message="Enter data", response_type=Data)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(
action="accept",
content=Data(
integer=1,
float_=1.0,
number=1.0,
boolean=True,
string="hello",
constant="x",
union="x",
choice="x",
enum=DataEnum.X,
),
)
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("get_data", {})
# Now all literal/enum fields should be preserved as strings
result_data = asdict(result.data)
result_data_enum = result_data.pop("enum")
assert result_data_enum == "x" # Should be a string now, not an enum
assert result_data == {
"integer": 1,
"float_": 1.0,
"number": 1.0,
"boolean": True,
"string": "hello",
"constant": "x",
"union": "x",
"choice": "x",
}
class TestValidation:
async def test_schema_validation_rejects_non_object(self):
"""Test that non-object schemas are rejected."""
with pytest.raises(TypeError, match="must be an object schema"):
validate_elicitation_json_schema({"type": "string"})
async def test_schema_validation_rejects_nested_objects(self):
"""Test that nested object schemas are rejected."""
with pytest.raises(
TypeError, match="has type 'object' which is not a primitive type"
):
validate_elicitation_json_schema(
{
"type": "object",
"properties": {
"user": {
"type": "object",
"properties": {"name": {"type": "string"}},
}
},
}
)
async def test_schema_validation_rejects_arrays(self):
"""Test that array schemas are rejected."""
with pytest.raises(
TypeError, match="has type 'array' which is not a primitive type"
):
validate_elicitation_json_schema(
{
"type": "object",
"properties": {
"users": {"type": "array", "items": {"type": "string"}}
},
}
)
class TestPatternMatching:
async def test_pattern_matching_accept(self):
"""Test pattern matching with AcceptedElicitation."""
mcp = FastMCP("TestServer")
@mcp.tool
async def pattern_match_tool(context: Context) -> str:
result = await context.elicit("Enter your name:", response_type=str)
match result:
case AcceptedElicitation(data=name):
return f"Hello {name}!"
case DeclinedElicitation():
return "You declined"
case CancelledElicitation():
return "Cancelled"
case _:
return "Unknown result"
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "Alice"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "Hello Alice!"
async def test_pattern_matching_decline(self):
"""Test pattern matching with DeclinedElicitation."""
mcp = FastMCP("TestServer")
@mcp.tool
async def pattern_match_tool(context: Context) -> str:
result = await context.elicit("Enter your name:", response_type=str)
match result:
case AcceptedElicitation(data=name):
return f"Hello {name}!"
case DeclinedElicitation():
return "You declined"
case CancelledElicitation():
return "Cancelled"
case _:
return "Unknown result"
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="decline")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "You declined"
async def test_pattern_matching_cancel(self):
"""Test pattern matching with CancelledElicitation."""
mcp = FastMCP("TestServer")
@mcp.tool
async def pattern_match_tool(context: Context) -> str:
result = await context.elicit("Enter your name:", response_type=str)
match result:
case AcceptedElicitation(data=name):
return f"Hello {name}!"
case DeclinedElicitation():
return "You declined"
case CancelledElicitation():
return "Cancelled"
case _:
return "Unknown result"
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="cancel")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("pattern_match_tool", {})
assert result.data == "Cancelled"
async def test_elicitation_implicit_acceptance(fastmcp_server):
"""Test that elicitation handler can return data directly without ElicitResult wrapper."""
async def elicitation_handler(message, response_type, params, ctx):
# Return data directly without wrapping in ElicitResult
# This should be treated as implicit acceptance
return response_type(name="Bob")
async with Client(
fastmcp_server, elicitation_handler=elicitation_handler
) as client:
result = await client.call_tool("ask_for_name")
assert result.data == "Hello, Bob!"
async def test_elicitation_implicit_acceptance_must_be_dict(fastmcp_server):
"""Test that elicitation handler can return data directly without ElicitResult wrapper."""
async def elicitation_handler(message, response_type, params, ctx):
# Return data directly without wrapping in ElicitResult
# This should be treated as implicit acceptance
return "Bob"
async with Client(
fastmcp_server, elicitation_handler=elicitation_handler
) as client:
with pytest.raises(
ToolError,
match="Elicitation responses must be serializable as a JSON object",
):
await client.call_tool("ask_for_name")
def test_enum_elicitation_schema_inline():
"""Test that enum schemas are generated inline without $ref/$defs for MCP compatibility."""
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
@dataclass
class TaskRequest:
title: str
priority: Priority
# Generate elicitation schema
schema = get_elicitation_schema(TaskRequest)
# Verify no $defs section exists (enums should be inlined)
assert "$defs" not in schema, (
"Schema should not contain $defs - enums must be inline"
)
# Verify no $ref in properties
for prop_name, prop_schema in schema.get("properties", {}).items():
assert "$ref" not in prop_schema, (
f"Property {prop_name} contains $ref - should be inline"
)
# Verify the priority field has inline enum values
priority_schema = schema["properties"]["priority"]
assert "enum" in priority_schema, "Priority should have enum values inline"
assert priority_schema["enum"] == ["low", "medium", "high"]
assert priority_schema.get("type") == "string"
# Verify title field is a simple string
assert schema["properties"]["title"]["type"] == "string"
def test_enum_elicitation_schema_with_enum_names():
"""Test that enum schemas can include enumNames for better UI display."""
class TaskStatus(Enum):
NOT_STARTED = "not_started"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
ON_HOLD = "on_hold"
@dataclass
class TaskUpdate:
task_id: str
status: TaskStatus
# Generate elicitation schema
schema = get_elicitation_schema(TaskUpdate)
# Verify enum is inline
assert "$defs" not in schema
assert "$ref" not in str(schema)
status_schema = schema["properties"]["status"]
assert "enum" in status_schema
assert status_schema["enum"] == [
"not_started",
"in_progress",
"completed",
"on_hold",
]
# Check if enumNames were added for display
assert "enumNames" in status_schema
assert status_schema["enumNames"] == [
"Not Started",
"In Progress",
"Completed",
"On Hold",
]