-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Add support for Elicitation #625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
src/mcp/server/fastmcp/server.py
Outdated
requestedSchema: dict[str, Any], | ||
) -> dict[str, Any]: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think what you want would be something like this:
async def elicit(self, schema: type[T], message: str | None = None) -> T: ...
And then it can be used inside the tools as:
from mcp.server.fastmcp import FastMCP
from typing_extensions import TypedDict
app = FastMCP()
class MyServerSchema(TypedDict):
name: str
@app.tool()
async def potato_tool(ctx: Context):
content = await ctx.elicit(MyServerSchema)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added message
as optional because I can imagine us extracting the message from the docstring of MyServerSchema
. For the best user experience.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you! I,plemented elicit
, looks nice and typesafa now!
Re message in the schema:
The message and schema serve distinct purposes in the elicitation flow:
- The message is the contextual question or prompt that explains why we're asking for information (e.g., "No tables available at 7 PM. Would you like to try 8 PM instead?")
- The schema defines what information we're collecting and its structure (e.g., fields for confirmation, alternative time, etc.)
This separation allows for:
- Dynamic context - The same schema can be reused with different messages based on the situation
- Clearer intent - The message can provide specific context that wouldn't make sense as a docstring (like "Your session will expire in 5 minutes. Save your work?")
result = await ctx.elicit( | ||
message=f"Confirm booking for {party_size} on {date}?", schema=ConfirmBooking | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it gives us a better API and user experience if ctx.elicit(schema=SchemaT)
always return an instance of SchemaT
, or an exception.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My comment implies that an exception would be raised if user rejects.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
then the problem will be how to distinguish between cancel and reject?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
result = await ctx.elicit( | |
message=f"Confirm booking for {party_size} on {date}?", schema=ConfirmBooking | |
) | |
try: | |
result = await ctx.elicit( | |
message=f"Confirm booking for {party_size} on {date}?", | |
schema=ConfirmBooking | |
) | |
except ElicitError as exc: | |
print(exc.reason) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't this imply that we use exceptions for control flow? We should reserve exceptions for handling exceptional circumstances. I don't think decline fits into this.
I do prefer having a return value that indicates if it was accepted,declined,etc.
The `elicit()` method returns an `ElicitationResult` with: | ||
- `action`: "accept", "decline", or "cancel" | ||
- `data`: The validated response (only when accepted) | ||
- `validation_error`: Any validation error message |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should just bubble.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this PR is missing the implementation for mcp/server/lowlevel/server.py
.
I decline this for now mostly because of elicitation being implement in FastMCP, but I believe it belongs at a lower level such that lowlevel server implementations have access to it.
For the tagged union appraoch, I would prefer it, but I am okay if it's not.
result = await ctx.elicit( | ||
message=f"Confirm booking for {party_size} on {date}?", schema=ConfirmBooking | ||
) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't this imply that we use exceptions for control flow? We should reserve exceptions for handling exceptional circumstances. I don't think decline fits into this.
I do prefer having a return value that indicates if it was accepted,declined,etc.
src/mcp/server/fastmcp/server.py
Outdated
class ElicitationResult(BaseModel, Generic[ElicitSchemaModelT]): | ||
"""Result of an elicitation request.""" | ||
|
||
action: Literal["accept", "decline", "cancel"] | ||
"""The user's action in response to the elicitation.""" | ||
|
||
data: ElicitSchemaModelT | None = None | ||
"""The validated data if action is 'accept', None otherwise.""" | ||
|
||
validation_error: str | None = None | ||
"""Validation error message if data failed to validate.""" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Okay, I think I can be fine with this, but the FP person in me really doesn't like that this is not very typesafe, because data
and validation_error
depends on action
.
An alternative, and something I probably prefer is using a tagged union:
from typing import Literal
class AcceptedElicitation(BaseModel, Generic[T]):
action: Literal["accept"]
data: T
class DeclinedElicitation(BaseModel):
action: Literal["decline"]
class CancelledElicitation(BaseModel):
action: Literal["cancel"]
class ValidationFailedElicitation(BaseModel):
action: Literal["accept"]
validation_error: str
ElicitationResult = AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation | ValidationFailedElicitation
You can then do:
match result:
case AcceptedElicitation(data=data):
# data is guaranteed to exist and be typed
process(data)
case DeclinedElicitation():
handle_decline()
case CancelledElicitation():
handle_cancel()
case ValidationFailedElicitation(validation_error=error):
handle_validation_error(error)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh, this is great!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we want to have validation_error
as a str
, it should bubble up. User may want to handle it differently.
src/mcp/server/fastmcp/server.py
Outdated
async def elicit( | ||
self, | ||
message: str, | ||
schema: type[ElicitSchemaModelT], | ||
) -> ElicitationResult[ElicitSchemaModelT]: | ||
"""Elicit information from the client/user. | ||
|
||
This method can be used to interactively ask for additional information from the | ||
client within a tool's execution. The client might display the message to the | ||
user and collect a response according to the provided schema. Or in case a | ||
client is an agent, it might decide how to handle the elicitation -- either by asking | ||
the user or automatically generating a response. | ||
|
||
Args: | ||
schema: A Pydantic model class defining the expected response structure, according to the specification, | ||
only primive types are allowed. | ||
message: Optional message to present to the user. If not provided, will use | ||
a default message based on the schema | ||
|
||
Returns: | ||
An ElicitationResult containing the action taken and the data if accepted | ||
|
||
Note: | ||
Check the result.action to determine if the user accepted, declined, or cancelled. | ||
The result.data will only be populated if action is "accept" and validation succeeded. | ||
""" | ||
|
||
# Validate that schema only contains primitive types and fail loudly if not | ||
_validate_elicitation_schema(schema) | ||
|
||
json_schema = schema.model_json_schema() | ||
|
||
result = await self.request_context.session.elicit( | ||
message=message, | ||
requestedSchema=json_schema, | ||
related_request_id=self.request_id, | ||
) | ||
|
||
if result.action == "accept" and result.content: | ||
# Validate and parse the content using the schema | ||
try: | ||
validated_data = schema.model_validate(result.content) | ||
return ElicitationResult(action="accept", data=validated_data) | ||
except ValidationError as e: | ||
return ElicitationResult(action="accept", validation_error=str(e)) | ||
else: | ||
return ElicitationResult(action=result.action) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should not be defined here. This should be in the ServerSession
and be called from request_context.session
.
The SDK is layered in such a way that FastMCP should always only contain convenience wrapping server/lowlevel or server/session, but never implement functionality itself.
0359e94
to
150f839
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thank you @dsp-ant, types LGTM
(note, there is a conflict in readme)
150f839
to
51b5ee8
Compare
Elicitation Spec PR
Overview
Adding support for the elicitation feature, allowing servers to interactively request additional information from clients during tool execution. The implementation follows a layered approach with both low-level protocol support and a high-level API through FastMCP.
Key Changes
1. Protocol Layer (
src/mcp/types.py
)ElicitRequest
,ElicitRequestParams
, andElicitResult
types to support the elicitation protocolServerRequest
andClientResult
to handle elicitation messages2. Low-Level Server/Client (
src/mcp/server/session.py
,src/mcp/client/session.py
)elicit()
method toServerSession
for sending elicitation requestselicit()
method toClientSession
for handling server elicitation requests3. FastMCP High-Level API (
src/mcp/server/fastmcp/server.py
)Context.elicit()
method that returns anElicitationResult
objectFastMCP Interface Design
The FastMCP elicitation interface prioritizes type safety and explicit result handling:
Interface characteristics: