From 9c14f0f73cec3695ab0c8d4431663828a62dda64 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:30:03 -0500 Subject: [PATCH] SEP-1330 enum schema support for elicitation --- docs/servers/elicitation.mdx | 101 +++++++++++++ src/fastmcp/server/elicitation.py | 146 +++++++++++++------ tests/client/test_elicitation.py | 230 ++++++++++++++++++++++++++++-- 3 files changed, 421 insertions(+), 56 deletions(-) diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx index 7b07c0fdb..596e8b892 100644 --- a/docs/servers/elicitation.mdx +++ b/docs/servers/elicitation.mdx @@ -250,6 +250,107 @@ async def set_priority(ctx: Context) -> str: ``` +### Multi-Select Enums + +Enable multi-select by wrapping your choices in an additional list level. This allows users to select multiple values from the available options. + + +```python {6-8} title="List of a list of strings" +@mcp.tool +async def select_tags(ctx: Context) -> str: + """Select multiple tags.""" + result = await ctx.elicit( + "Choose tags", + response_type=[["bug", "feature", "documentation"]] # Note: list of a list + ) + + if result.action == "accept": + tags = result.data # List of selected strings + return f"Selected tags: {', '.join(tags)}" +``` + +```python {1, 3-6, 11-14} title="list[Enum] type annotation" +from enum import Enum + +class Tag(Enum): + BUG = "bug" + FEATURE = "feature" + DOCS = "documentation" + +@mcp.tool +async def select_tags(ctx: Context) -> str: + result = await ctx.elicit( + "Choose tags", + response_type=list[Tag] # Type annotation for multi-select + ) + if result.action == "accept": + tags = [tag.value for tag in result.data] + return f"Selected: {', '.join(tags)}" +``` + + +For titled multi-select, wrap a dict in a list (see [Titled Enums](#titled-enums) for dict syntax): + +```python {6-12} +@mcp.tool +async def select_priorities(ctx: Context) -> str: + """Select multiple priorities.""" + result = await ctx.elicit( + "Choose priorities", + response_type=[{ # Note: list containing a dict + "low": {"title": "Low Priority"}, + "medium": {"title": "Medium Priority"}, + "high": {"title": "High Priority"} + }] + ) + + if result.action == "accept": + priorities = result.data # List of selected strings + return f"Selected: {', '.join(priorities)}" +``` + +### Titled Enums + +For better UI display, you can provide human-readable titles for enum options. FastMCP generates SEP-1330 compliant schemas using the `oneOf` pattern with `const` and `title` fields. + +You can use a dict to specify titles for enum values: + +```python {6-10} +@mcp.tool +async def set_priority(ctx: Context) -> str: + """Set task priority level.""" + result = await ctx.elicit( + "What priority level?", + response_type={ + "low": {"title": "Low Priority"}, + "medium": {"title": "Medium Priority"}, + "high": {"title": "High Priority"} + } + ) + + if result.action == "accept": + return f"Priority set to: {result.data}" +``` + +For multi-select with titles, wrap the dict in a list: + +```python {6-12} +@mcp.tool +async def select_priorities(ctx: Context) -> str: + """Select multiple priorities.""" + result = await ctx.elicit( + "Choose priorities", + response_type=[{ # List containing a dict for multi-select + "low": {"title": "Low Priority"}, + "medium": {"title": "Medium Priority"}, + "high": {"title": "High Priority"} + }] + ) + + if result.action == "accept": + priorities = result.data # List of selected strings + return f"Selected: {', '.join(priorities)}" +``` ### Structured Responses diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py index f5b1951d7..403b604f2 100644 --- a/src/fastmcp/server/elicitation.py +++ b/src/fastmcp/server/elicitation.py @@ -38,49 +38,63 @@ class ElicitationJsonSchema(GenerateJsonSchema): """ def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: - """Override to prevent ref generation for enums.""" + """Override to prevent ref generation for enums and handle list schemas.""" # For enum schemas, bypass the ref mechanism entirely if schema["type"] == "enum": # Directly call our custom enum_schema without going through handler # This prevents the ref/defs mechanism from being invoked return self.enum_schema(schema) + # For list schemas, check if items are enums + if schema["type"] == "list": + return self.list_schema(schema) # For all other types, use the default implementation return super().generate_inner(schema) + def list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue: + """Generate schema for list types, detecting enum items for multi-select.""" + items_schema = schema.get("items_schema") + + # Check if items are enum/Literal + if items_schema and items_schema.get("type") == "enum": + # Generate array with enum items + items = self.enum_schema(items_schema) + # If items have oneOf pattern, convert to anyOf for multi-select per SEP-1330 + if "oneOf" in items: + items = {"anyOf": items["oneOf"]} + return { + "type": "array", + "items": items, # Will be {"enum": [...]} or {"anyOf": [...]} + } + + # Check if items are Literal (which Pydantic represents differently) + if items_schema: + # Try to detect Literal patterns + items_result = super().generate_inner(items_schema) + # If it's a const pattern or enum-like, allow it + if ( + "const" in items_result + or "enum" in items_result + or "oneOf" in items_result + ): + # Convert oneOf to anyOf for multi-select + if "oneOf" in items_result: + items_result = {"anyOf": items_result["oneOf"]} + return { + "type": "array", + "items": items_result, + } + + # Default behavior for non-enum arrays + return super().list_schema(schema) + def enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue: - """Generate inline enum schema with optional enumNames for better UI. + """Generate inline enum schema. - If enum members have a _display_name_ attribute or custom __str__, - we'll include enumNames for better UI representation. + Always generates enum pattern: {"enum": [value, ...]} + Titled enums are handled separately via dict-based syntax in ctx.elicit(). """ - # Get the base schema from parent - result = super().enum_schema(schema) - - # Try to add enumNames if the enum has display-friendly names - enum_cls = schema.get("cls") - if enum_cls: - members = schema.get("members", []) - enum_names = [] - has_custom_names = False - - for member in members: - # Check if member has a custom display name attribute - if hasattr(member, "_display_name_"): - enum_names.append(member._display_name_) - has_custom_names = True - # Or use the member name with better formatting - else: - # Convert SNAKE_CASE to Title Case for display - display_name = member.name.replace("_", " ").title() - enum_names.append(display_name) - if display_name != member.value: - has_custom_names = True - - # Only add enumNames if they differ from the values - if has_custom_names: - result["enumNames"] = enum_names - - return result + # Get the base schema from parent - always use simple enum pattern + return super().enum_schema(schema) # we can't use the low-level AcceptedElicitation because it only works with BaseModels @@ -96,6 +110,27 @@ class ScalarElicitationType(Generic[T]): value: T +def _dict_to_enum_schema( + enum_dict: dict[str, dict[str, str]], multi_select: bool = False +) -> dict[str, Any]: + """Convert dict enum to SEP-1330 compliant schema pattern. + + Args: + enum_dict: {"low": {"title": "Low Priority"}, "medium": {"title": "Medium Priority"}} + multi_select: If True, use anyOf pattern; if False, use oneOf pattern + + Returns: + {"oneOf": [{"const": "low", "title": "Low Priority"}, ...]} for single-select + {"anyOf": [{"const": "low", "title": "Low Priority"}, ...]} for multi-select + """ + pattern_key = "anyOf" if multi_select else "oneOf" + pattern = [] + for value, metadata in enum_dict.items(): + title = metadata.get("title", value) + pattern.append({"const": value, "title": title}) + return {pattern_key: pattern} + + def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]: """Get the schema for an elicitation response. @@ -197,20 +232,7 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None: ) continue - # Check if it's a primitive type - if prop_type not in ALLOWED_TYPES: - raise TypeError( - f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not " - f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas." - ) - - # Check for nested objects or arrays of objects (not allowed) - if prop_type == "object": - raise TypeError( - f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. " - "Elicitation schemas must be flat objects with primitive properties only." - ) - + # Check for arrays before checking primitive types if prop_type == "array": items_schema = prop_schema.get("items", {}) if items_schema.get("type") == "object": @@ -218,3 +240,35 @@ def validate_elicitation_json_schema(schema: dict[str, Any]) -> None: f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. " "Elicitation schemas must be flat objects with primitive properties only." ) + + # Allow arrays with enum patterns (for multi-select) + if "enum" in items_schema: + continue # Allowed: {"type": "array", "items": {"enum": [...]}} + + # Allow arrays with oneOf/anyOf const patterns (SEP-1330) + if "oneOf" in items_schema or "anyOf" in items_schema: + union_schemas = items_schema.get("oneOf", []) + items_schema.get( + "anyOf", [] + ) + if union_schemas and all("const" in s for s in union_schemas): + continue # Allowed: {"type": "array", "items": {"anyOf": [{"const": ...}, ...]}} + + # Reject other array types (e.g., arrays of primitives without enum pattern) + raise TypeError( + f"Elicitation schema field '{prop_name}' is an array, but arrays are only allowed " + "when items are enums (for multi-select). Only enum arrays are supported in elicitation schemas." + ) + + # Check for nested objects (not allowed) + if prop_type == "object": + raise TypeError( + f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. " + "Elicitation schemas must be flat objects with primitive properties only." + ) + + # Check if it's a primitive type + if prop_type not in ALLOWED_TYPES: + raise TypeError( + f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not " + f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas." + ) diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index ba98c2fe4..7c0a6092b 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -691,8 +691,8 @@ def test_enum_elicitation_schema_inline(): assert schema["properties"]["title"]["type"] == "string" -def test_enum_elicitation_schema_with_enum_names(): - """Test that enum schemas can include enumNames for better UI display.""" +def test_enum_elicitation_schema_inline_untitled(): + """Test that enum schemas generate simple enum pattern (no automatic titles).""" class TaskStatus(Enum): NOT_STARTED = "not_started" @@ -713,7 +713,10 @@ def test_enum_elicitation_schema_with_enum_names(): assert "$ref" not in str(schema) status_schema = schema["properties"]["status"] + # Should generate simple enum pattern (no automatic title generation) assert "enum" in status_schema + assert "oneOf" not in status_schema + assert "enumNames" not in status_schema assert status_schema["enum"] == [ "not_started", "in_progress", @@ -721,11 +724,218 @@ def test_enum_elicitation_schema_with_enum_names(): "on_hold", ] - # Check if enumNames were added for display - assert "enumNames" in status_schema - assert status_schema["enumNames"] == [ - "Not Started", - "In Progress", - "Completed", - "On Hold", - ] + +async def test_dict_based_titled_single_select(): + """Test dict-based titled single-select enum.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(ctx: Context) -> str: + result = await ctx.elicit( + "Choose priority", + response_type={ + "low": {"title": "Low Priority"}, + "high": {"title": "High Priority"}, + }, + ) + if result.action == "accept": + return result.data # type: ignore[attr-defined] + return "declined" + + async def elicitation_handler(message, response_type, params, ctx): + return ElicitResult(action="accept", content={"value": "low"}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data == "low" + + +async def test_list_list_multi_select_untitled(): + """Test list[list[str]] for multi-select untitled shorthand.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(ctx: Context) -> str: + result = await ctx.elicit( + "Choose tags", + response_type=[["bug", "feature", "documentation"]], + ) + if result.action == "accept": + return ",".join(result.data) # type: ignore[attr-defined] + return "declined" + + async def elicitation_handler(message, response_type, params, ctx): + # Verify schema has array with enum pattern + schema = params.requestedSchema + assert schema["type"] == "object" + assert "value" in schema["properties"] + value_schema = schema["properties"]["value"] + assert value_schema["type"] == "array" + assert "enum" in value_schema["items"] + assert value_schema["items"]["enum"] == ["bug", "feature", "documentation"] + + return ElicitResult(action="accept", content={"value": ["bug", "feature"]}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data == "bug,feature" + + +async def test_list_dict_multi_select_titled(): + """Test list[dict] for multi-select titled.""" + mcp = FastMCP("TestServer") + + @mcp.tool + async def my_tool(ctx: Context) -> str: + result = await ctx.elicit( + "Choose priorities", + response_type=[ + { + "low": {"title": "Low Priority"}, + "high": {"title": "High Priority"}, + } + ], + ) + if result.action == "accept": + return ",".join(result.data) # type: ignore[attr-defined] + return "declined" + + async def elicitation_handler(message, response_type, params, ctx): + # Verify schema has array with anyOf pattern + schema = params.requestedSchema + assert schema["type"] == "object" + assert "value" in schema["properties"] + value_schema = schema["properties"]["value"] + assert value_schema["type"] == "array" + assert "anyOf" in value_schema["items"] + any_of = value_schema["items"]["anyOf"] + assert {"const": "low", "title": "Low Priority"} in any_of + assert {"const": "high", "title": "High Priority"} in any_of + + return ElicitResult(action="accept", content={"value": ["low", "high"]}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data == "low,high" + + +async def test_list_enum_multi_select(): + """Test list[Enum] for multi-select with enum in dataclass field.""" + + class Priority(Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + @dataclass + class TaskRequest: + priorities: list[Priority] + + schema = get_elicitation_schema(TaskRequest) + + priorities_schema = schema["properties"]["priorities"] + assert priorities_schema["type"] == "array" + assert "items" in priorities_schema + items_schema = priorities_schema["items"] + # Should have enum pattern for untitled enums + assert "enum" in items_schema + assert items_schema["enum"] == ["low", "medium", "high"] + + +async def test_list_enum_multi_select_direct(): + """Test list[Enum] type annotation passed directly to ctx.elicit().""" + mcp = FastMCP("TestServer") + + class Priority(Enum): + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + @mcp.tool + async def my_tool(ctx: Context) -> str: + result = await ctx.elicit( + "Choose priorities", + response_type=list[Priority], # Type annotation for multi-select + ) + if result.action == "accept": + priorities = result.data # type: ignore[attr-defined] + return ",".join( + [p.value if isinstance(p, Priority) else str(p) for p in priorities] + ) + return "declined" + + async def elicitation_handler(message, response_type, params, ctx): + # Verify schema has array with enum pattern + schema = params.requestedSchema + assert schema["type"] == "object" + assert "value" in schema["properties"] + value_schema = schema["properties"]["value"] + assert value_schema["type"] == "array" + assert "enum" in value_schema["items"] + assert value_schema["items"]["enum"] == ["low", "medium", "high"] + + return ElicitResult(action="accept", content={"value": ["low", "high"]}) + + async with Client(mcp, elicitation_handler=elicitation_handler) as client: + result = await client.call_tool("my_tool", {}) + assert result.data == "low,high" + + +async def test_validation_allows_enum_arrays(): + """Test validation accepts arrays with enum items.""" + schema = { + "type": "object", + "properties": { + "priorities": { + "type": "array", + "items": {"enum": ["low", "medium", "high"]}, + } + }, + } + validate_elicitation_json_schema(schema) # Should not raise + + +async def test_validation_allows_enum_arrays_with_anyof(): + """Test validation accepts arrays with anyOf enum pattern.""" + schema = { + "type": "object", + "properties": { + "priorities": { + "type": "array", + "items": { + "anyOf": [ + {"const": "low", "title": "Low Priority"}, + {"const": "high", "title": "High Priority"}, + ] + }, + } + }, + } + validate_elicitation_json_schema(schema) # Should not raise + + +async def test_validation_rejects_non_enum_arrays(): + """Test validation still rejects arrays of objects.""" + schema = { + "type": "object", + "properties": { + "users": { + "type": "array", + "items": {"type": "object", "properties": {"name": {"type": "string"}}}, + } + }, + } + with pytest.raises(TypeError, match="array of objects"): + validate_elicitation_json_schema(schema) + + +async def test_validation_rejects_primitive_arrays(): + """Test validation rejects arrays of primitives without enum pattern.""" + schema = { + "type": "object", + "properties": { + "names": {"type": "array", "items": {"type": "string"}}, + }, + } + with pytest.raises(TypeError, match="arrays are only allowed"): + validate_elicitation_json_schema(schema)