diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx index 600a3d2fe..923e704c6 100644 --- a/docs/servers/elicitation.mdx +++ b/docs/servers/elicitation.mdx @@ -156,6 +156,28 @@ async def pick_a_boolean(ctx: Context) -> str: ``` +#### Customizing the Field Label + + + +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. diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index afaae2f5a..18587bf14 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -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 diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py index caa53049e..5947d223b 100644 --- a/src/fastmcp/server/elicitation.py +++ b/src/fastmcp/server/elicitation.py @@ -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: diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index 3ca43a507..b5d16bf4d 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -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")