Skip to content

Commit ff23382

Browse files
MCP SEP-1330: Elicitation schema updates for Enums (#324)
* WIP: PoC demonstrating new enum schemas + multi-selection * Cleanup checkbox impl * add bare enum support * Add missing type field in multi-select schema (minor fix for SEP-1330 compliance) * bump mcp sdk with support for 1330 * update demo --------- Co-authored-by: Tapan Chugh <tapanc@cs.washington.edu> Co-authored-by: evalstate <1936278+evalstate@users.noreply.github.com>
1 parent bcca217 commit ff23382

6 files changed

Lines changed: 232 additions & 31 deletions

File tree

examples/mcp/elicitations/elicitation_forms_server.py

Lines changed: 62 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import logging
99
import sys
10+
from typing import Optional, TypedDict
1011

1112
from mcp import ReadResourceResult
1213
from mcp.server.elicitation import (
@@ -30,20 +31,63 @@
3031
mcp = FastMCP("Elicitation Forms Demo Server", log_level="INFO")
3132

3233

34+
class TitledEnumOption(TypedDict):
35+
"""Type definition for oneOf/anyOf schema options."""
36+
37+
const: str
38+
title: str
39+
40+
41+
def _create_enum_schema_options(data: dict[str, str]) -> list[TitledEnumOption]:
42+
"""Convert a dictionary to oneOf/anyOf schema format.
43+
44+
Args:
45+
data: Dictionary mapping enum values to display titles
46+
47+
Returns:
48+
List of schema options with 'const' and 'title' fields
49+
50+
Example:
51+
>>> _create_enum_schema_options({"dark": "Dark Mode", "light": "Light Mode"})
52+
[{"const": "dark", "title": "Dark Mode"}, {"const": "light", "title": "Light Mode"}]
53+
"""
54+
return [{"const": k, "title": v} for k, v in data.items()]
55+
56+
3357
@mcp.resource(uri="elicitation://event-registration")
3458
async def event_registration() -> ReadResourceResult:
3559
"""Register for a tech conference event."""
60+
workshop_names = {
61+
"ai_basics": "AI Fundamentals",
62+
"llm_apps": "Building LLM Applications",
63+
"prompt_eng": "Prompt Engineering",
64+
"rag_systems": "RAG Systems",
65+
"fine_tuning": "Model Fine-tuning",
66+
"deployment": "Production Deployment",
67+
}
3668

3769
class EventRegistration(BaseModel):
3870
name: str = Field(description="Your full name", min_length=2, max_length=100)
3971
email: str = Field(description="Your email address", json_schema_extra={"format": "email"})
4072
company_website: str | None = Field(
4173
None, description="Your company website (optional)", json_schema_extra={"format": "uri"}
4274
)
75+
workshops: list[str] = Field(
76+
description="Select the workshops you want to attend",
77+
min_length=1,
78+
max_length=3,
79+
json_schema_extra={
80+
"items": {
81+
"enum": list(workshop_names.keys()),
82+
"enumNames": list(workshop_names.values()),
83+
},
84+
"uniqueItems": True,
85+
},
86+
)
4387
event_date: str = Field(
4488
description="Which event date works for you?", json_schema_extra={"format": "date"}
4589
)
46-
dietary_requirements: str | None = Field(
90+
dietary_requirements: Optional[str] = Field(
4791
None, description="Any dietary requirements? (optional)", max_length=200
4892
)
4993

@@ -60,7 +104,10 @@ class EventRegistration(BaseModel):
60104
f"🏢 Company: {data.company_website or 'Not provided'}",
61105
f"📅 Event Date: {data.event_date}",
62106
f"🍽️ Dietary Requirements: {data.dietary_requirements or 'None'}",
107+
f"🎓 Workshops ({len(data.workshops)} selected):",
63108
]
109+
for workshop in data.workshops:
110+
lines.append(f" • {workshop_names.get(workshop, workshop)}")
64111
response = "\n".join(lines)
65112
case DeclinedElicitation():
66113
response = "Registration declined - no ticket reserved"
@@ -79,6 +126,13 @@ class EventRegistration(BaseModel):
79126
@mcp.resource(uri="elicitation://product-review")
80127
async def product_review() -> ReadResourceResult:
81128
"""Submit a product review with rating and comments."""
129+
categories = {
130+
"electronics": "Electronics",
131+
"books": "Books & Media",
132+
"clothing": "Clothing",
133+
"home": "Home & Garden",
134+
"sports": "Sports & Outdoors",
135+
}
82136

83137
class ProductReview(BaseModel):
84138
rating: int = Field(description="Rate this product (1-5 stars)", ge=1, le=5)
@@ -87,16 +141,7 @@ class ProductReview(BaseModel):
87141
)
88142
category: str = Field(
89143
description="What type of product is this?",
90-
json_schema_extra={
91-
"enum": ["electronics", "books", "clothing", "home", "sports"],
92-
"enumNames": [
93-
"Electronics",
94-
"Books & Media",
95-
"Clothing",
96-
"Home & Garden",
97-
"Sports & Outdoors",
98-
],
99-
},
144+
json_schema_extra={"oneOf": _create_enum_schema_options(categories)},
100145
)
101146
review_text: str = Field(
102147
description="Tell us about your experience",
@@ -112,7 +157,7 @@ class ProductReview(BaseModel):
112157
113158
Overall, highly recommended!""",
114159
min_length=10,
115-
max_length=1000
160+
max_length=1000,
116161
)
117162

118163
result = await mcp.get_context().elicit(
@@ -127,7 +172,7 @@ class ProductReview(BaseModel):
127172
"🎯 Product Review Submitted!",
128173
f"⭐ Rating: {stars} ({data.rating}/5)",
129174
f"📊 Satisfaction: {data.satisfaction}/10.0",
130-
f"📦 Category: {data.category.replace('_', ' ').title()}",
175+
f"📦 Category: {categories.get(data.category, data.category)}",
131176
f"💬 Review: {data.review_text}",
132177
]
133178
response = "\n".join(lines)
@@ -149,16 +194,15 @@ class ProductReview(BaseModel):
149194
async def account_settings() -> ReadResourceResult:
150195
"""Configure your account settings and preferences."""
151196

197+
themes = {"light": "Light Theme", "dark": "Dark Theme", "auto": "Auto (System)"}
198+
152199
class AccountSettings(BaseModel):
153200
email_notifications: bool = Field(True, description="Receive email notifications?")
154201
marketing_emails: bool = Field(False, description="Subscribe to marketing emails?")
155202
theme: str = Field(
156203
"dark",
157204
description="Choose your preferred theme",
158-
json_schema_extra={
159-
"enum": ["light", "dark", "auto"],
160-
"enumNames": ["Light Theme", "Dark Theme", "Auto (System)"],
161-
},
205+
json_schema_extra={"oneOf": _create_enum_schema_options(themes)},
162206
)
163207
privacy_public: bool = Field(False, description="Make your profile public?")
164208
items_per_page: int = Field(
@@ -173,7 +217,7 @@ class AccountSettings(BaseModel):
173217
"⚙️ Account Settings Updated!",
174218
f"📧 Email notifications: {'On' if data.email_notifications else 'Off'}",
175219
f"📬 Marketing emails: {'On' if data.marketing_emails else 'Off'}",
176-
f"🎨 Theme: {data.theme.title()}",
220+
f"🎨 Theme: {themes.get(data.theme, data.theme)}",
177221
f"👥 Public profile: {'Yes' if data.privacy_public else 'No'}",
178222
f"📄 Items per page: {data.items_per_page}",
179223
]

examples/mcp/elicitations/forms_demo.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ async def main():
3636
# Example 1: Event Registration
3737
console.print("[bold yellow]Example 1: Event Registration Form[/bold yellow]")
3838
console.print(
39-
"[dim]Demonstrates: string validation, email format, URL format, date format[/dim]"
39+
"[dim]Demonstrates: string validation, email format, URL format, date format, "
40+
"multi-select enums[/dim]"
4041
)
4142
result = await agent["forms-demo"].get_resource("elicitation://event-registration")
4243

@@ -95,6 +96,7 @@ async def main():
9596
console.print("• [green]String validation[/green] (min/max length)")
9697
console.print("• [green]Number validation[/green] (range constraints)")
9798
console.print("• [green]Radio selections[/green] (enum dropdowns)")
99+
console.print("• [green]Multi-select enums[/green] (checkbox lists)")
98100
console.print("• [green]Boolean selections[/green] (checkboxes)")
99101
console.print("• [green]Format validation[/green] (email, URL, date, datetime)")
100102
console.print("• [green]Multiline text[/green] (expandable text areas)")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ classifiers = [
1515
requires-python = ">=3.13.5,<3.14"
1616
dependencies = [
1717
"fastapi>=0.121.0",
18-
"mcp==1.22.0",
18+
"mcp==1.23.1",
1919
"opentelemetry-distro>=0.55b0",
2020
"opentelemetry-exporter-otlp-proto-http>=1.7.0",
2121
"pydantic-settings>=2.7.0",
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Custom form elements for elicitation forms."""
2+
3+
from typing import Optional, Sequence, TypeVar
4+
5+
from prompt_toolkit.formatted_text import AnyFormattedText
6+
from prompt_toolkit.validation import ValidationError
7+
from prompt_toolkit.widgets import CheckboxList
8+
9+
_T = TypeVar("_T")
10+
11+
12+
class ValidatedCheckboxList(CheckboxList[_T]):
13+
"""CheckboxList with min/max items validation."""
14+
15+
def __init__(
16+
self,
17+
values: Sequence[tuple[_T, AnyFormattedText]],
18+
default_values: Optional[Sequence[_T]] = None,
19+
min_items: Optional[int] = None,
20+
max_items: Optional[int] = None,
21+
):
22+
"""
23+
Initialize checkbox list with validation.
24+
25+
Args:
26+
values: List of (value, label) tuples
27+
default_values: Initially selected values
28+
min_items: Minimum number of items that must be selected
29+
max_items: Maximum number of items that can be selected
30+
"""
31+
super().__init__(values, default_values=default_values)
32+
self.min_items = min_items
33+
self.max_items = max_items
34+
35+
@property
36+
def validation_error(self) -> Optional[ValidationError]:
37+
"""
38+
Check if current selection is valid.
39+
40+
Returns:
41+
ValidationError if invalid, None if valid
42+
"""
43+
selected_count = len(self.current_values)
44+
45+
if self.min_items is not None and selected_count < self.min_items:
46+
if self.min_items == 1:
47+
message = "At least 1 selection required"
48+
else:
49+
message = f"At least {self.min_items} selections required"
50+
return ValidationError(message=message)
51+
52+
if self.max_items is not None and selected_count > self.max_items:
53+
if self.max_items == 1:
54+
message = "Only 1 selection allowed"
55+
else:
56+
message = f"Maximum {self.max_items} selections allowed"
57+
return ValidationError(message=message)
58+
59+
return None

0 commit comments

Comments
 (0)