Skip to content

Commit d1c72b6

Browse files
committed
add ElicitationResult to fastMCP
1 parent fa57eda commit d1c72b6

File tree

4 files changed

+149
-93
lines changed

4 files changed

+149
-93
lines changed

src/mcp/server/fastmcp/server.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,20 @@
6565

6666
logger = get_logger(__name__)
6767

68-
ElicitedModelT = TypeVar("ElicitedModelT", bound=BaseModel)
68+
ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel)
69+
70+
71+
class ElicitationResult(BaseModel, Generic[ElicitSchemaModelT]):
72+
"""Result of an elicitation request."""
73+
74+
action: Literal["accept", "decline", "cancel"]
75+
"""The user's action in response to the elicitation."""
76+
77+
data: ElicitSchemaModelT | None = None
78+
"""The validated data if action is 'accept', None otherwise."""
79+
80+
validation_error: str | None = None
81+
"""Validation error message if data failed to validate."""
6982

7083

7184
class Settings(BaseSettings, Generic[LifespanResultT]):
@@ -959,28 +972,28 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
959972
async def elicit(
960973
self,
961974
message: str,
962-
schema: type[ElicitedModelT],
963-
) -> ElicitedModelT:
975+
schema: type[ElicitSchemaModelT],
976+
) -> ElicitationResult[ElicitSchemaModelT]:
964977
"""Elicit information from the client/user.
965978
966979
This method can be used to interactively ask for additional information from the
967980
client within a tool's execution. The client might display the message to the
968981
user and collect a response according to the provided schema. Or in case a
969-
client
970-
is an agent, it might decide how to handle the elicitation -- either by asking
982+
client is an agent, it might decide how to handle the elicitation -- either by asking
971983
the user or automatically generating a response.
972984
973985
Args:
974-
schema: A Pydantic model class defining the expected response structure
986+
schema: A Pydantic model class defining the expected response structure, according to the specification,
987+
only primive types are allowed.
975988
message: Optional message to present to the user. If not provided, will use
976989
a default message based on the schema
977990
978991
Returns:
979-
An instance of the schema type with the user's response
992+
An ElicitationResult containing the action taken and the data if accepted
980993
981-
Raises:
982-
Exception: If the user declines or cancels the elicitation
983-
ValidationError: If the response doesn't match the schema
994+
Note:
995+
Check the result.action to determine if the user accepted, declined, or cancelled.
996+
The result.data will only be populated if action is "accept" and validation succeeded.
984997
"""
985998

