Add support for Elicitation (#625)
Co-authored-by: David Soria Parra <davidsp@anthropic.com>
This commit is contained in:
parent
a3bcabdce2
commit
69e6572d48
9 changed files with 643 additions and 8 deletions
46
README.md
46
README.md
|
|
@ -31,6 +31,8 @@
|
||||||
- [Images](#images)
|
- [Images](#images)
|
||||||
- [Context](#context)
|
- [Context](#context)
|
||||||
- [Completions](#completions)
|
- [Completions](#completions)
|
||||||
|
- [Elicitation](#elicitation)
|
||||||
|
- [Authentication](#authentication)
|
||||||
- [Running Your Server](#running-your-server)
|
- [Running Your Server](#running-your-server)
|
||||||
- [Development Mode](#development-mode)
|
- [Development Mode](#development-mode)
|
||||||
- [Claude Desktop Integration](#claude-desktop-integration)
|
- [Claude Desktop Integration](#claude-desktop-integration)
|
||||||
|
|
@ -372,6 +374,50 @@ async def handle_completion(
|
||||||
return Completion(values=filtered)
|
return Completion(values=filtered)
|
||||||
return None
|
return None
|
||||||
```
|
```
|
||||||
|
### Elicitation
|
||||||
|
|
||||||
|
Request additional information from users during tool execution:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.server.fastmcp import FastMCP, Context
|
||||||
|
from mcp.server.elicitation import (
|
||||||
|
AcceptedElicitation,
|
||||||
|
DeclinedElicitation,
|
||||||
|
CancelledElicitation,
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
mcp = FastMCP("Booking System")
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def book_table(date: str, party_size: int, ctx: Context) -> str:
|
||||||
|
"""Book a table with confirmation"""
|
||||||
|
|
||||||
|
# Schema must only contain primitive types (str, int, float, bool)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
match result:
|
||||||
|
case AcceptedElicitation(data=data):
|
||||||
|
if data.confirm:
|
||||||
|
return f"Booked! Notes: {data.notes or 'None'}"
|
||||||
|
return "Booking cancelled"
|
||||||
|
case DeclinedElicitation():
|
||||||
|
return "Booking declined"
|
||||||
|
case CancelledElicitation():
|
||||||
|
return "Booking cancelled"
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,14 @@ class SamplingFnT(Protocol):
|
||||||
) -> types.CreateMessageResult | types.ErrorData: ...
|
) -> types.CreateMessageResult | types.ErrorData: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ElicitationFnT(Protocol):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
context: RequestContext["ClientSession", Any],
|
||||||
|
params: types.ElicitRequestParams,
|
||||||
|
) -> types.ElicitResult | types.ErrorData: ...
|
||||||
|
|
||||||
|
|
||||||
class ListRootsFnT(Protocol):
|
class ListRootsFnT(Protocol):
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self, context: RequestContext["ClientSession", Any]
|
self, context: RequestContext["ClientSession", Any]
|
||||||
|
|
@ -58,6 +66,16 @@ async def _default_sampling_callback(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _default_elicitation_callback(
|
||||||
|
context: RequestContext["ClientSession", Any],
|
||||||
|
params: types.ElicitRequestParams,
|
||||||
|
) -> types.ElicitResult | types.ErrorData:
|
||||||
|
return types.ErrorData(
|
||||||
|
code=types.INVALID_REQUEST,
|
||||||
|
message="Elicitation not supported",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _default_list_roots_callback(
|
async def _default_list_roots_callback(
|
||||||
context: RequestContext["ClientSession", Any],
|
context: RequestContext["ClientSession", Any],
|
||||||
) -> types.ListRootsResult | types.ErrorData:
|
) -> types.ListRootsResult | types.ErrorData:
|
||||||
|
|
@ -91,6 +109,7 @@ class ClientSession(
|
||||||
write_stream: MemoryObjectSendStream[SessionMessage],
|
write_stream: MemoryObjectSendStream[SessionMessage],
|
||||||
read_timeout_seconds: timedelta | None = None,
|
read_timeout_seconds: timedelta | None = None,
|
||||||
sampling_callback: SamplingFnT | None = None,
|
sampling_callback: SamplingFnT | None = None,
|
||||||
|
elicitation_callback: ElicitationFnT | None = None,
|
||||||
list_roots_callback: ListRootsFnT | None = None,
|
list_roots_callback: ListRootsFnT | None = None,
|
||||||
logging_callback: LoggingFnT | None = None,
|
logging_callback: LoggingFnT | None = None,
|
||||||
message_handler: MessageHandlerFnT | None = None,
|
message_handler: MessageHandlerFnT | None = None,
|
||||||
|
|
@ -105,12 +124,16 @@ class ClientSession(
|
||||||
)
|
)
|
||||||
self._client_info = client_info or DEFAULT_CLIENT_INFO
|
self._client_info = client_info or DEFAULT_CLIENT_INFO
|
||||||
self._sampling_callback = sampling_callback or _default_sampling_callback
|
self._sampling_callback = sampling_callback or _default_sampling_callback
|
||||||
|
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
|
||||||
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
|
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
|
||||||
self._logging_callback = logging_callback or _default_logging_callback
|
self._logging_callback = logging_callback or _default_logging_callback
|
||||||
self._message_handler = message_handler or _default_message_handler
|
self._message_handler = message_handler or _default_message_handler
|
||||||
|
|
||||||
async def initialize(self) -> types.InitializeResult:
|
async def initialize(self) -> types.InitializeResult:
|
||||||
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
|
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
|
||||||
|
elicitation = (
|
||||||
|
types.ElicitationCapability() if self._elicitation_callback is not _default_elicitation_callback else None
|
||||||
|
)
|
||||||
roots = (
|
roots = (
|
||||||
# TODO: Should this be based on whether we
|
# TODO: Should this be based on whether we
|
||||||
# _will_ send notifications, or only whether
|
# _will_ send notifications, or only whether
|
||||||
|
|
@ -128,6 +151,7 @@ class ClientSession(
|
||||||
protocolVersion=types.LATEST_PROTOCOL_VERSION,
|
protocolVersion=types.LATEST_PROTOCOL_VERSION,
|
||||||
capabilities=types.ClientCapabilities(
|
capabilities=types.ClientCapabilities(
|
||||||
sampling=sampling,
|
sampling=sampling,
|
||||||
|
elicitation=elicitation,
|
||||||
experimental=None,
|
experimental=None,
|
||||||
roots=roots,
|
roots=roots,
|
||||||
),
|
),
|
||||||
|
|
@ -362,6 +386,12 @@ class ClientSession(
|
||||||
client_response = ClientResponse.validate_python(response)
|
client_response = ClientResponse.validate_python(response)
|
||||||
await responder.respond(client_response)
|
await responder.respond(client_response)
|
||||||
|
|
||||||
|
case types.ElicitRequest(params=params):
|
||||||
|
with responder:
|
||||||
|
response = await self._elicitation_callback(ctx, params)
|
||||||
|
client_response = ClientResponse.validate_python(response)
|
||||||
|
await responder.respond(client_response)
|
||||||
|
|
||||||
case types.ListRootsRequest():
|
case types.ListRootsRequest():
|
||||||
with responder:
|
with responder:
|
||||||
response = await self._list_roots_callback(ctx)
|
response = await self._list_roots_callback(ctx)
|
||||||
|
|
|
||||||
111
src/mcp/server/elicitation.py
Normal file
111
src/mcp/server/elicitation.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""Elicitation utilities for MCP servers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import types
|
||||||
|
from typing import Generic, Literal, TypeVar, Union, get_args, get_origin
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic.fields import FieldInfo
|
||||||
|
|
||||||
|
from mcp.server.session import ServerSession
|
||||||
|
from mcp.types import RequestId
|
||||||
|
|
||||||
|
ElicitSchemaModelT = TypeVar("ElicitSchemaModelT", bound=BaseModel)
|
||||||
|
|
||||||
|
|
||||||
|
class AcceptedElicitation(BaseModel, Generic[ElicitSchemaModelT]):
|
||||||
|
"""Result when user accepts the elicitation."""
|
||||||
|
|
||||||
|
action: Literal["accept"] = "accept"
|
||||||
|
data: ElicitSchemaModelT
|
||||||
|
|
||||||
|
|
||||||
|
class DeclinedElicitation(BaseModel):
|
||||||
|
"""Result when user declines the elicitation."""
|
||||||
|
|
||||||
|
action: Literal["decline"] = "decline"
|
||||||
|
|
||||||
|
|
||||||
|
class CancelledElicitation(BaseModel):
|
||||||
|
"""Result when user cancels the elicitation."""
|
||||||
|
|
||||||
|
action: Literal["cancel"] = "cancel"
|
||||||
|
|
||||||
|
|
||||||
|
ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation
|
||||||
|
|
||||||
|
|
||||||
|
# Primitive types allowed in elicitation schemas
|
||||||
|
_ELICITATION_PRIMITIVE_TYPES = (str, int, float, bool)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_elicitation_schema(schema: type[BaseModel]) -> None:
|
||||||
|
"""Validate that a Pydantic model only contains primitive field types."""
|
||||||
|
for field_name, field_info in schema.model_fields.items():
|
||||||
|
if not _is_primitive_field(field_info):
|
||||||
|
raise TypeError(
|
||||||
|
f"Elicitation schema field '{field_name}' must be a primitive type "
|
||||||
|
f"{_ELICITATION_PRIMITIVE_TYPES} or Optional of these types. "
|
||||||
|
f"Complex types like lists, dicts, or nested models are not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_primitive_field(field_info: FieldInfo) -> bool:
|
||||||
|
"""Check if a field is a primitive type allowed in elicitation schemas."""
|
||||||
|
annotation = field_info.annotation
|
||||||
|
|
||||||
|
# Handle None type
|
||||||
|
if annotation is types.NoneType:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle basic primitive types
|
||||||
|
if annotation in _ELICITATION_PRIMITIVE_TYPES:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle Union types
|
||||||
|
origin = get_origin(annotation)
|
||||||
|
if origin is Union or origin is types.UnionType:
|
||||||
|
args = get_args(annotation)
|
||||||
|
# All args must be primitive types or None
|
||||||
|
return all(arg is types.NoneType or arg in _ELICITATION_PRIMITIVE_TYPES for arg in args)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def elicit_with_validation(
|
||||||
|
session: ServerSession,
|
||||||
|
message: str,
|
||||||
|
schema: type[ElicitSchemaModelT],
|
||||||
|
related_request_id: RequestId | None = None,
|
||||||
|
) -> ElicitationResult[ElicitSchemaModelT]:
|
||||||
|
"""Elicit information from the client/user with schema validation.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
# Validate that schema only contains primitive types and fail loudly if not
|
||||||
|
_validate_elicitation_schema(schema)
|
||||||
|
|
||||||
|
json_schema = schema.model_json_schema()
|
||||||
|
|
||||||
|
result = await session.elicit(
|
||||||
|
message=message,
|
||||||
|
requestedSchema=json_schema,
|
||||||
|
related_request_id=related_request_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.action == "accept" and result.content:
|
||||||
|
# Validate and parse the content using the schema
|
||||||
|
validated_data = schema.model_validate(result.content)
|
||||||
|
return AcceptedElicitation(data=validated_data)
|
||||||
|
elif result.action == "decline":
|
||||||
|
return DeclinedElicitation()
|
||||||
|
elif result.action == "cancel":
|
||||||
|
return CancelledElicitation()
|
||||||
|
else:
|
||||||
|
# This should never happen, but handle it just in case
|
||||||
|
raise ValueError(f"Unexpected elicitation action: {result.action}")
|
||||||
|
|
@ -34,6 +34,7 @@ from mcp.server.auth.provider import OAuthAuthorizationServerProvider
|
||||||
from mcp.server.auth.settings import (
|
from mcp.server.auth.settings import (
|
||||||
AuthSettings,
|
AuthSettings,
|
||||||
)
|
)
|
||||||
|
from mcp.server.elicitation import ElicitationResult, ElicitSchemaModelT, elicit_with_validation
|
||||||
from mcp.server.fastmcp.exceptions import ResourceError
|
from mcp.server.fastmcp.exceptions import ResourceError
|
||||||
from mcp.server.fastmcp.prompts import Prompt, PromptManager
|
from mcp.server.fastmcp.prompts import Prompt, PromptManager
|
||||||
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
|
from mcp.server.fastmcp.resources import FunctionResource, Resource, ResourceManager
|
||||||
|
|
@ -972,6 +973,37 @@ class Context(BaseModel, Generic[ServerSessionT, LifespanContextT, RequestT]):
|
||||||
assert self._fastmcp is not None, "Context is not available outside of a request"
|
assert self._fastmcp is not None, "Context is not available outside of a request"
|
||||||
return await self._fastmcp.read_resource(uri)
|
return await self._fastmcp.read_resource(uri)
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return await elicit_with_validation(
|
||||||
|
session=self.request_context.session, message=message, schema=schema, related_request_id=self.request_id
|
||||||
|
)
|
||||||
|
|
||||||
async def log(
|
async def log(
|
||||||
self,
|
self,
|
||||||
level: Literal["debug", "info", "warning", "error"],
|
level: Literal["debug", "info", "warning", "error"],
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,10 @@ class ServerSession(
|
||||||
if client_caps.sampling is None:
|
if client_caps.sampling is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if capability.elicitation is not None:
|
||||||
|
if client_caps.elicitation is None:
|
||||||
|
return False
|
||||||
|
|
||||||
if capability.experimental is not None:
|
if capability.experimental is not None:
|
||||||
if client_caps.experimental is None:
|
if client_caps.experimental is None:
|
||||||
return False
|
return False
|
||||||
|
|
@ -251,6 +255,35 @@ class ServerSession(
|
||||||
types.ListRootsResult,
|
types.ListRootsResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def elicit(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
requestedSchema: types.ElicitRequestedSchema,
|
||||||
|
related_request_id: types.RequestId | None = None,
|
||||||
|
) -> types.ElicitResult:
|
||||||
|
"""Send an elicitation/create request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: The message to present to the user
|
||||||
|
requestedSchema: Schema defining the expected response structure
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The client's response
|
||||||
|
"""
|
||||||
|
return await self.send_request(
|
||||||
|
types.ServerRequest(
|
||||||
|
types.ElicitRequest(
|
||||||
|
method="elicitation/create",
|
||||||
|
params=types.ElicitRequestParams(
|
||||||
|
message=message,
|
||||||
|
requestedSchema=requestedSchema,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
types.ElicitResult,
|
||||||
|
metadata=ServerMessageMetadata(related_request_id=related_request_id),
|
||||||
|
)
|
||||||
|
|
||||||
async def send_ping(self) -> types.EmptyResult:
|
async def send_ping(self) -> types.EmptyResult:
|
||||||
"""Send a ping request."""
|
"""Send a ping request."""
|
||||||
return await self.send_request(
|
return await self.send_request(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,14 @@ import anyio
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
|
|
||||||
import mcp.types as types
|
import mcp.types as types
|
||||||
from mcp.client.session import ClientSession, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
|
from mcp.client.session import (
|
||||||
|
ClientSession,
|
||||||
|
ElicitationFnT,
|
||||||
|
ListRootsFnT,
|
||||||
|
LoggingFnT,
|
||||||
|
MessageHandlerFnT,
|
||||||
|
SamplingFnT,
|
||||||
|
)
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.shared.message import SessionMessage
|
from mcp.shared.message import SessionMessage
|
||||||
|
|
||||||
|
|
@ -53,6 +60,7 @@ async def create_connected_server_and_client_session(
|
||||||
message_handler: MessageHandlerFnT | None = None,
|
message_handler: MessageHandlerFnT | None = None,
|
||||||
client_info: types.Implementation | None = None,
|
client_info: types.Implementation | None = None,
|
||||||
raise_exceptions: bool = False,
|
raise_exceptions: bool = False,
|
||||||
|
elicitation_callback: ElicitationFnT | None = None,
|
||||||
) -> AsyncGenerator[ClientSession, None]:
|
) -> AsyncGenerator[ClientSession, None]:
|
||||||
"""Creates a ClientSession that is connected to a running MCP server."""
|
"""Creates a ClientSession that is connected to a running MCP server."""
|
||||||
async with create_client_server_memory_streams() as (
|
async with create_client_server_memory_streams() as (
|
||||||
|
|
@ -83,6 +91,7 @@ async def create_connected_server_and_client_session(
|
||||||
logging_callback=logging_callback,
|
logging_callback=logging_callback,
|
||||||
message_handler=message_handler,
|
message_handler=message_handler,
|
||||||
client_info=client_info,
|
client_info=client_info,
|
||||||
|
elicitation_callback=elicitation_callback,
|
||||||
) as client_session:
|
) as client_session:
|
||||||
await client_session.initialize()
|
await client_session.initialize()
|
||||||
yield client_session
|
yield client_session
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,12 @@ class SamplingCapability(BaseModel):
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class ElicitationCapability(BaseModel):
|
||||||
|
"""Capability for elicitation operations."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
class ClientCapabilities(BaseModel):
|
class ClientCapabilities(BaseModel):
|
||||||
"""Capabilities a client may support."""
|
"""Capabilities a client may support."""
|
||||||
|
|
||||||
|
|
@ -223,6 +229,8 @@ class ClientCapabilities(BaseModel):
|
||||||
"""Experimental, non-standard capabilities that the client supports."""
|
"""Experimental, non-standard capabilities that the client supports."""
|
||||||
sampling: SamplingCapability | None = None
|
sampling: SamplingCapability | None = None
|
||||||
"""Present if the client supports sampling from an LLM."""
|
"""Present if the client supports sampling from an LLM."""
|
||||||
|
elicitation: ElicitationCapability | None = None
|
||||||
|
"""Present if the client supports elicitation from the user."""
|
||||||
roots: RootsCapability | None = None
|
roots: RootsCapability | None = None
|
||||||
"""Present if the client supports listing roots."""
|
"""Present if the client supports listing roots."""
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
@ -1186,11 +1194,49 @@ class ClientNotification(
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult]):
|
# Type for elicitation schema - a JSON Schema dict
|
||||||
|
ElicitRequestedSchema: TypeAlias = dict[str, Any]
|
||||||
|
"""Schema for elicitation requests."""
|
||||||
|
|
||||||
|
|
||||||
|
class ElicitRequestParams(RequestParams):
|
||||||
|
"""Parameters for elicitation requests."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
requestedSchema: ElicitRequestedSchema
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
|
||||||
|
class ElicitRequest(Request[ElicitRequestParams, Literal["elicitation/create"]]):
|
||||||
|
"""A request from the server to elicit information from the client."""
|
||||||
|
|
||||||
|
method: Literal["elicitation/create"]
|
||||||
|
params: ElicitRequestParams
|
||||||
|
|
||||||
|
|
||||||
|
class ElicitResult(Result):
|
||||||
|
"""The client's response to an elicitation request."""
|
||||||
|
|
||||||
|
action: Literal["accept", "decline", "cancel"]
|
||||||
|
"""
|
||||||
|
The user action in response to the elicitation.
|
||||||
|
- "accept": User submitted the form/confirmed the action
|
||||||
|
- "decline": User explicitly declined the action
|
||||||
|
- "cancel": User dismissed without making an explicit choice
|
||||||
|
"""
|
||||||
|
|
||||||
|
content: dict[str, str | int | float | bool | None] | None = None
|
||||||
|
"""
|
||||||
|
The submitted form data, only present when action is "accept".
|
||||||
|
Contains values matching the requested schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult | ElicitResult]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ServerRequest(RootModel[PingRequest | CreateMessageRequest | ListRootsRequest]):
|
class ServerRequest(RootModel[PingRequest | CreateMessageRequest | ListRootsRequest | ElicitRequest]):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
210
tests/server/fastmcp/test_elicitation.py
Normal file
210
tests/server/fastmcp/test_elicitation.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""
|
||||||
|
Test the elicitation feature using stdio transport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.shared.memory import create_connected_server_and_client_session
|
||||||
|
from mcp.types import ElicitResult, TextContent
|
||||||
|
|
||||||
|
|
||||||
|
# Shared schema for basic tests
|
||||||
|
class AnswerSchema(BaseModel):
|
||||||
|
answer: str = Field(description="The user's answer to the question")
|
||||||
|
|
||||||
|
|
||||||
|
def create_ask_user_tool(mcp: FastMCP):
|
||||||
|
"""Create a standard ask_user tool that handles all elicitation responses."""
|
||||||
|
|
||||||
|
@mcp.tool(description="A tool that uses elicitation")
|
||||||
|
async def ask_user(prompt: str, ctx: Context) -> str:
|
||||||
|
result = await ctx.elicit(
|
||||||
|
message=f"Tool wants to ask: {prompt}",
|
||||||
|
schema=AnswerSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.action == "accept" and result.data:
|
||||||
|
return f"User answered: {result.data.answer}"
|
||||||
|
elif result.action == "decline":
|
||||||
|
return "User declined to answer"
|
||||||
|
else:
|
||||||
|
return "User cancelled"
|
||||||
|
|
||||||
|
return ask_user
|
||||||
|
|
||||||
|
|
||||||
|
async def call_tool_and_assert(
|
||||||
|
mcp: FastMCP,
|
||||||
|
elicitation_callback,
|
||||||
|
tool_name: str,
|
||||||
|
args: dict,
|
||||||
|
expected_text: str | None = None,
|
||||||
|
text_contains: list[str] | None = None,
|
||||||
|
):
|
||||||
|
"""Helper to create session, call tool, and assert result."""
|
||||||
|
async with create_connected_server_and_client_session(
|
||||||
|
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||||
|
) as client_session:
|
||||||
|
await client_session.initialize()
|
||||||
|
|
||||||
|
result = await client_session.call_tool(tool_name, args)
|
||||||
|
assert len(result.content) == 1
|
||||||
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
|
||||||
|
if expected_text is not None:
|
||||||
|
assert result.content[0].text == expected_text
|
||||||
|
elif text_contains is not None:
|
||||||
|
for substring in text_contains:
|
||||||
|
assert substring in result.content[0].text
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stdio_elicitation():
|
||||||
|
"""Test the elicitation feature using stdio transport."""
|
||||||
|
mcp = FastMCP(name="StdioElicitationServer")
|
||||||
|
create_ask_user_tool(mcp)
|
||||||
|
|
||||||
|
# Create a custom handler for elicitation requests
|
||||||
|
async def elicitation_callback(context, params):
|
||||||
|
if params.message == "Tool wants to ask: What is your name?":
|
||||||
|
return ElicitResult(action="accept", content={"answer": "Test User"})
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unexpected elicitation message: {params.message}")
|
||||||
|
|
||||||
|
await call_tool_and_assert(
|
||||||
|
mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User answered: Test User"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stdio_elicitation_decline():
|
||||||
|
"""Test elicitation with user declining."""
|
||||||
|
mcp = FastMCP(name="StdioElicitationDeclineServer")
|
||||||
|
create_ask_user_tool(mcp)
|
||||||
|
|
||||||
|
async def elicitation_callback(context, params):
|
||||||
|
return ElicitResult(action="decline")
|
||||||
|
|
||||||
|
await call_tool_and_assert(
|
||||||
|
mcp, elicitation_callback, "ask_user", {"prompt": "What is your name?"}, "User declined to answer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_elicitation_schema_validation():
|
||||||
|
"""Test that elicitation schemas must only contain primitive types."""
|
||||||
|
mcp = FastMCP(name="ValidationTestServer")
|
||||||
|
|
||||||
|
def create_validation_tool(name: str, schema_class: type[BaseModel]):
|
||||||
|
@mcp.tool(name=name, description=f"Tool testing {name}")
|
||||||
|
async def tool(ctx: Context) -> str:
|
||||||
|
try:
|
||||||
|
await ctx.elicit(message="This should fail validation", schema=schema_class)
|
||||||
|
return "Should not reach here"
|
||||||
|
except TypeError as e:
|
||||||
|
return f"Validation failed as expected: {str(e)}"
|
||||||
|
|
||||||
|
return tool
|
||||||
|
|
||||||
|
# Test cases for invalid schemas
|
||||||
|
class InvalidListSchema(BaseModel):
|
||||||
|
names: list[str] = Field(description="List of names")
|
||||||
|
|
||||||
|
class NestedModel(BaseModel):
|
||||||
|
value: str
|
||||||
|
|
||||||
|
class InvalidNestedSchema(BaseModel):
|
||||||
|
nested: NestedModel = Field(description="Nested model")
|
||||||
|
|
||||||
|
create_validation_tool("invalid_list", InvalidListSchema)
|
||||||
|
create_validation_tool("nested_model", InvalidNestedSchema)
|
||||||
|
|
||||||
|
# Dummy callback (won't be called due to validation failure)
|
||||||
|
async def elicitation_callback(context, params):
|
||||||
|
return ElicitResult(action="accept", content={})
|
||||||
|
|
||||||
|
async with create_connected_server_and_client_session(
|
||||||
|
mcp._mcp_server, elicitation_callback=elicitation_callback
|
||||||
|
) as client_session:
|
||||||
|
await client_session.initialize()
|
||||||
|
|
||||||
|
# Test both invalid schemas
|
||||||
|
for tool_name, field_name in [("invalid_list", "names"), ("nested_model", "nested")]:
|
||||||
|
result = await client_session.call_tool(tool_name, {})
|
||||||
|
assert len(result.content) == 1
|
||||||
|
assert isinstance(result.content[0], TextContent)
|
||||||
|
assert "Validation failed as expected" in result.content[0].text
|
||||||
|
assert field_name in result.content[0].text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_elicitation_with_optional_fields():
|
||||||
|
"""Test that Optional fields work correctly in elicitation schemas."""
|
||||||
|
mcp = FastMCP(name="OptionalFieldServer")
|
||||||
|
|
||||||
|
class OptionalSchema(BaseModel):
|
||||||
|
required_name: str = Field(description="Your name (required)")
|
||||||
|
optional_age: int | None = Field(default=None, description="Your age (optional)")
|
||||||
|
optional_email: str | None = Field(default=None, description="Your email (optional)")
|
||||||
|
subscribe: bool | None = Field(default=False, description="Subscribe to newsletter?")
|
||||||
|
|
||||||
|
@mcp.tool(description="Tool with optional fields")
|
||||||
|
async def optional_tool(ctx: Context) -> str:
|
||||||
|
result = await ctx.elicit(message="Please provide your information", schema=OptionalSchema)
|
||||||
|
|
||||||
|
if result.action == "accept" and result.data:
|
||||||
|
info = [f"Name: {result.data.required_name}"]
|
||||||
|
if result.data.optional_age is not None:
|
||||||
|
info.append(f"Age: {result.data.optional_age}")
|
||||||
|
if result.data.optional_email is not None:
|
||||||
|
info.append(f"Email: {result.data.optional_email}")
|
||||||
|
info.append(f"Subscribe: {result.data.subscribe}")
|
||||||
|
return ", ".join(info)
|
||||||
|
else:
|
||||||
|
return f"User {result.action}"
|
||||||
|
|
||||||
|
# Test cases with different field combinations
|
||||||
|
test_cases = [
|
||||||
|
(
|
||||||
|
# All fields provided
|
||||||
|
{"required_name": "John Doe", "optional_age": 30, "optional_email": "john@example.com", "subscribe": True},
|
||||||
|
"Name: John Doe, Age: 30, Email: john@example.com, Subscribe: True",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# Only required fields
|
||||||
|
{"required_name": "Jane Smith"},
|
||||||
|
"Name: Jane Smith, Subscribe: False",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for content, expected in test_cases:
|
||||||
|
|
||||||
|
async def callback(context, params):
|
||||||
|
return ElicitResult(action="accept", content=content)
|
||||||
|
|
||||||
|
await call_tool_and_assert(mcp, callback, "optional_tool", {}, expected)
|
||||||
|
|
||||||
|
# Test invalid optional field
|
||||||
|
class InvalidOptionalSchema(BaseModel):
|
||||||
|
name: str = Field(description="Name")
|
||||||
|
optional_list: list[str] | None = Field(default=None, description="Invalid optional list")
|
||||||
|
|
||||||
|
@mcp.tool(description="Tool with invalid optional field")
|
||||||
|
async def invalid_optional_tool(ctx: Context) -> str:
|
||||||
|
try:
|
||||||
|
await ctx.elicit(message="This should fail", schema=InvalidOptionalSchema)
|
||||||
|
return "Should not reach here"
|
||||||
|
except TypeError as e:
|
||||||
|
return f"Validation failed: {str(e)}"
|
||||||
|
|
||||||
|
await call_tool_and_assert(
|
||||||
|
mcp,
|
||||||
|
lambda c, p: ElicitResult(action="accept", content={}),
|
||||||
|
"invalid_optional_tool",
|
||||||
|
{},
|
||||||
|
text_contains=["Validation failed:", "optional_list"],
|
||||||
|
)
|
||||||
|
|
@ -14,14 +14,14 @@ from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from pydantic import AnyUrl
|
from pydantic import AnyUrl, BaseModel, Field
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from mcp.client.session import ClientSession
|
from mcp.client.session import ClientSession
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.server.fastmcp.resources import FunctionResource
|
from mcp.server.fastmcp.resources import FunctionResource
|
||||||
from mcp.shared.context import RequestContext
|
from mcp.shared.context import RequestContext
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
|
@ -30,6 +30,7 @@ from mcp.types import (
|
||||||
CompletionContext,
|
CompletionContext,
|
||||||
CreateMessageRequestParams,
|
CreateMessageRequestParams,
|
||||||
CreateMessageResult,
|
CreateMessageResult,
|
||||||
|
ElicitResult,
|
||||||
GetPromptResult,
|
GetPromptResult,
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
LoggingMessageNotification,
|
LoggingMessageNotification,
|
||||||
|
|
@ -98,6 +99,20 @@ def make_fastmcp_app():
|
||||||
def echo(message: str) -> str:
|
def echo(message: str) -> str:
|
||||||
return f"Echo: {message}"
|
return f"Echo: {message}"
|
||||||
|
|
||||||
|
# Add a tool that uses elicitation
|
||||||
|
@mcp.tool(description="A tool that uses elicitation")
|
||||||
|
async def ask_user(prompt: str, ctx: Context) -> str:
|
||||||
|
class AnswerSchema(BaseModel):
|
||||||
|
answer: str = Field(description="The user's answer to the question")
|
||||||
|
|
||||||
|
result = await ctx.elicit(message=f"Tool wants to ask: {prompt}", schema=AnswerSchema)
|
||||||
|
|
||||||
|
if result.action == "accept" and result.data:
|
||||||
|
return f"User answered: {result.data.answer}"
|
||||||
|
else:
|
||||||
|
# Handle cancellation or decline
|
||||||
|
return f"User cancelled or declined: {result.action}"
|
||||||
|
|
||||||
# Create the SSE app
|
# Create the SSE app
|
||||||
app = mcp.sse_app()
|
app = mcp.sse_app()
|
||||||
|
|
||||||
|
|
@ -261,6 +276,49 @@ def make_everything_fastmcp() -> FastMCP:
|
||||||
context_data["path"] = request.url.path
|
context_data["path"] = request.url.path
|
||||||
return json.dumps(context_data)
|
return json.dumps(context_data)
|
||||||
|
|
||||||
|
# Restaurant booking tool with elicitation
|
||||||
|
@mcp.tool(description="Book a table at a restaurant with elicitation")
|
||||||
|
async def book_restaurant(
|
||||||
|
date: str,
|
||||||
|
time: str,
|
||||||
|
party_size: int,
|
||||||
|
ctx: Context,
|
||||||
|
) -> str:
|
||||||
|
"""Book a table - uses elicitation if requested date is unavailable."""
|
||||||
|
|
||||||
|
class AlternativeDateSchema(BaseModel):
|
||||||
|
checkAlternative: bool = Field(description="Would you like to try another date?")
|
||||||
|
alternativeDate: str = Field(
|
||||||
|
default="2024-12-26",
|
||||||
|
description="What date would you prefer? (YYYY-MM-DD)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# For testing: assume dates starting with "2024-12-25" are unavailable
|
||||||
|
if date.startswith("2024-12-25"):
|
||||||
|
# Use elicitation to ask about alternatives
|
||||||
|
result = await ctx.elicit(
|
||||||
|
message=(
|
||||||
|
f"No tables available for {party_size} people on {date} "
|
||||||
|
f"at {time}. Would you like to check another date?"
|
||||||
|
),
|
||||||
|
schema=AlternativeDateSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.action == "accept" and result.data:
|
||||||
|
if result.data.checkAlternative:
|
||||||
|
alt_date = result.data.alternativeDate
|
||||||
|
return f"✅ Booked table for {party_size} on {alt_date} at {time}"
|
||||||
|
else:
|
||||||
|
return "❌ No booking made"
|
||||||
|
elif result.action in ("decline", "cancel"):
|
||||||
|
return "❌ Booking cancelled"
|
||||||
|
else:
|
||||||
|
# Handle case where action is "accept" but data is None
|
||||||
|
return "❌ No booking data received"
|
||||||
|
else:
|
||||||
|
# Available - book directly
|
||||||
|
return f"✅ Booked table for {party_size} on {date} at {time}"
|
||||||
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -652,6 +710,22 @@ class NotificationCollector:
|
||||||
await self.handle_tool_list_changed(message.root.params)
|
await self.handle_tool_list_changed(message.root.params)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_test_elicitation_callback(context, params):
|
||||||
|
"""Shared elicitation callback for tests.
|
||||||
|
|
||||||
|
Handles elicitation requests for restaurant booking tests.
|
||||||
|
"""
|
||||||
|
# For restaurant booking test
|
||||||
|
if "No tables available" in params.message:
|
||||||
|
return ElicitResult(
|
||||||
|
action="accept",
|
||||||
|
content={"checkAlternative": True, "alternativeDate": "2024-12-26"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Default response
|
||||||
|
return ElicitResult(action="decline")
|
||||||
|
|
||||||
|
|
||||||
async def call_all_mcp_features(session: ClientSession, collector: NotificationCollector) -> None:
|
async def call_all_mcp_features(session: ClientSession, collector: NotificationCollector) -> None:
|
||||||
"""
|
"""
|
||||||
Test all MCP features using the provided session.
|
Test all MCP features using the provided session.
|
||||||
|
|
@ -747,6 +821,21 @@ async def call_all_mcp_features(session: ClientSession, collector: NotificationC
|
||||||
assert "info" in log_levels
|
assert "info" in log_levels
|
||||||
assert "warning" in log_levels
|
assert "warning" in log_levels
|
||||||
|
|
||||||
|
# 5. Test elicitation tool
|
||||||
|
# Test restaurant booking with unavailable date (triggers elicitation)
|
||||||
|
booking_result = await session.call_tool(
|
||||||
|
"book_restaurant",
|
||||||
|
{
|
||||||
|
"date": "2024-12-25", # Unavailable date to trigger elicitation
|
||||||
|
"time": "19:00",
|
||||||
|
"party_size": 4,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert len(booking_result.content) == 1
|
||||||
|
assert isinstance(booking_result.content[0], TextContent)
|
||||||
|
# Should have booked the alternative date from elicitation callback
|
||||||
|
assert "✅ Booked table for 4 on 2024-12-26" in booking_result.content[0].text
|
||||||
|
|
||||||
# Test resources
|
# Test resources
|
||||||
# 1. Static resource
|
# 1. Static resource
|
||||||
resources = await session.list_resources()
|
resources = await session.list_resources()
|
||||||
|
|
@ -887,8 +976,6 @@ async def test_fastmcp_all_features_sse(everything_server: None, everything_serv
|
||||||
# Create notification collector
|
# Create notification collector
|
||||||
collector = NotificationCollector()
|
collector = NotificationCollector()
|
||||||
|
|
||||||
# Create a sampling callback that simulates an LLM
|
|
||||||
|
|
||||||
# Connect to the server with callbacks
|
# Connect to the server with callbacks
|
||||||
async with sse_client(everything_server_url + "/sse") as streams:
|
async with sse_client(everything_server_url + "/sse") as streams:
|
||||||
# Set up message handler to capture notifications
|
# Set up message handler to capture notifications
|
||||||
|
|
@ -901,6 +988,7 @@ async def test_fastmcp_all_features_sse(everything_server: None, everything_serv
|
||||||
async with ClientSession(
|
async with ClientSession(
|
||||||
*streams,
|
*streams,
|
||||||
sampling_callback=sampling_callback,
|
sampling_callback=sampling_callback,
|
||||||
|
elicitation_callback=create_test_elicitation_callback,
|
||||||
message_handler=message_handler,
|
message_handler=message_handler,
|
||||||
) as session:
|
) as session:
|
||||||
# Run the common test suite
|
# Run the common test suite
|
||||||
|
|
@ -933,7 +1021,37 @@ async def test_fastmcp_all_features_streamable_http(
|
||||||
read_stream,
|
read_stream,
|
||||||
write_stream,
|
write_stream,
|
||||||
sampling_callback=sampling_callback,
|
sampling_callback=sampling_callback,
|
||||||
|
elicitation_callback=create_test_elicitation_callback,
|
||||||
message_handler=message_handler,
|
message_handler=message_handler,
|
||||||
) as session:
|
) as session:
|
||||||
# Run the common test suite with HTTP-specific test suffix
|
# Run the common test suite with HTTP-specific test suffix
|
||||||
await call_all_mcp_features(session, collector)
|
await call_all_mcp_features(session, collector)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_elicitation_feature(server: None, server_url: str) -> None:
|
||||||
|
"""Test the elicitation feature."""
|
||||||
|
|
||||||
|
# Create a custom handler for elicitation requests
|
||||||
|
async def elicitation_callback(context, params):
|
||||||
|
# Verify the elicitation parameters
|
||||||
|
if params.message == "Tool wants to ask: What is your name?":
|
||||||
|
return ElicitResult(content={"answer": "Test User"}, action="accept")
|
||||||
|
else:
|
||||||
|
raise ValueError("Unexpected elicitation message")
|
||||||
|
|
||||||
|
# Connect to the server with our custom elicitation handler
|
||||||
|
async with sse_client(server_url + "/sse") as streams:
|
||||||
|
async with ClientSession(*streams, elicitation_callback=elicitation_callback) as session:
|
||||||
|
# First initialize the session
|
||||||
|
result = await session.initialize()
|
||||||
|
assert isinstance(result, InitializeResult)
|
||||||
|
assert result.serverInfo.name == "NoAuthServer"
|
||||||
|
|
||||||
|
# Call the tool that uses elicitation
|
||||||
|
tool_result = await session.call_tool("ask_user", {"prompt": "What is your name?"})
|
||||||
|
# Verify the result
|
||||||
|
assert len(tool_result.content) == 1
|
||||||
|
assert isinstance(tool_result.content[0], TextContent)
|
||||||
|
# # The test should only succeed with the successful elicitation response
|
||||||
|
assert tool_result.content[0].text == "User answered: Test User"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue