mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Add response_title and response_description to ctx.elicit() (#3912)
This commit is contained in:
parent
3117846176
commit
110cd3adcb
4 changed files with 242 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue