Skip to content

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

Merged
merged 10 commits into from
Jun 17, 2025
Merged

Add support for Elicitation #625

merged 10 commits into from
Jun 17, 2025

Conversation

ihrpr
Copy link
Contributor

@ihrpr ihrpr commented May 4, 2025

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)

  • Added ElicitRequest, ElicitRequestParams, and ElicitResult types to support the elicitation protocol
  • Extended ServerRequest and ClientResult to handle elicitation messages
  • Follows the MCP specification for elicitation support

2. Low-Level Server/Client (src/mcp/server/session.py, src/mcp/client/session.py)

  • Added elicit() method to ServerSession for sending elicitation requests
  • Added elicit() method to ClientSession for handling server elicitation requests
  • Design decision: Uses raw dictionaries instead of Pydantic models - this maintains consistency with the low-level API design which provides direct protocol access without data modeling abstractions

3. FastMCP High-Level API (src/mcp/server/fastmcp/server.py)

  • Added Context.elicit() method that returns an ElicitationResult object
  • Design decision: Returns a result object instead of raising exceptions for decline/cancel actions. This treats user declining or cancelling as expected outcomes rather than error conditions
  • Enforces MCP specification requirement that elicitation schemas only contain primitive types (str, int, float, bool)
  • Validates schemas at runtime with descriptive error messages

FastMCP Interface Design

The FastMCP elicitation interface prioritizes type safety and explicit result handling:

@server.tool()
async def book_table(date: str, party_size: int, ctx: Context) -> str:
    class ConfirmBooking(BaseModel):
        confirm: bool = Field(description="Confirm booking?")
        notes: str = Field(default="", description="Special requests")

    result = await ctx.elicit(
        message=f"Confirm booking for {party_size} on {date}?",
        schema=ConfirmBooking
    )

    if result.action == "accept" and result.data:
        # result.data is typed as ConfirmBooking
        if result.data.confirm:
            return f"Booked! Notes: {result.data.notes}"
    elif result.action == "decline":
        return "User declined"
    else:  # cancel
        return "User cancelled"

Interface characteristics:

  • result.data is typed according to the provided schema
  • All three user actions (accept/decline/cancel) are handled explicitly
  • Validation errors are captured in result.validation_error when user input doesn't match schema
  • Schema validation happens before sending the request, failing fast if non-primitive types are used

Comment on lines 1008 to 1009
requestedSchema: dict[str, Any],
) -> dict[str, Any]:
Copy link
Member

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)

Copy link
Member

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.

Copy link
Contributor Author

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:

  1. Dynamic context - The same schema can be reused with different messages based on the situation
  2. 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?")

@ihrpr ihrpr changed the title Elicitation prototype Add support for Elicitation Jun 12, 2025
@ihrpr ihrpr requested review from dsp-ant and bhosmer-ant June 12, 2025 10:16
@ihrpr ihrpr marked this pull request as ready for review June 12, 2025 10:17
@ihrpr ihrpr linked an issue Jun 12, 2025 that may be closed by this pull request
Comment on lines +335 to +404
result = await ctx.elicit(
message=f"Confirm booking for {party_size} on {date}?", schema=ConfirmBooking
)
Copy link
Member

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.

Copy link
Member

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.

Copy link
Contributor Author

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?

Copy link
Member

@Kludex Kludex Jun 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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)

Copy link
Member

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
Copy link
Member

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.

Copy link
Member

@dsp-ant dsp-ant left a 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.

Comment on lines +335 to +404
result = await ctx.elicit(
message=f"Confirm booking for {party_size} on {date}?", schema=ConfirmBooking
)
Copy link
Member

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.

Comment on lines 73 to 84
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."""

Copy link
Member

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)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, this is great!

Copy link
Member

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.

Comment on lines 1011 to 1058
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)

Copy link
Member

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.

@dsp-ant dsp-ant force-pushed the ihrpr/elicitation-prototype branch 2 times, most recently from 0359e94 to 150f839 Compare June 17, 2025 10:03
Copy link
Contributor Author

@ihrpr ihrpr left a 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)

@dsp-ant dsp-ant force-pushed the ihrpr/elicitation-prototype branch from 150f839 to 51b5ee8 Compare June 17, 2025 10:49
@dsp-ant dsp-ant merged commit 69e6572 into main Jun 17, 2025
12 checks passed
@dsp-ant dsp-ant deleted the ihrpr/elicitation-prototype branch June 17, 2025 10:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Introduce elicitation as new client capability
3 participants