SEP-1330: Elicitation Enum Schema Improvements and Standards Compliance (#1246)
Co-authored-by: Tapan Chugh <tapanc@cs.washington.edu> Co-authored-by: Felix Weinberger <fweinberger@anthropic.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com>
This commit is contained in:
parent
71c475588f
commit
b19fa6f279
1 changed files with 222 additions and 16 deletions
|
|
@ -116,7 +116,7 @@ async def test_elicitation_schema_validation():
|
|||
|
||||
# Test cases for invalid schemas
|
||||
class InvalidListSchema(BaseModel):
|
||||
names: list[str] = Field(description="List of names")
|
||||
numbers: list[int] = Field(description="List of numbers")
|
||||
|
||||
class NestedModel(BaseModel):
|
||||
value: str
|
||||
|
|
@ -139,7 +139,7 @@ async def test_elicitation_schema_validation():
|
|||
await client_session.initialize()
|
||||
|
||||
# Test both invalid schemas
|
||||
for tool_name, field_name in [("invalid_list", "names"), ("nested_model", "nested")]:
|
||||
for tool_name, field_name in [("invalid_list", "numbers"), ("nested_model", "nested")]:
|
||||
result = await client_session.call_tool(tool_name, {})
|
||||
assert len(result.content) == 1
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
|
|
@ -197,7 +197,7 @@ async def test_elicitation_with_optional_fields():
|
|||
# Test invalid optional field
|
||||
class InvalidOptionalSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
optional_list: list[str] | None = Field(default=None, description="Invalid optional list")
|
||||
optional_list: list[int] | None = Field(default=None, description="Invalid optional list")
|
||||
|
||||
@mcp.tool(description="Tool with invalid optional field")
|
||||
async def invalid_optional_tool(ctx: Context[ServerSession, None]) -> str: # pragma: no cover
|
||||
|
|
@ -220,6 +220,47 @@ async def test_elicitation_with_optional_fields():
|
|||
text_contains=["Validation failed:", "optional_list"],
|
||||
)
|
||||
|
||||
# Test valid list[str] for multi-select enum
|
||||
class ValidMultiSelectSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] = Field(description="Tags")
|
||||
|
||||
@mcp.tool(description="Tool with valid list[str] field")
|
||||
async def valid_multiselect_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide tags", schema=ValidMultiSelectSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"Name: {result.data.name}, Tags: {', '.join(result.data.tags)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
|
||||
if "Please provide tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
await call_tool_and_assert(mcp, multiselect_callback, "valid_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2")
|
||||
|
||||
# Test Optional[list[str]] for optional multi-select enum
|
||||
class OptionalMultiSelectSchema(BaseModel):
|
||||
name: str = Field(description="Name")
|
||||
tags: list[str] | None = Field(default=None, description="Optional tags")
|
||||
|
||||
@mcp.tool(description="Tool with optional list[str] field")
|
||||
async def optional_multiselect_tool(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Please provide optional tags", schema=OptionalMultiSelectSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
tags_str = ", ".join(result.data.tags) if result.data.tags else "none"
|
||||
return f"Name: {result.data.name}, Tags: {tags_str}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def optional_multiselect_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
|
||||
if "Please provide optional tags" in params.message:
|
||||
return ElicitResult(action="accept", content={"name": "Test", "tags": ["tag1", "tag2"]})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
await call_tool_and_assert(
|
||||
mcp, optional_multiselect_callback, "optional_multiselect_tool", {}, "Name: Test, Tags: tag1, tag2"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_with_default_values():
|
||||
|
|
@ -274,3 +315,89 @@ async def test_elicitation_with_default_values():
|
|||
await call_tool_and_assert(
|
||||
mcp, callback_override, "defaults_tool", {}, "Name: John, Age: 25, Subscribe: False, Email: john@example.com"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elicitation_with_enum_titles():
|
||||
"""Test elicitation with enum schemas using oneOf/anyOf for titles."""
|
||||
mcp = FastMCP(name="ColorPreferencesApp")
|
||||
|
||||
# Test single-select with titles using oneOf
|
||||
class FavoriteColorSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
favorite_color: str = Field(
|
||||
description="Select your favorite color",
|
||||
json_schema_extra={
|
||||
"oneOf": [
|
||||
{"const": "red", "title": "Red"},
|
||||
{"const": "green", "title": "Green"},
|
||||
{"const": "blue", "title": "Blue"},
|
||||
{"const": "yellow", "title": "Yellow"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Single color selection")
|
||||
async def select_favorite_color(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Select your favorite color", schema=FavoriteColorSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Favorite: {result.data.favorite_color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test multi-select with titles using anyOf
|
||||
class FavoriteColorsSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
favorite_colors: list[str] = Field(
|
||||
description="Select your favorite colors",
|
||||
json_schema_extra={
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"const": "red", "title": "Red"},
|
||||
{"const": "green", "title": "Green"},
|
||||
{"const": "blue", "title": "Blue"},
|
||||
{"const": "yellow", "title": "Yellow"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Multiple color selection")
|
||||
async def select_favorite_colors(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Select your favorite colors", schema=FavoriteColorsSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Colors: {', '.join(result.data.favorite_colors)}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
# Test legacy enumNames format
|
||||
class LegacyColorSchema(BaseModel):
|
||||
user_name: str = Field(description="Your name")
|
||||
color: str = Field(
|
||||
description="Select a color",
|
||||
json_schema_extra={"enum": ["red", "green", "blue"], "enumNames": ["Red", "Green", "Blue"]},
|
||||
)
|
||||
|
||||
@mcp.tool(description="Legacy enum format")
|
||||
async def select_color_legacy(ctx: Context[ServerSession, None]) -> str:
|
||||
result = await ctx.elicit(message="Select a color (legacy format)", schema=LegacyColorSchema)
|
||||
if result.action == "accept" and result.data:
|
||||
return f"User: {result.data.user_name}, Color: {result.data.color}"
|
||||
return f"User {result.action}" # pragma: no cover
|
||||
|
||||
async def enum_callback(context: RequestContext[ClientSession, Any], params: ElicitRequestParams):
|
||||
if "colors" in params.message and "legacy" not in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Bob", "favorite_colors": ["red", "green"]})
|
||||
elif "color" in params.message:
|
||||
if "legacy" in params.message:
|
||||
return ElicitResult(action="accept", content={"user_name": "Charlie", "color": "green"})
|
||||
else:
|
||||
return ElicitResult(action="accept", content={"user_name": "Alice", "favorite_color": "blue"})
|
||||
return ElicitResult(action="decline") # pragma: no cover
|
||||
|
||||
# Test single-select with titles
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_favorite_color", {}, "User: Alice, Favorite: blue")
|
||||
|
||||
# Test multi-select with titles
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_favorite_colors", {}, "User: Bob, Colors: red, green")
|
||||
|
||||
# Test legacy enumNames format
|
||||
await call_tool_and_assert(mcp, enum_callback, "select_color_legacy", {}, "User: Charlie, Color: green")
|
||||
|
|
|
|||
Loading…
Reference in a new issue