Skip to content

Commit a75afd4

Browse files
ihrprdsp-ant
authored andcommitted
add ElicitationResult to fastMCP
1 parent 3a2d915 commit a75afd4

File tree

4 files changed

+148
-92
lines changed

4 files changed

+148
-92
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]):
@@ -977,28 +990,28 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
977990
async def elicit(
978991
self,
979992
message: str,
980-
schema: type[ElicitedModelT],
981-
) -> ElicitedModelT:
993+
schema: type[ElicitSchemaModelT],
994+
) -> ElicitationResult[ElicitSchemaModelT]:
982995
"""Elicit information from the client/user.
983996
984997
This method can be used to interactively ask for additional information from the
985998
client within a tool's execution. The client might display the message to the
986999
user and collect a response according to the provided schema. Or in case a
987-
client
988-
is an agent, it might decide how to handle the elicitation -- either by asking
1000+
client is an agent, it might decide how to handle the elicitation -- either by asking
9891001
the user or automatically generating a response.
9901002
9911003
Args:
992-
schema: A Pydantic model class defining the expected response structure
1004+
schema: A Pydantic model class defining the expected response structure, according to the specification,
1005+
only primive types are allowed.
9931006
message: Optional message to present to the user. If not provided, will use
9941007
a default message based on the schema
9951008
9961009
Returns:
997-
An instance of the schema type with the user's response
1010+
An ElicitationResult containing the action taken and the data if accepted
9981011
999-
Raises:
1000-
Exception: If the user declines or cancels the elicitation
1001-
ValidationError: If the response doesn't match the schema
1012+
Note:
1013+
Check the result.action to determine if the user accepted, declined, or cancelled.
1014+
The result.data will only be populated if action is "accept" and validation succeeded.
10021015
"""
10031016

10041017
json_schema = schema.model_json_schema()
@@ -1012,13 +1025,12 @@ async def elicit(
10121025
if result.action == "accept" and result.content:
10131026
# Validate and parse the content using the schema
10141027
try:
1015-
return schema.model_validate(result.content)
1028+
validated_data = schema.model_validate(result.content)
1029+
return ElicitationResult(action="accept", data=validated_data)
10161030
except ValidationError as e:
1017-
raise ValueError(f"Invalid response: {e}")
1018-
elif result.action == "decline":
1019-
raise Exception("User declined to provide information")
1031+
return ElicitationResult(action="accept", validation_error=str(e))
10201032
else:
1021-
raise Exception("User cancelled the request")
1033+
return ElicitationResult(action=result.action)
10221034

10231035
async def log(
10241036
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: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,13 @@ async def ask_user(prompt: str, ctx: Context) -> str:
105105
class AnswerSchema(BaseModel):
106106
answer: str = Field(description="The user's answer to the question")
107107

108-
try:
109-
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
110-
return f"User answered: {result.answer}"
111-
except Exception as e:
108+
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
109+
110+
if result.action == "accept" and result.data:
111+
return f"User answered: {result.data.answer}"
112+
else:
112113
# Handle cancellation or decline
113-
return f"User cancelled or declined: {str(e)}"
114+
return f"User cancelled or declined: {result.action}"
114115

115116
# Create the SSE app
116117
app = mcp.sse_app()
@@ -295,23 +296,25 @@ class AlternativeDateSchema(BaseModel):
295296
# For testing: assume dates starting with "2024-12-25" are unavailable
296297
if date.startswith("2024-12-25"):
297298
# Use elicitation to ask about alternatives
298-
try:
299-
result = await ctx.elicit(
300-
message=(
301-
f"No tables available for {party_size} people on {date} "
302-
f"at {time}. Would you like to check another date?"
303-
),
304-
schema=AlternativeDateSchema,
305-
)
299+
result = await ctx.elicit(
300+
message=(
301+
f"No tables available for {party_size} people on {date} "
302+
f"at {time}. Would you like to check another date?"
303+
),
304+
schema=AlternativeDateSchema,
305+
)
306306

307-
if result.checkAlternative:
308-
alt_date = result.alternativeDate
307+
if result.action == "accept" and result.data:
308+
if result.data.checkAlternative:
309+
alt_date = result.data.alternativeDate
309310
return f"✅ Booked table for {party_size} on {alt_date} at {time}"
310311
else:
311312
return "❌ No booking made"
312-
except Exception:
313-
# User declined or cancelled
313+
elif result.action in ("decline", "cancel"):
314314
return "❌ Booking cancelled"
315+
else:
316+
# Validation error
317+
return f"❌ Invalid input: {result.validation_error}"
315318
else:
316319
# Available - book directly
317320
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)