From c3ffef677b634df59ae245f0da6130e4637ca133 Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Tue, 9 Dec 2025 12:22:30 -0500
Subject: [PATCH] SEP-1330 enum schema support (#2549)
* SEP-1330 enum schema support for elicitation
* Add version badges for 2.14.0 elicitation features
* Fix Context.elicit() to handle SEP-1330 enum syntaxes
* Guard against empty list in elicit response_type
* Add guards for empty dict/list edge cases in elicit
* Refactor elicit: extract parsing and response handling to elicitation.py
---
docs/servers/elicitation.mdx | 120 ++++++++++-
src/fastmcp/server/context.py | 54 +----
src/fastmcp/server/elicitation.py | 332 +++++++++++++++++++++++++-----
tests/client/test_elicitation.py | 238 +++++++++++++++++++--
4 files changed, 628 insertions(+), 116 deletions(-)
diff --git a/docs/servers/elicitation.mdx b/docs/servers/elicitation.mdx
index b66f186ce..18e615d0a 100644
--- a/docs/servers/elicitation.mdx
+++ b/docs/servers/elicitation.mdx
@@ -3,6 +3,7 @@ title: User Elicitation
sidebarTitle: Elicitation
description: Request structured input from users during tool execution through the MCP context.
icon: message-question
+tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
@@ -209,10 +210,10 @@ Often you'll want to constrain the user's response to a specific set of values.
async def set_priority(ctx: Context) -> str:
"""Set task priority level."""
result = await ctx.elicit(
- "What priority level?",
+ "What priority level?",
response_type=["low", "medium", "high"],
)
-
+
if result.action == "accept":
return f"Priority set to: {result.data}"
```
@@ -223,10 +224,10 @@ from typing import Literal
async def set_priority(ctx: Context) -> str:
"""Set task priority level."""
result = await ctx.elicit(
- "What priority level?",
+ "What priority level?",
response_type=Literal["low", "medium", "high"]
)
-
+
if result.action == "accept":
return f"Priority set to: {result.data}"
return "No priority set"
@@ -237,19 +238,124 @@ from enum import Enum
class Priority(Enum):
LOW = "low"
MEDIUM = "medium"
- HIGH = "high"
+ HIGH = "high"
@mcp.tool
async def set_priority(ctx: Context) -> str:
"""Set task priority level."""
result = await ctx.elicit("What priority level?", response_type=Priority)
-
+
if result.action == "accept":
return f"Priority set to: {result.data.value}"
return "No priority set"
```
+#### Multi-Select
+
+
+
+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 Options](#titled-options) 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 Options
+
+
+
+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.
+
+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
@@ -282,6 +388,8 @@ async def create_task(ctx: Context) -> str:
### Default Values
+
+
You can provide default values for elicitation fields using Pydantic's `Field(default=...)`. Clients will pre-populate form fields with these defaults, making it easier for users to provide input.
Default values are supported for all primitive types:
diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py
index 9b137add3..393e08853 100644
--- a/src/fastmcp/server/context.py
+++ b/src/fastmcp/server/context.py
@@ -8,9 +8,8 @@ from collections.abc import Generator, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar, Token
from dataclasses import dataclass
-from enum import Enum
from logging import Logger
-from typing import Any, Literal, cast, get_origin, overload
+from typing import Any, overload
import anyio
from mcp import LoggingLevel, ServerSession
@@ -41,12 +40,11 @@ from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
- ScalarElicitationType,
- get_elicitation_schema,
+ handle_elicit_accept,
+ parse_elicit_response_type,
)
from fastmcp.server.server import FastMCP
from fastmcp.utilities.logging import _clamp_logger, get_logger
-from fastmcp.utilities.types import get_cached_typeadapter
logger: Logger = get_logger(name=__name__)
to_client_logger: Logger = logger.getChild(suffix="to_client")
@@ -674,61 +672,21 @@ class Context:
type or dataclass or BaseModel. If it is a primitive type, an
object schema with a single "value" field will be generated.
"""
- if response_type is None:
- schema = {"type": "object", "properties": {}}
- else:
- # if the user provided a list of strings, treat it as a Literal
- if isinstance(response_type, list):
- if not all(isinstance(item, str) for item in response_type):
- raise ValueError(
- "List of options must be a list of strings. Received: "
- f"{response_type}"
- )
- # Convert list of options to Literal type and wrap
- choice_literal = Literal[tuple(response_type)] # type: ignore
- response_type = ScalarElicitationType[choice_literal] # type: ignore
- # if the user provided a primitive scalar, wrap it in an object schema
- elif (
- response_type in {bool, int, float, str}
- or get_origin(response_type) is Literal
- or (isinstance(response_type, type) and issubclass(response_type, Enum))
- ):
- response_type = ScalarElicitationType[response_type] # type: ignore
-
- response_type = cast(type[T], response_type)
-
- schema = get_elicitation_schema(response_type)
+ config = parse_elicit_response_type(response_type)
result = await self.session.elicit(
message=message,
- requestedSchema=schema,
+ requestedSchema=config.schema,
related_request_id=self.request_id,
)
if result.action == "accept":
- if response_type is not None:
- type_adapter = get_cached_typeadapter(response_type)
- validated_data = cast(
- T | ScalarElicitationType[T],
- type_adapter.validate_python(result.content),
- )
- if isinstance(validated_data, ScalarElicitationType):
- return AcceptedElicitation[T](data=validated_data.value)
- else:
- return AcceptedElicitation[T](data=cast(T, validated_data))
- elif result.content:
- raise ValueError(
- "Elicitation expected an empty response, but received: "
- f"{result.content}"
- )
- else:
- return AcceptedElicitation[dict[str, Any]](data={})
+ return handle_elicit_accept(config, result.content)
elif result.action == "decline":
return DeclinedElicitation()
elif result.action == "cancel":
return CancelledElicitation()
else:
- # This should never happen, but handle it just in case
raise ValueError(f"Unexpected elicitation action: {result.action}")
def set_state(self, key: str, value: Any) -> None:
diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py
index 91b535967..c2a93a1f7 100644
--- a/src/fastmcp/server/elicitation.py
+++ b/src/fastmcp/server/elicitation.py
@@ -1,7 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any, Generic, Literal
+from enum import Enum
+from typing import Any, Generic, Literal, get_origin
from mcp.server.elicitation import (
CancelledElicitation,
@@ -20,8 +21,11 @@ __all__ = [
"AcceptedElicitation",
"CancelledElicitation",
"DeclinedElicitation",
+ "ElicitConfig",
"ScalarElicitationType",
"get_elicitation_schema",
+ "handle_elicit_accept",
+ "parse_elicit_response_type",
]
logger = get_logger(__name__)
@@ -38,49 +42,63 @@ class ElicitationJsonSchema(GenerateJsonSchema):
"""
def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: # type: ignore[override]
- """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) # type: ignore[arg-type]
+ # For list schemas, check if items are enums
+ if schema["type"] == "list":
+ return self.list_schema(schema) # type: ignore[arg-type]
# 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) # type: ignore[arg-type]
+ # 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 +114,207 @@ class ScalarElicitationType(Generic[T]):
value: T
+@dataclass
+class ElicitConfig:
+ """Configuration for an elicitation request.
+
+ Attributes:
+ schema: The JSON schema to send to the client
+ response_type: The type to validate responses with (None for raw schemas)
+ is_raw: True if schema was built directly (extract "value" from response)
+ """
+
+ schema: dict[str, Any]
+ response_type: type | None
+ is_raw: bool
+
+
+def parse_elicit_response_type(response_type: Any) -> ElicitConfig:
+ """Parse response_type into schema and handling configuration.
+
+ Supports multiple syntaxes:
+ - None: Empty object schema, expect empty response
+ - dict: {"low": {"title": "..."}} -> single-select titled enum
+ - list patterns:
+ - [["a", "b"]] -> multi-select untitled
+ - [{"low": {...}}] -> multi-select titled
+ - ["a", "b"] -> single-select untitled
+ - list[X] type annotation: multi-select with type
+ - Scalar types (bool, int, float, str, Literal, Enum): single value
+ - Other types (dataclass, BaseModel): use directly
+ """
+ if response_type is None:
+ return ElicitConfig(
+ schema={"type": "object", "properties": {}},
+ response_type=None,
+ is_raw=False,
+ )
+
+ if isinstance(response_type, dict):
+ return _parse_dict_syntax(response_type)
+
+ if isinstance(response_type, list):
+ return _parse_list_syntax(response_type)
+
+ 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 _is_scalar_type(response_type: Any) -> bool:
+ """Check if response_type is a scalar type that needs wrapping."""
+ return (
+ response_type in {bool, int, float, str}
+ or get_origin(response_type) is Literal
+ or (isinstance(response_type, type) and issubclass(response_type, Enum))
+ )
+
+
+def _parse_dict_syntax(d: dict[str, Any]) -> ElicitConfig:
+ """Parse dict syntax: {"low": {"title": "..."}} -> single-select titled."""
+ if not d:
+ raise ValueError("Dict response_type cannot be empty.")
+ enum_schema = _dict_to_enum_schema(d, multi_select=False)
+ return ElicitConfig(
+ schema={
+ "type": "object",
+ "properties": {"value": enum_schema},
+ "required": ["value"],
+ },
+ response_type=None,
+ is_raw=True,
+ )
+
+
+def _parse_list_syntax(lst: list[Any]) -> ElicitConfig:
+ """Parse list patterns: [[...]], [{...}], or [...]."""
+ # [["a", "b", "c"]] -> multi-select untitled
+ if (
+ len(lst) == 1
+ and isinstance(lst[0], list)
+ and lst[0]
+ and all(isinstance(item, str) for item in lst[0])
+ ):
+ return ElicitConfig(
+ schema={
+ "type": "object",
+ "properties": {"value": {"type": "array", "items": {"enum": lst[0]}}},
+ "required": ["value"],
+ },
+ response_type=None,
+ is_raw=True,
+ )
+
+ # [{"low": {"title": "..."}}] -> multi-select titled
+ if len(lst) == 1 and isinstance(lst[0], dict) and lst[0]:
+ enum_schema = _dict_to_enum_schema(lst[0], multi_select=True)
+ return ElicitConfig(
+ schema={
+ "type": "object",
+ "properties": {"value": {"type": "array", "items": enum_schema}},
+ "required": ["value"],
+ },
+ response_type=None,
+ is_raw=True,
+ )
+
+ # ["a", "b", "c"] -> single-select untitled
+ if lst and all(isinstance(item, str) for item in lst):
+ choice_literal = Literal[tuple(lst)] # type: ignore[valid-type]
+ wrapped = ScalarElicitationType[choice_literal] # type: ignore[valid-type]
+ return ElicitConfig(
+ schema=get_elicitation_schema(wrapped), # type: ignore[arg-type]
+ response_type=wrapped, # type: ignore[assignment]
+ is_raw=False,
+ )
+
+ raise ValueError(f"Invalid list response_type format. Received: {lst}")
+
+
+def _parse_generic_list(response_type: Any) -> ElicitConfig:
+ """Parse list[X] type annotation -> multi-select."""
+ wrapped = ScalarElicitationType[response_type] # type: ignore[valid-type]
+ return ElicitConfig(
+ schema=get_elicitation_schema(wrapped), # type: ignore[arg-type]
+ response_type=wrapped, # type: ignore[assignment]
+ is_raw=False,
+ )
+
+
+def _parse_scalar_type(response_type: Any) -> ElicitConfig:
+ """Parse scalar types (bool, int, float, str, Literal, Enum)."""
+ wrapped = ScalarElicitationType[response_type] # type: ignore[valid-type]
+ return ElicitConfig(
+ schema=get_elicitation_schema(wrapped), # type: ignore[arg-type]
+ response_type=wrapped, # type: ignore[assignment]
+ is_raw=False,
+ )
+
+
+def handle_elicit_accept(
+ config: ElicitConfig, content: Any
+) -> AcceptedElicitation[Any]:
+ """Handle an accepted elicitation response.
+
+ Args:
+ config: The elicitation configuration from parse_elicit_response_type
+ content: The response content from the client
+
+ Returns:
+ AcceptedElicitation with the extracted/validated data
+ """
+ # For raw schemas (dict/nested-list syntax), extract value directly
+ if config.is_raw:
+ if not isinstance(content, dict) or "value" not in content:
+ raise ValueError("Elicitation response missing required 'value' field.")
+ return AcceptedElicitation[Any](data=content["value"])
+
+ # For typed schemas, validate with Pydantic
+ if config.response_type is not None:
+ type_adapter = get_cached_typeadapter(config.response_type)
+ validated_data = type_adapter.validate_python(content)
+ if isinstance(validated_data, ScalarElicitationType):
+ return AcceptedElicitation[Any](data=validated_data.value)
+ return AcceptedElicitation[Any](data=validated_data)
+
+ # For None response_type, expect empty response
+ if content:
+ raise ValueError(
+ f"Elicitation expected an empty response, but received: {content}"
+ )
+ return AcceptedElicitation[dict[str, Any]](data={})
+
+
+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 +416,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 +424,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 987c779fb..b762eef26 100644
--- a/tests/client/test_elicitation.py
+++ b/tests/client/test_elicitation.py
@@ -515,7 +515,7 @@ class TestValidation:
"""Test that nested object schemas are rejected."""
with pytest.raises(
- TypeError, match="has type 'object' which is not a primitive type"
+ TypeError, match="is an object, but nested objects are not allowed"
):
validate_elicitation_json_schema(
{
@@ -530,11 +530,9 @@ class TestValidation:
)
async def test_schema_validation_rejects_arrays(self):
- """Test that array schemas are rejected."""
+ """Test that non-enum array schemas are rejected."""
- with pytest.raises(
- TypeError, match="has type 'array' which is not a primitive type"
- ):
+ with pytest.raises(TypeError, match="is an array, but arrays are only allowed"):
validate_elicitation_json_schema(
{
"type": "object",
@@ -692,8 +690,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"
@@ -714,7 +712,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",
@@ -722,14 +723,221 @@ 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)
class TestElicitationDefaults: