Skip to content

Commit ffc374d

Browse files
authored
Merge branch 'main' into feat/sep-1036-url-elicitation
2 parents c7560df + b19fa6f commit ffc374d

File tree

4 files changed

+222
-16
lines changed

4 files changed

+222
-16
lines changed

examples/servers/everything-server/mcp_everything_server/server.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,65 @@ async def test_elicitation_sep1034_defaults(ctx: Context[ServerSession, None]) -
198198
return f"Elicitation not supported or error: {str(e)}"
199199

200200

201+
class EnumSchemasTestSchema(BaseModel):
202+
"""Schema for testing enum schema variations (SEP-1330)"""
203+
204+
untitledSingle: str = Field(
205+
description="Simple enum without titles", json_schema_extra={"enum": ["active", "inactive", "pending"]}
206+
)
207+
titledSingle: str = Field(
208+
description="Enum with titled options (oneOf)",
209+
json_schema_extra={
210+
"oneOf": [
211+
{"const": "low", "title": "Low Priority"},
212+
{"const": "medium", "title": "Medium Priority"},
213+
{"const": "high", "title": "High Priority"},
214+
]
215+
},
216+
)
217+
untitledMulti: list[str] = Field(
218+
description="Multi-select without titles",
219+
json_schema_extra={"items": {"type": "string", "enum": ["read", "write", "execute"]}},
220+
)
221+
titledMulti: list[str] = Field(
222+
description="Multi-select with titled options",
223+
json_schema_extra={
224+
"items": {
225+
"anyOf": [
226+
{"const": "feature", "title": "New Feature"},
227+
{"const": "bug", "title": "Bug Fix"},
228+
{"const": "docs", "title": "Documentation"},
229+
]
230+
}
231+
},
232+
)
233+
legacyEnum: str = Field(
234+
description="Legacy enum with enumNames",
235+
json_schema_extra={
236+
"enum": ["small", "medium", "large"],
237+
"enumNames": ["Small Size", "Medium Size", "Large Size"],
238+
},
239+
)
240+
241+
242+
@mcp.tool()
243+
async def test_elicitation_sep1330_enums(ctx: Context[ServerSession, None]) -> str:
244+
"""Tests elicitation with enum schema variations per SEP-1330"""
245+
try:
246+
result = await ctx.elicit(
247+
message="Please select values using different enum schema types", schema=EnumSchemasTestSchema
248+
)
249+
250+
if result.action == "accept":
251+
content = result.data.model_dump_json()
252+
else:
253+
content = "{}"
254+
255+
return f"Elicitation completed: action={result.action}, content={content}"
256+
except Exception as e:
257+
return f"Elicitation not supported or error: {str(e)}"
258+
259+
201260
@mcp.tool()
202261
def test_error_handling() -> str:
203262
"""Tests error response handling"""

src/mcp/server/elicitation.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
from __future__ import annotations
44

55
import types
6+
from collections.abc import Sequence
67
from typing import Generic, Literal, TypeVar, Union, get_args, get_origin
78

89
from pydantic import BaseModel
9-
from pydantic.fields import FieldInfo
1010

1111
from mcp.server.session import ServerSession
1212
from mcp.types import RequestId
@@ -52,22 +52,40 @@ class AcceptedUrlElicitation(BaseModel):
5252
def _validate_elicitation_schema(schema: type[BaseModel]) -> None:
5353
"""Validate that a Pydantic model only contains primitive field types."""
5454
for field_name, field_info in schema.model_fields.items():
55-
if not _is_primitive_field(field_info):
55+
annotation = field_info.annotation
56+
57+
if annotation is None or annotation is types.NoneType: # pragma: no cover
58+
continue
59+
elif _is_primitive_field(annotation):
60+
continue
61+
elif _is_string_sequence(annotation):
62+
continue
63+
else:
5664
raise TypeError(
5765
f"Elicitation schema field '{field_name}' must be a primitive type "
58-
f"{_ELICITATION_PRIMITIVE_TYPES} or Optional of these types. "
59-
f"Complex types like lists, dicts, or nested models are not allowed."
66+
f"{_ELICITATION_PRIMITIVE_TYPES}, a sequence of strings (list[str], etc.), "
67+
f"or Optional of these types. Nested models and complex types are not allowed."
6068
)
6169

6270

63-
def _is_primitive_field(field_info: FieldInfo) -> bool:
64-
"""Check if a field is a primitive type allowed in elicitation schemas."""
65-
annotation = field_info.annotation
71+
def _is_string_sequence(annotation: type) -> bool:
72+
"""Check if annotation is a sequence of strings (list[str], Sequence[str], etc)."""
73+
origin = get_origin(annotation)
74+
# Check if it's a sequence-like type with str elements
75+
if origin:
76+
try:
77+
if issubclass(origin, Sequence):
78+
args = get_args(annotation)
79+
# Should have single str type arg
80+
return len(args) == 1 and args[0] is str
81+
except TypeError: # pragma: no cover
82+
# origin is not a class, so it can't be a subclass of Sequence
83+
pass
84+
return False
6685

67-
# Handle None type
68-
if annotation is types.NoneType: # pragma: no cover
69-
return True
7086

87+
def _is_primitive_field(annotation: type) -> bool:
88+
"""Check if a field is a primitive type allowed in elicitation schemas."""
7189
# Handle basic primitive types
7290
if annotation in _ELICITATION_PRIMITIVE_TYPES:
7391
return True
@@ -76,8 +94,10 @@ def _is_primitive_field(field_info: FieldInfo) -> bool:
7694
origin = get_origin(annotation)
7795
if origin is Union or origin is types.UnionType:
7896
args = get_args(annotation)
79-
# All args must be primitive types or None
80-
return all(arg is types.NoneType or arg in _ELICITATION_PRIMITIVE_TYPES for arg in args)
97+
# All args must be primitive types, None, or string sequences
98+
return all(
99+
arg is types.NoneType or arg in _ELICITATION_PRIMITIVE_TYPES or _is_string_sequence(arg) for arg in args
100+
)
81101

82102
return False
83103

src/mcp/types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1563,7 +1563,7 @@ class ElicitResult(Result):
15631563
- "cancel": User dismissed without making an explicit choice
15641564
"""
15651565

1566-
content: dict[str, str | int | bool | list[str]] | None = None
1566+
content: dict[str, str | int | float | bool | list[str] | None] | None = None
15671567
"""
15681568
The submitted form data, only present when action is "accept" in form mode.
15691569
Contains values matching the requested schema. Values can be strings, integers,

tests/server/fastmcp/test_elicitation.py

Lines changed: 130 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ async def tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover
117117

118118
# Test cases for invalid schemas
119119
class InvalidListSchema(BaseModel):
120-
names: list[str] = Field(description="List of names")
120+
numbers: list[int] = Field(description="List of numbers")
121121

122122
class NestedModel(BaseModel):
123123
value: str
@@ -140,7 +140,7 @@ async def elicitation_callback(
140140
await client_session.initialize()
141141

142142
# Test both invalid schemas
143-
for tool_name, field_name in [("invalid_list", "names"), ("nested_model", "nested")]:
143+
for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]:
144144
result = await client_session.call_tool(tool_name, {})
145145
assert len(result.content) == 1
146146
assert isinstance(result.content[0], TextContent)
@@ -198,7 +198,7 @@ async def callback(context: RequestContext[ClientSession, None], params: ElicitR
198198
# Test invalid optional field
199199
class InvalidOptionalSchema(BaseModel):
200200
name: str = Field(description="Name")
201-
optional_list: list[str] | None = Field(default=None, description="Invalid optional list")
201+
optional_list: list[int] | None = Field(default=None, description="Invalid optional list")
202202

203203
@mcp.tool(description="Tool with invalid optional field")
204204
async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover
@@ -221,6 +221,47 @@ async def elicitation_callback(
221221
text_contains=["Validation failed:", "optional_list"],
222222
)
223223

224+
# Test valid list[str] for multi-select enum
225+
class ValidMultiSelectSchema(BaseModel):
226+
name: str = Field(description="Name")
227+
tags: list[str] = Field(description="Tags")
228+
229+
@mcp.tool(description="Tool with valid list[str] field")
230+
async def valid_multiselect_tool(ctx: Context[ServerSession, None]) -> str:
231+
result = await ctx.elicit(message="Please provide tags", schema=ValidMultiSelectSchema)
232+
if result.action == "accept" and result.data:
233+
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
234+
return f"User {result.action}" # pragma: no cover
235+
236+
async def multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
237+
if "Please provide tags" in params.message:
238+
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
239+
return ElicitResult(action="decline") # pragma: no cover
240+
241+
await call_tool_and_assert(mcp, multiselect_callback, "valid_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2")
242+
243+
# Test Optional[list[str]] for optional multi-select enum
244+
class OptionalMultiSelectSchema(BaseModel):
245+
name: str = Field(description="Name")
246+
tags: list[str] | None = Field(default=None, description="Optional tags")
247+
248+
@mcp.tool(description="Tool with optional list[str] field")
249+
async def optional_multiselect_tool(ctx: Context[ServerSession, None]) -> str:
250+
result = await ctx.elicit(message="Please provide optional tags", schema=OptionalMultiSelectSchema)
251+
if result.action == "accept" and result.data:
252+
tags_str = ", ".join(result.data.tags) if result.data.tags else "none"
253+
return f"Name: {result.data.name}, Tags: {tags_str}"
254+
return f"User {result.action}" # pragma: no cover
255+
256+
async def optional_multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
257+
if "Please provide optional tags" in params.message:
258+
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
259+
return ElicitResult(action="decline") # pragma: no cover
260+
261+
await call_tool_and_assert(
262+
mcp, optional_multiselect_callback, "optional_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2"
263+
)
264+
224265

225266
@pytest.mark.anyio
226267
async def test_elicitation_with_default_values():
@@ -276,3 +317,89 @@ async def callback_override(context: RequestContext[ClientSession, None], params
276317
await call_tool_and_assert(
277318
mcp, callback_override, "defaults_tool", {}, "Name: John, Age: 25, Subscribe: False, Email: [email protected]"
278319
)
320+
321+
322+
@pytest.mark.anyio
323+
async def test_elicitation_with_enum_titles():
324+
"""Test elicitation with enum schemas using oneOf/anyOf for titles."""
325+
mcp = FastMCP(name="ColorPreferencesApp")
326+
327+
# Test single-select with titles using oneOf
328+
class FavoriteColorSchema(BaseModel):
329+
user_name: str = Field(description="Your name")
330+
favorite_color: str = Field(
331+
description="Select your favorite color",
332+
json_schema_extra={
333+
"oneOf": [
334+
{"const": "red", "title": "Red"},
335+
{"const": "green", "title": "Green"},
336+
{"const": "blue", "title": "Blue"},
337+
{"const": "yellow", "title": "Yellow"},
338+
]
339+
},
340+
)
341+
342+
@mcp.tool(description="Single color selection")
343+
async def select_favorite_color(ctx: Context[ServerSession, None]) -> str:
344+
result = await ctx.elicit(message="Select your favorite color", schema=FavoriteColorSchema)
345+
if result.action == "accept" and result.data:
346+
return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}"
347+
return f"User {result.action}" # pragma: no cover
348+
349+
# Test multi-select with titles using anyOf
350+
class FavoriteColorsSchema(BaseModel):
351+
user_name: str = Field(description="Your name")
352+
favorite_colors: list[str] = Field(
353+
description="Select your favorite colors",
354+
json_schema_extra={
355+
"items": {
356+
"anyOf": [
357+
{"const": "red", "title": "Red"},
358+
{"const": "green", "title": "Green"},
359+
{"const": "blue", "title": "Blue"},
360+
{"const": "yellow", "title": "Yellow"},
361+
]
362+
}
363+
},
364+
)
365+
366+
@mcp.tool(description="Multiple color selection")
367+
async def select_favorite_colors(ctx: Context[ServerSession, None]) -> str:
368+
result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema)
369+
if result.action == "accept" and result.data:
370+
return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}"
371+
return f"User {result.action}" # pragma: no cover
372+
373+
# Test legacy enumNames format
374+
class LegacyColorSchema(BaseModel):
375+
user_name: str = Field(description="Your name")
376+
color: str = Field(
377+
description="Select a color",
378+
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
379+
)
380+
381+
@mcp.tool(description="Legacy enum format")
382+
async def select_color_legacy(ctx: Context[ServerSession, None]) -> str:
383+
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
384+
if result.action == "accept" and result.data:
385+
return f"User: {result.data.user_name}, Color: {result.data.color}"
386+
return f"User {result.action}" # pragma: no cover
387+
388+
async def enum_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
389+
if "colors" in params.message and "legacy" not in params.message:
390+
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
391+
elif "color" in params.message:
392+
if "legacy" in params.message:
393+
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
394+
else:
395+
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
396+
return ElicitResult(action="decline") # pragma: no cover
397+
398+
# Test single-select with titles
399+
await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue")
400+
401+
# Test multi-select with titles
402+
await call_tool_and_assert(mcp, enum_callback, "select_favorite_colors", {}, "User: Bob, Colors: red, green")
403+
404+
# Test legacy enumNames format
405+
await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green")

0 commit comments

Comments
 (0)