Add response_title and response_description to ctx.elicit() (#3912)

This commit is contained in:
Jeremiah Lowin 2026-04-13 20:12:09 -04:00 committed by William Easton
commit c82395a9a2
No known key found for this signature in database
4 changed files with 242 additions and 18 deletions

View file

@ -156,6 +156,28 @@ async def pick_a_boolean(ctx: Context) -> str:
```
</CodeGroup>
#### Customizing the Field Label
<VersionBadge version="3.3.0" />
When FastMCP wraps a scalar, `Literal`, `Enum`, or one of the constrained-option shorthands, the wrapper's `value` property is labelled `"Value"` by default — and some clients (including VS Code) render that label directly in the UI. Pass `response_title` and `response_description` to override it:
```python
@mcp.tool
async def confirm_purchase(ctx: Context) -> str:
result = await ctx.elicit(
"Buy 1x Baguette?",
response_type=bool,
response_title="Confirm purchase",
response_description="Approve this transaction?",
)
if result.action == "accept":
return "Purchased" if result.data else "Declined"
return "No response"
```
These arguments only apply when FastMCP is adding the wrapper. For structured responses (`BaseModel`, dataclass, `TypedDict`), set the metadata on the individual fields via `Field(title=..., description=...)` — passing `response_title` or `response_description` alongside a model type raises `TypeError`.
### No Response
Sometimes, the goal of an elicitation is to simply get a user to approve or reject an action. Pass `None` as the response type to indicate that no data is expected. The `data` field will be `None` when the user accepts.

View file

@ -1017,6 +1017,9 @@ class Context:
self,
message: str,
response_type: None,
*,
response_title: str | None = None,
response_description: str | None = None,
) -> (
AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation
): ...
@ -1029,6 +1032,9 @@ class Context:
self,
message: str,
response_type: type[T],
*,
response_title: str | None = None,
response_description: str | None = None,
) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is not None, the accepted elicitation will contain the
@ -1039,6 +1045,9 @@ class Context:
self,
message: str,
response_type: list[str],
*,
response_title: str | None = None,
response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a list of strings, the accepted elicitation will
@ -1049,6 +1058,9 @@ class Context:
self,
message: str,
response_type: dict[str, dict[str, str]],
*,
response_title: str | None = None,
response_description: str | None = None,
) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ...
"""When response_type is a dict mapping keys to title dicts, the accepted
@ -1059,6 +1071,9 @@ class Context:
self,
message: str,
response_type: list[list[str]],
*,
response_title: str | None = None,
response_description: str | None = None,
) -> (
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
): ...
@ -1071,6 +1086,9 @@ class Context:
self,
message: str,
response_type: list[dict[str, dict[str, str]]],
*,
response_title: str | None = None,
response_description: str | None = None,
) -> (
AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation
): ...
@ -1088,6 +1106,9 @@ class Context:
| list[list[str]]
| list[dict[str, dict[str, str]]]
| None = None,
*,
response_title: str | None = None,
response_description: str | None = None,
) -> (
AcceptedElicitation[T]
| AcceptedElicitation[dict[str, Any]]
@ -1118,13 +1139,25 @@ class Context:
response_type: The type of the response, which should be a primitive
type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
response_title: Optional label to display for the wrapped ``value``
field when ``response_type`` is a scalar, Literal, Enum, or one
of the dict/list shorthand forms. Overrides the auto-generated
"Value" label. Raises ``TypeError`` if passed with a BaseModel,
dataclass, or ``None`` response type (use ``Field(title=...)``
on the model instead).
response_description: Optional description to attach to the wrapped
``value`` field. Same scope rules as ``response_title``.
Note:
This method works transparently in both request and background task
contexts. In background task mode (SEP-1686), it will set the task
status to "input_required" and wait for the client to provide input.
"""
config = parse_elicit_response_type(response_type)
config = parse_elicit_response_type(
response_type,
response_title=response_title,
response_description=response_description,
)
if self.is_background_task:
# Background task mode: use task-aware elicitation

View file

@ -129,7 +129,11 @@ class ElicitConfig:
is_raw: bool
def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
def parse_elicit_response_type(
response_type: Any,
response_title: str | None = None,
response_description: str | None = None,
) -> ElicitConfig:
"""Parse response_type into schema and handling configuration.
Supports multiple syntaxes:
@ -142,8 +146,25 @@ def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
- `list[X]` type annotation: multi-select with type
- Scalar types (bool, int, float, str, Literal, Enum): single value
- Other types (dataclass, BaseModel): use directly
The ``response_title`` and ``response_description`` arguments customize the
label and description of the wrapped ``value`` property for the scalar/dict/list
shorthand forms. They are only valid when FastMCP is wrapping the response
type; passing them with a full BaseModel/dataclass (or ``None``) raises
``TypeError``, because in those cases the user already controls field
metadata via ``Field(title=..., description=...)``.
"""
has_response_metadata = (
response_title is not None or response_description is not None
)
if response_type is None:
if has_response_metadata:
raise TypeError(
"response_title and response_description are not supported when "
"response_type is None, because the elicitation schema has no "
"fields to label."
)
return ElicitConfig(
schema={"type": "object", "properties": {}},
response_type=None,
@ -151,23 +172,46 @@ def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
)
if isinstance(response_type, dict):
return _parse_dict_syntax(response_type)
config = _parse_dict_syntax(response_type)
elif isinstance(response_type, list):
config = _parse_list_syntax(response_type)
elif get_origin(response_type) is list:
config = _parse_generic_list(response_type)
elif _is_scalar_type(response_type):
config = _parse_scalar_type(response_type)
else:
# Other types (dataclass, BaseModel, etc.) - use directly
if has_response_metadata:
raise TypeError(
"response_title and response_description are only supported when "
"response_type is a scalar, Literal, Enum, or the dict/list "
"shorthand forms. For BaseModel or dataclass response types, use "
"Field(title=..., description=...) on the individual fields."
)
return ElicitConfig(
schema=get_elicitation_schema(response_type),
response_type=response_type,
is_raw=False,
)
if isinstance(response_type, list):
return _parse_list_syntax(response_type)
if has_response_metadata:
_apply_value_metadata(config.schema, response_title, response_description)
return config
if get_origin(response_type) is list:
return _parse_generic_list(response_type)
if _is_scalar_type(response_type):
return _parse_scalar_type(response_type)
# Other types (dataclass, BaseModel, etc.) - use directly
return ElicitConfig(
schema=get_elicitation_schema(response_type),
response_type=response_type,
is_raw=False,
)
def _apply_value_metadata(
schema: dict[str, Any],
title: str | None,
description: str | None,
) -> None:
"""Override title/description on the wrapped ``value`` property in-place."""
value_schema = schema.get("properties", {}).get("value")
if value_schema is None:
return
if title is not None:
value_schema["title"] = title
if description is not None:
value_schema["description"] = description
def _is_scalar_type(response_type: Any) -> bool:

View file

@ -117,6 +117,131 @@ async def test_elicitation_handler_parameters():
assert captured_params["ctx"] is not None
async def test_elicitation_response_title_and_description_on_scalar():
"""response_title and response_description customize the wrapped `value` field."""
mcp = FastMCP("TestServer")
captured_schema: dict[str, Any] = {}
@mcp.tool
async def confirm_purchase(context: Context) -> str:
result = await context.elicit(
message="Buy 1x Baguette?",
response_type=bool,
response_title="Confirm purchase",
response_description="Approve this transaction?",
)
if isinstance(result, AcceptedElicitation):
return "confirmed" if result.data else "rejected"
return "no answer"
async def elicitation_handler(message, response_type, params, ctx):
captured_schema.update(params.requestedSchema)
return ElicitResult(action="accept", content={"value": True})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
await client.call_tool("confirm_purchase", {})
assert captured_schema["properties"]["value"]["title"] == "Confirm purchase"
assert (
captured_schema["properties"]["value"]["description"]
== "Approve this transaction?"
)
assert captured_schema["properties"]["value"]["type"] == "boolean"
async def test_elicitation_response_title_on_dict_shorthand():
"""response_title applies to the `value` property for dict shorthand."""
mcp = FastMCP("TestServer")
captured_schema: dict[str, Any] = {}
@mcp.tool
async def pick_priority(context: Context) -> str:
result = await context.elicit(
message="Priority?",
response_type={"low": {"title": "Low"}, "high": {"title": "High"}},
response_title="Priority level",
)
return "ok" if isinstance(result, AcceptedElicitation) else "none"
async def elicitation_handler(message, response_type, params, ctx):
captured_schema.update(params.requestedSchema)
return ElicitResult(action="accept", content={"value": "low"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
await client.call_tool("pick_priority", {})
assert captured_schema["properties"]["value"]["title"] == "Priority level"
async def test_elicitation_response_title_on_list_shorthand():
"""response_title applies to the `value` property for list shorthand."""
mcp = FastMCP("TestServer")
captured_schema: dict[str, Any] = {}
@mcp.tool
async def pick_color(context: Context) -> str:
result = await context.elicit(
message="Color?",
response_type=["red", "green", "blue"],
response_title="Favorite color",
)
return "ok" if isinstance(result, AcceptedElicitation) else "none"
async def elicitation_handler(message, response_type, params, ctx):
captured_schema.update(params.requestedSchema)
return ElicitResult(action="accept", content={"value": "red"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
await client.call_tool("pick_color", {})
assert captured_schema["properties"]["value"]["title"] == "Favorite color"
async def test_elicitation_response_title_rejected_for_basemodel():
"""response_title raises TypeError when response_type is a BaseModel."""
mcp = FastMCP("TestServer")
class Person(BaseModel):
name: str
@mcp.tool
async def ask(context: Context) -> str:
await context.elicit(
message="Name?",
response_type=Person,
response_title="Not allowed",
)
return "done"
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={"name": "x"})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
with pytest.raises(ToolError, match="response_title"):
await client.call_tool("ask", {})
async def test_elicitation_response_title_rejected_for_none():
"""response_title raises TypeError when response_type is None."""
mcp = FastMCP("TestServer")
@mcp.tool
async def ask(context: Context) -> str:
await context.elicit(
message="Confirm?",
response_type=None,
response_title="Not allowed",
)
return "done"
async def elicitation_handler(message, response_type, params, ctx):
return ElicitResult(action="accept", content={})
async with Client(mcp, elicitation_handler=elicitation_handler) as client:
with pytest.raises(ToolError, match="response_title"):
await client.call_tool("ask", {})
async def test_elicitation_cancel_action():
"""Test user canceling elicitation request."""
mcp = FastMCP("TestServer")