SEP-1330 enum schema support for elicitation

This commit is contained in:
Jeremiah Lowin 2025-12-04 11:30:03 -05:00
commit 9c14f0f73c
3 changed files with 419 additions and 54 deletions

View file

@ -250,6 +250,107 @@ async def set_priority(ctx: Context) -> str:
```
</CodeGroup>
### 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.
<CodeGroup>
```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)}"
```
</CodeGroup>
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

View file

@ -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."
)

View file

@ -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)