986999
json_schema = schema.model_json_schema()
@@ -994,13 +1007,12 @@ async def elicit(
9941007
if result.action == "accept" and result.content:
9951008
# Validate and parse the content using the schema
9961009
try:
997-
return schema.model_validate(result.content)
1010+
validated_data = schema.model_validate(result.content)
1011+
return ElicitationResult(action="accept", data=validated_data)
9981012
except ValidationError as e:
999-
raise ValueError(f"Invalid response: {e}")
1000-
elif result.action == "decline":
1001-
raise Exception("User declined to provide information")
1013+
return ElicitationResult(action="accept", validation_error=str(e))
10021014
else:
1003-
raise Exception("User cancelled the request")
1015+
return ElicitationResult(action=result.action)
10041016

10051017
async def log(
10061018
self,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""
2+
Test the elicitation feature using stdio transport.
3+
"""
4+
5+
import pytest
6+
from pydantic import BaseModel, Field
7+
8+
from mcp.server.fastmcp import Context, FastMCP
9+
from mcp.shared.memory import create_connected_server_and_client_session
10+
from mcp.types import ElicitResult, TextContent
11+
12+
13+
@pytest.mark.anyio
14+
async def test_stdio_elicitation():
15+
"""Test the elicitation feature using stdio transport."""
16+
17+
# Create a FastMCP server with a tool that uses elicitation
18+
mcp = FastMCP(name="StdioElicitationServer")
19+
20+
@mcp.tool(description="A tool that uses elicitation")
21+
async def ask_user(prompt: str, ctx: Context) -> str:
22+
class AnswerSchema(BaseModel):
23+
answer: str = Field(description="The user's answer to the question")
24+
25+
result = await ctx.elicit(
26+
message=f"Tool wants to ask: {prompt}",
27+
schema=AnswerSchema,
28+
)
29+
30+
if result.action == "accept" and result.data:
31+
return f"User answered: {result.data.answer}"
32+
elif result.action == "decline":
33+
return "User declined to answer"
34+
else:
35+
return "User cancelled"
36+
37+
# Create a custom handler for elicitation requests
38+
async def elicitation_callback(context, params):
39+
# Verify the elicitation parameters
40+
if params.message == "Tool wants to ask: What is your name?":
41+
return ElicitResult(action="accept", content={"answer": "Test User"})
42+
else:
43+
raise ValueError(f"Unexpected elicitation message: {params.message}")
44+
45+
# Use memory-based session to test with stdio transport
46+
async with create_connected_server_and_client_session(
47+
mcp._mcp_server, elicitation_callback=elicitation_callback
48+
) as client_session:
49+
# First initialize the session
50+
result = await client_session.initialize()
51+
assert result.serverInfo.name == "StdioElicitationServer"
52+
53+
# Call the tool that uses elicitation
54+
tool_result = await client_session.call_tool("ask_user", {"prompt": "What is your name?"})
55+
56+
# Verify the result
57+
assert len(tool_result.content) == 1
58+
assert isinstance(tool_result.content[0], TextContent)
59+
assert tool_result.content[0].text == "User answered: Test User"
60+
61+
62+
@pytest.mark.anyio
63+
async def test_stdio_elicitation_decline():
64+
"""Test elicitation with user declining."""
65+
66+
mcp = FastMCP(name="StdioElicitationDeclineServer")
67+
68+
@mcp.tool(description="A tool that uses elicitation")
69+
async def ask_user(prompt: str, ctx: Context) -> str:
70+
class AnswerSchema(BaseModel):
71+
answer: str = Field(description="The user's answer to the question")
72+
73+
result = await ctx.elicit(
74+
message=f"Tool wants to ask: {prompt}",
75+
schema=AnswerSchema,
76+
)
77+
78+
if result.action == "accept" and result.data:
79+
return f"User answered: {result.data.answer}"
80+
elif result.action == "decline":
81+
return "User declined to answer"
82+
else:
83+
return "User cancelled"
84+
85+
# Create a custom handler that declines
86+
async def elicitation_callback(context, params):
87+
return ElicitResult(action="decline")
88+
89+
async with create_connected_server_and_client_session(
90+
mcp._mcp_server, elicitation_callback=elicitation_callback
91+
) as client_session:
92+
# Initialize
93+
await client_session.initialize()
94+
95+
# Call the tool
96+
tool_result = await client_session.call_tool("ask_user", {"prompt": "What is your name?"})
97+
98+
# Verify the result
99+
assert len(tool_result.content) == 1
100+
assert isinstance(tool_result.content[0], TextContent)
101+
assert tool_result.content[0].text == "User declined to answer"

tests/server/fastmcp/test_integration.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,13 @@ async def ask_user(prompt: str, ctx: Context) -> str:
9696
class AnswerSchema(BaseModel):
9797
answer: str = Field(description="The user's answer to the question")
9898

99-
try:
100-
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
101-
return f"User answered: {result.answer}"
102-
except Exception as e:
99+
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
100+
101+
if result.action == "accept" and result.data:
102+
return f"User answered: {result.data.answer}"
103+
else:
103104
# Handle cancellation or decline
104-
return f"User cancelled or declined: {str(e)}"
105+
return f"User cancelled or declined: {result.action}"
105106

106107
# Create the SSE app
107108
app = mcp.sse_app()
@@ -252,23 +253,25 @@ class AlternativeDateSchema(BaseModel):
252253
# For testing: assume dates starting with "2024-12-25" are unavailable
253254
if date.startswith("2024-12-25"):
254255
# Use elicitation to ask about alternatives
255-
try:
256-
result = await ctx.elicit(
257-
message=(
258-
f"No tables available for {party_size} people on {date} "
259-
f"at {time}. Would you like to check another date?"
260-
),
261-
schema=AlternativeDateSchema,
262-
)
263-
264-
if result.checkAlternative:
265-
alt_date = result.alternativeDate
256+
result = await ctx.elicit(
257+
message=(
258+
f"No tables available for {party_size} people on {date} "
259+
f"at {time}. Would you like to check another date?"
260+
),
261+
schema=AlternativeDateSchema,
262+
)
263+
264+
if result.action == "accept" and result.data:
265+
if result.data.checkAlternative:
266+
alt_date = result.data.alternativeDate
266267
return f"✅ Booked table for {party_size} on {alt_date} at {time}"
267268
else:
268269
return "❌ No booking made"
269-
except Exception:
270-
# User declined or cancelled
270+
elif result.action in ("decline", "cancel"):
271271
return "❌ Booking cancelled"
272+
else:
273+
# Validation error
274+
return f"❌ Invalid input: {result.validation_error}"
272275
else:
273276
# Available - book directly
274277
return f"✅ Booked table for {party_size} on {date} at {time}"

tests/server/fastmcp/test_stdio_elicitation.py

Lines changed: 0 additions & 60 deletions
This file was deleted.

0 commit comments

Comments
 (0)