Support "no response" elicitation requests

This commit is contained in:
Jeremiah Lowin 2025-06-30 13:03:57 -04:00
commit eaf27c710d
7 changed files with 171 additions and 87 deletions

View file

@ -65,7 +65,7 @@ The elicitation handler receives four parameters:
</ResponseField>
<ResponseField name="response_type" type="type">
A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing and IDE support.
A Python dataclass type that FastMCP created from the server's JSON schema. Use this to construct your response with proper typing and IDE support. If the server requests an empty object (indicating no response), this will be `None`.
</ResponseField>
<ResponseField name="params" type="ElicitRequestParams">

View file

@ -68,8 +68,8 @@ async def collect_user_info(ctx: Context) -> str:
The prompt message to display to the user
</ResponseField>
<ResponseField name="response_type" type="type" default="str">
The Python type defining the expected response structure (dataclass, primitive type, etc.) Note that elicitation responses are subject to a restricted subset of JSON Schema types. See [Supported Response Types](#supported-response-types) for more details.
<ResponseField name="response_type" type="type" default="None">
The Python type defining the expected response structure (dataclass, primitive type, etc.) Note that elicitation responses are subject to a restricted subset of JSON Schema types. See [Supported Response Types](#supported-response-types) for more details.
</ResponseField>
</Expandable>
@ -140,7 +140,7 @@ The server must send a schema to the client indicating the type of data it expec
The MCP spec only supports a limited subset of JSON Schema types for elicitation responses. Specifically, it only supports JSON **objects** with **primitive** properties including `string`, `number` (or `integer`), `boolean` and `enum` fields.
FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`), by automatically wrapping them in MCP-compatible object schemas.
FastMCP makes it easy to request a broader range of types, including scalars (e.g. `str`) or no response at all, by automatically wrapping them in MCP-compatible object schemas.
### Scalar Types
@ -184,6 +184,22 @@ async def pick_a_boolean(ctx: Context) -> str:
```
</CodeGroup>
### No Response
Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. In this case, you can pass `None` as the response type to indicate that no response is expected. In order to comply with the MCP spec, the client will see a schema requesting an empty object in response. In this case, the `data` field of the `ElicitationResult` object will be `None` when the user accepts the elicitation.
```python {4} title="No response"
@mcp.tool
async def approve_action(ctx: Context) -> str:
"""Approve an action."""
result = await ctx.elicit("Approve this action?", response_type=None)
if result.action == "accept":
return do_action()
else:
raise ValueError("Action rejected")
```
### Constrained Options
Often you'll want to constrain the user's response to a specific set of values. You can do this by using a `Literal` type or a Python enum as the response type, or by passing a list of strings to the `response_type` parameter as a convenient shortcut.

View file

@ -41,7 +41,10 @@ def create_elicitation_callback(
params: ElicitRequestParams,
) -> MCPElicitResult | mcp.types.ErrorData:
try:
response_type = json_schema_to_type(params.requestedSchema)
if params.requestedSchema == {"type": "object", "properties": {}}:
response_type = None
else:
response_type = json_schema_to_type(params.requestedSchema)
result = await elicitation_handler(
params.message, response_type, params, context

View file

@ -7,7 +7,7 @@ from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
from enum import Enum
from typing import Literal, TypeVar, cast, get_origin
from typing import Any, Literal, TypeVar, cast, get_origin, overload
from mcp import LoggingLevel, ServerSession
from mcp.server.lowlevel.helper_types import ReadResourceContents
@ -312,11 +312,49 @@ class Context:
return result.content
@overload
async def elicit(
self,
message: str,
response_type: None,
) -> (
AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
): ...
"""When response_type is None, the accepted elicitaiton will contain an
empty dict"""
@overload
async def elicit(
self,
message: str,
response_type: type[T],
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is not None, the accepted elicitaiton will contain the
response data"""
@overload
async def elicit(
self,
message: str,
response_type: list[str],
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a list of strings, the accepted elicitaiton will
contain the selected string response"""
async def elicit(
self,
message: str,
response_type: type[T] | list[str] | None = None,
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation:
) -> (
AcceptedElicitation[T]
| AcceptedElicitation[dict[str, Any]]
| AcceptedElicitation[str]
| DeclinedElicitation
| CancelledElicitation
):
"""
Send an elicitation request to the client and await the response.
@ -330,6 +368,10 @@ class Context:
"value" field will be generated for the MCP interaction and
automatically deconstructed into the primitive type upon response.
If the response_type is None, the generated schema will be that of an
empty object in order to comply with the MCP protocol requirements.
Clients must send an empty object ("{}")in response.
Args:
message: A human-readable message explaining what information is needed
response_type: The type of the response, which should be a primitive
@ -337,48 +379,56 @@ class Context:
object schema with a single "value" field will be generated.
"""
if response_type is None:
response_type = str # type: ignore
schema = {"type": "object", "properties": {}}
else:
# if the user provided a list of strings, treat it as a Literal
if isinstance(response_type, list):
if not all(isinstance(item, str) for item in response_type):
raise ValueError(
"List of options must be a list of strings. Received: "
f"{response_type}"
)
# Convert list of options to Literal type and wrap
choice_literal = Literal[tuple(response_type)] # type: ignore
response_type = ScalarElicitationType[choice_literal] # type: ignore
# if the user provided a primitive scalar, wrap it in an object schema
elif response_type in {bool, int, float, str}:
response_type = ScalarElicitationType[response_type] # type: ignore
# if the user provided a Literal type, wrap it in an object schema
elif get_origin(response_type) is Literal:
response_type = ScalarElicitationType[response_type] # type: ignore
# if the user provided an Enum type, wrap it in an object schema
elif isinstance(response_type, type) and issubclass(response_type, Enum):
response_type = ScalarElicitationType[response_type] # type: ignore
# if the user provided a list of strings, treat it as a Literal
if isinstance(response_type, list):
if not all(isinstance(item, str) for item in response_type):
raise ValueError(
"List of options must be a list of strings. Received: "
f"{response_type}"
)
# Convert list of options to Literal type and wrap
choice_literal = Literal[tuple(response_type)] # type: ignore
response_type = ScalarElicitationType[choice_literal] # type: ignore
# if the user provided a primitive scalar, wrap it in an object schema
elif response_type in {bool, int, float, str}:
response_type = ScalarElicitationType[response_type] # type: ignore
# if the user provided a Literal type, wrap it in an object schema
elif get_origin(response_type) is Literal:
response_type = ScalarElicitationType[response_type] # type: ignore
# if the user provided an Enum type, wrap it in an object schema
elif isinstance(response_type, type) and issubclass(response_type, Enum):
response_type = ScalarElicitationType[response_type] # type: ignore
response_type = cast(type[T], response_type)
response_type = cast(type[T], response_type)
requested_schema = get_elicitation_schema(response_type)
schema = get_elicitation_schema(response_type)
result = await self.session.elicit(
message=message,
requestedSchema=requested_schema,
requestedSchema=schema,
related_request_id=self.request_id,
)
if result.action == "accept" and result.content:
type_adapter = get_cached_typeadapter(response_type)
validated_data = cast(
T | ScalarElicitationType[T],
type_adapter.validate_python(result.content),
)
if isinstance(validated_data, ScalarElicitationType):
return AcceptedElicitation[T](data=validated_data.value)
if result.action == "accept":
if response_type is not None:
type_adapter = get_cached_typeadapter(response_type)
validated_data = cast(
T | ScalarElicitationType[T],
type_adapter.validate_python(result.content),
)
if isinstance(validated_data, ScalarElicitationType):
return AcceptedElicitation[T](data=validated_data.value)
else:
return AcceptedElicitation[T](data=validated_data)
elif result.content:
raise ValueError(
"Elicitation expected an empty response, but received: "
f"{result.content}"
)
else:
return AcceptedElicitation[T](data=validated_data)
return AcceptedElicitation[dict[str, Any]](data={})
elif result.action == "decline":
return DeclinedElicitation()
elif result.action == "cancel":

View file

@ -81,11 +81,6 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None:
)
properties = schema.get("properties", {})
if not properties:
raise TypeError(
"Elicitation schema must have at least one property. "
"Empty object schemas are not allowed."
)
for prop_name, prop_schema in properties.items():
prop_type = prop_schema.get("type")

View file

@ -77,7 +77,3 @@ class FastMCPComponent(FastMCPBaseModel):
def disable(self) -> None:
"""Disable the component."""
self.enabled = False
def get_display_name(self) -> str:
"""Get the display name for this component, preferring title over name."""
return self.title if self.title is not None else self.name

View file

@ -1,8 +1,9 @@
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Literal
from typing import Any, Literal
import pytest
from mcp.types import ElicitRequestParams
from pydantic import BaseModel
from typing_extensions import TypedDict
@ -14,6 +15,7 @@ from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
validate_elicitation_json_schema,
)
from fastmcp.utilities.types import TypeAdapter
@ -79,30 +81,6 @@ async def test_elicitation_decline(fastmcp_server):
assert result.data == "No name provided."
async def test_default_response_type(fastmcp_server):
"""Test elicitation with string content."""
mcp = FastMCP("TestServer")
@mcp.tool
async def ask_for_color(context: Context) -> str:
result = await context.elicit(
message="What is your favorite color?"
# Default schema should be string
)
if result.action == "accept":
assert isinstance(result.data, str)
return f"Your favorite color is {result.data}!"
return "No color provided"
async def elicitation_handler(message, response_type, params, ctx):
# Mock user providing their favorite color as string in content dict
return ElicitResult(action="accept", content={"value": "blue"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("ask_for_color", {})
assert result.data == "Your favorite color is blue!"
async def test_elicitation_handler_parameters():
"""Test that elicitation handler receives correct parameters."""
mcp = FastMCP("TestServer")
@ -162,6 +140,62 @@ async def test_elicitation_cancel_action():
class TestScalarResponseTypes:
async def test_elicitation_no_response(self):
"""Test elicitation with no response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> None:
result = await context.elicit(message="", response_type=None)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(
message, response_type, params: ElicitRequestParams, ctx
):
assert params.requestedSchema == {"type": "object", "properties": {}}
assert response_type == dict[str, Any]
return ElicitResult(action="accept")
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data is None
async def test_elicitation_empty_response(self):
"""Test elicitation with empty response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> None:
result = await context.elicit(message="", response_type=None)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(
message, response_type, params: ElicitRequestParams, ctx
):
return ElicitResult(action="accept", content={})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
result = await client.call_tool("my_tool", {})
assert result.data is None
async def test_elicitation_response_when_no_response_requested(self):
"""Test elicitation with no response type."""
mcp = FastMCP("TestServer")
@mcp.tool
async def my_tool(context: Context) -> None:
result = await context.elicit(message="", response_type=None)
return result.data # type: ignore[attr-defined]
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"value": "hello"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
with pytest.raises(
ToolError, match="Elicitation expected an empty response"
):
await client.call_tool("my_tool", {})
async def test_elicitation_str_response(self):
"""Test elicitation with string schema."""
mcp = FastMCP("TestServer")
@ -262,7 +296,7 @@ class TestScalarResponseTypes:
result = await client.call_tool("my_tool", {})
assert result.data == "x"
async def test_elicitation_list_response(self):
async def test_elicitation_list_of_strings_response(self):
"""Test elicitation with list schema."""
mcp = FastMCP("TestServer")
@ -459,21 +493,12 @@ async def test_all_primitive_field_types():
class TestValidation:
async def test_schema_validation_rejects_non_object(self):
"""Test that non-object schemas are rejected."""
from fastmcp.server.elicitation import validate_elicitation_json_schema
with pytest.raises(TypeError, match="must be an object schema"):
validate_elicitation_json_schema({"type": "string"})
async def test_schema_validation_rejects_empty_object(self):
"""Test that object schemas without properties are rejected."""
from fastmcp.server.elicitation import validate_elicitation_json_schema
with pytest.raises(TypeError, match="must have at least one property"):
validate_elicitation_json_schema({"type": "object"})
async def test_schema_validation_rejects_nested_objects(self):
"""Test that nested object schemas are rejected."""
from fastmcp.server.elicitation import validate_elicitation_json_schema
with pytest.raises(
TypeError, match="has type 'object' which is not a primitive type"
@ -492,7 +517,6 @@ class TestValidation:
async def test_schema_validation_rejects_arrays(self):
"""Test that array schemas are rejected."""
from fastmcp.server.elicitation import validate_elicitation_json_schema
with pytest.raises(
TypeError, match="has type 'array' which is not a primitive type"