Skip to content

Commit f551eff

Browse files
committed
Type the elicitation requested schema on the send side
ElicitRequestedSchema was a TypeAlias for dict[str, Any]; it is now a Pydantic model of the spec's restricted requested-schema subset, backed by a new PrimitiveSchemaDefinition union (StringSchema, NumberSchema, BooleanSchema, and the enum schemas). ServerSession.elicit_form (and the deprecated elicit alias) and ClientPeer.elicit_form accept only this model, so a nested-object property, an array-of-objects property, or an anyOf union is unconstructible at the only place a server author supplies a schema, rather than silently forwarded to the client. The spec restricts form-mode requested schemas to flat objects with primitive-typed properties only ("complex nested structures, arrays of objects ... are intentionally not supported"). The high-level Context.elicit / elicit_with_validation path is unchanged in behaviour: it converts the rendered JSON Schema into the typed model, keeping its existing per-field TypeError contract and producing value-identical wire output. Inbound is deliberately untouched. The wire field ElicitRequestFormParams.requested_schema stays a plain dict[str, Any], so older servers that emit anyOf for Optional form fields still reach the client's elicitation callback. The typed-model-to-wire-dict conversion lives in one place, ElicitRequestedSchema.to_wire(), which both send sites call. The schema family is extra="allow": keys schema.ts does not name (a top-level title, pattern, exclusiveMinimum, json_schema_extra keys) still round-trip, because the primitives-only restriction is carried by the union members' required type literals, not by extra-key rejection.
1 parent 88a69d7 commit f551eff

16 files changed

Lines changed: 445 additions & 132 deletions

File tree

docs/migration.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,42 @@ Positional calls (`await ctx.info("hello")`) are unaffected.
738738

739739
`Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected.
740740

741+
### `ServerSession.elicit_form()` takes a typed `ElicitRequestedSchema`
742+
743+
`ServerSession.elicit_form()` (and the deprecated `elicit()` alias, and `ClientPeer.elicit_form()`)
744+
now take an `mcp_types.ElicitRequestedSchema` -- a Pydantic model of the spec's restricted
745+
requested-schema subset -- instead of an arbitrary `dict[str, Any]`. `ElicitRequestedSchema` was
746+
previously a `TypeAlias` for `dict[str, Any]`; it is now that model. A schema with a nested-object
747+
property, an array-of-objects property, or an `anyOf` union is rejected at construction.
748+
749+
**Why:** the spec restricts form-mode requested schemas to flat objects with primitive-typed
750+
properties only ("complex nested structures, arrays of objects ... are intentionally not
751+
supported"). Typing the send side makes a non-conforming schema impossible to construct rather
752+
than silently forwarded.
753+
754+
**How to migrate:** build the model in place of the dict, or validate an existing JSON Schema dict:
755+
756+
```python
757+
from mcp_types import BooleanSchema, ElicitRequestedSchema, StringSchema
758+
759+
await ctx.session.elicit_form(
760+
"Choose a username.",
761+
ElicitRequestedSchema(
762+
properties={"username": StringSchema(type="string"), "newsletter": BooleanSchema(type="boolean")},
763+
required=["username"],
764+
),
765+
)
766+
767+
# Or, if you already have a JSON Schema dict:
768+
await ctx.session.elicit_form("Choose a username.", ElicitRequestedSchema.model_validate(my_schema))
769+
```
770+
771+
The high-level `Context.elicit()` / `elicit_with_validation()` path, which generates the schema
772+
from a Pydantic model class, is unchanged. The wire type `ElicitRequestFormParams.requested_schema`
773+
is still a plain `dict[str, Any]`: the client's inbound parsing deliberately tolerates
774+
non-conforming schemas so older servers (which emit `anyOf` for `Optional` form fields) still
775+
reach the elicitation callback.
776+
741777
### Replace `RootModel` by union types with `TypeAdapter` validation
742778

743779
The following union types are no longer `RootModel` subclasses:

examples/stories/legacy_elicitation/server_lowlevel.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@
88
from mcp.server.lowlevel import Server
99
from stories._hosting import run_server_from_args
1010

11-
REGISTRATION_SCHEMA: types.ElicitRequestedSchema = {
12-
"type": "object",
13-
"properties": {
14-
"username": {"type": "string"},
15-
"plan": {"type": "string", "enum": ["free", "pro", "team"]},
11+
REGISTRATION_SCHEMA = types.ElicitRequestedSchema(
12+
properties={
13+
"username": types.StringSchema(type="string"),
14+
"plan": types.UntitledSingleSelectEnumSchema(type="string", enum=["free", "pro", "team"]),
1615
},
17-
"required": ["username"],
18-
}
16+
required=["username"],
17+
)
1918
LINK_INPUT_SCHEMA: dict[str, Any] = {
2019
"type": "object",
2120
"properties": {"provider": {"type": "string"}},

examples/stories/stickynotes/server_lowlevel.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,10 @@ def claim_id(self) -> str:
2222
return nid
2323

2424

25-
CONFIRM_SCHEMA: dict[str, Any] = {
26-
"type": "object",
27-
"properties": {"confirm": {"type": "boolean", "title": "Yes, permanently delete every sticky note"}},
28-
"required": ["confirm"],
29-
}
25+
CONFIRM_SCHEMA = types.ElicitRequestedSchema(
26+
properties={"confirm": types.BooleanSchema(type="boolean", title="Yes, permanently delete every sticky note")},
27+
required=["confirm"],
28+
)
3029

3130
TOOLS = [
3231
types.Tool(

scripts/gen_surface_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
# Older python-sdk releases emit `anyOf` for Optional fields; the callback's
4545
# own schema validation is the real gate, so accept any property shape inbound.
4646
# PrimitiveSchemaDefinition becomes an orphan $def after this patch but
47-
# datamodel-codegen still emits it; elicitation.py imports it as the gate type.
47+
# datamodel-codegen still emits it; the monolith carries the user-facing family.
4848
(
4949
"$defs/ElicitRequestFormParams/properties/requestedSchema/properties/properties/additionalProperties",
5050
{"$ref": "#/$defs/PrimitiveSchemaDefinition"},

src/mcp-types/mcp_types/__init__.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
AudioContent,
1616
BaseMetadata,
1717
BlobResourceContents,
18+
BooleanSchema,
1819
CacheableResult,
1920
CallToolRequest,
2021
CallToolRequestParams,
@@ -57,6 +58,8 @@
5758
ElicitResult,
5859
EmbeddedResource,
5960
EmptyResult,
61+
EnumOption,
62+
EnumSchema,
6063
FormElicitationCapability,
6164
GetPromptRequest,
6265
GetPromptRequestParams,
@@ -82,6 +85,7 @@
8285
InputResponse,
8386
InputResponseRequestParams,
8487
InputResponses,
88+
LegacyTitledEnumSchema,
8589
ListPromptsRequest,
8690
ListPromptsResult,
8791
ListResourcesRequest,
@@ -101,12 +105,15 @@
101105
MissingRequiredClientCapabilityErrorData,
102106
ModelHint,
103107
ModelPreferences,
108+
MultiSelectEnumSchema,
104109
Notification,
105110
NotificationParams,
111+
NumberSchema,
106112
PaginatedRequest,
107113
PaginatedRequestParams,
108114
PaginatedResult,
109115
PingRequest,
116+
PrimitiveSchemaDefinition,
110117
ProgressNotification,
111118
ProgressNotificationParams,
112119
ProgressToken,
@@ -152,7 +159,9 @@
152159
ServerTasksRequestsCapability,
153160
SetLevelRequest,
154161
SetLevelRequestParams,
162+
SingleSelectEnumSchema,
155163
StopReason,
164+
StringSchema,
156165
SubscribeRequest,
157166
SubscribeRequestParams,
158167
SubscriptionFilter,
@@ -175,6 +184,9 @@
175184
TasksToolsCapability,
176185
TextContent,
177186
TextResourceContents,
187+
TitledMultiSelectEnumItems,
188+
TitledMultiSelectEnumSchema,
189+
TitledSingleSelectEnumSchema,
178190
Tool,
179191
ToolAnnotations,
180192
ToolChoice,
@@ -186,6 +198,9 @@
186198
UnsubscribeRequest,
187199
UnsubscribeRequestParams,
188200
UnsupportedProtocolVersionErrorData,
201+
UntitledMultiSelectEnumItems,
202+
UntitledMultiSelectEnumSchema,
203+
UntitledSingleSelectEnumSchema,
189204
UrlElicitationCapability,
190205
client_notification_adapter,
191206
client_request_adapter,
@@ -231,21 +246,37 @@
231246
"LOG_LEVEL_META_KEY",
232247
# Type aliases and variables
233248
"ContentBlock",
234-
"ElicitRequestedSchema",
235249
"ElicitRequestParams",
250+
"EnumSchema",
236251
"IncludeContext",
237252
"InputRequest",
238253
"InputRequests",
239254
"InputResponse",
240255
"InputResponses",
241256
"LoggingLevel",
257+
"MultiSelectEnumSchema",
258+
"PrimitiveSchemaDefinition",
242259
"ProgressToken",
243260
"ResultType",
244261
"Role",
245262
"SamplingContent",
246263
"SamplingMessageContentBlock",
264+
"SingleSelectEnumSchema",
247265
"StopReason",
248266
"TaskStatus",
267+
# Elicitation requested-schema models (form-mode property schemas; primitives only)
268+
"BooleanSchema",
269+
"ElicitRequestedSchema",
270+
"EnumOption",
271+
"LegacyTitledEnumSchema",
272+
"NumberSchema",
273+
"StringSchema",
274+
"TitledMultiSelectEnumItems",
275+
"TitledMultiSelectEnumSchema",
276+
"TitledSingleSelectEnumSchema",
277+
"UntitledMultiSelectEnumItems",
278+
"UntitledMultiSelectEnumSchema",
279+
"UntitledSingleSelectEnumSchema",
249280
# Base classes
250281
"BaseMetadata",
251282
"Request",

src/mcp-types/mcp_types/_types.py

Lines changed: 173 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1911,9 +1911,172 @@ class ElicitCompleteNotification(
19111911
params: ElicitCompleteNotificationParams
19121912

19131913

1914-
# Kept as a raw JSON Schema dict so callers can hand it straight to a validator;
1915-
# the per-version packages model RequestedSchema/PrimitiveSchemaDefinition strictly.
1916-
ElicitRequestedSchema: TypeAlias = dict[str, Any]
1914+
class _OpenSchemaModel(MCPModel):
1915+
"""Internal base for the elicitation requested-schema family: an open key set.
1916+
1917+
Keys schema.ts does not name still round-trip to the wire. The high-level renderer
1918+
(`model_json_schema`) emits non-spec JSON Schema keys -- a top-level `title`,
1919+
`exclusiveMinimum` / `pattern` from `Field(gt=...)` / `Field(pattern=...)`, and anything
1920+
in `json_schema_extra` -- and `extra="allow"` forwards them unchanged. The
1921+
primitives-only restriction is carried by the union members' required `type` literals
1922+
and required fields, NOT by extra-key rejection: `forbid` would break the high-level
1923+
path, and `ignore` would silently rewrite the user's schema.
1924+
"""
1925+
1926+
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True, extra="allow")
1927+
1928+
1929+
class StringSchema(_OpenSchemaModel):
1930+
"""A string-typed form field in an elicitation requested schema."""
1931+
1932+
type: Literal["string"]
1933+
title: str | None = None
1934+
description: str | None = None
1935+
min_length: int | None = None
1936+
max_length: int | None = None
1937+
format: Literal["email", "uri", "date", "date-time"] | None = None
1938+
default: str | None = None
1939+
1940+
1941+
class NumberSchema(_OpenSchemaModel):
1942+
"""A number- or integer-typed form field in an elicitation requested schema."""
1943+
1944+
type: Literal["number", "integer"]
1945+
title: str | None = None
1946+
description: str | None = None
1947+
minimum: int | float | None = None
1948+
maximum: int | float | None = None
1949+
default: int | float | None = None
1950+
1951+
1952+
class BooleanSchema(_OpenSchemaModel):
1953+
"""A boolean-typed form field in an elicitation requested schema."""
1954+
1955+
type: Literal["boolean"]
1956+
title: str | None = None
1957+
description: str | None = None
1958+
default: bool | None = None
1959+
1960+
1961+
class EnumOption(_OpenSchemaModel):
1962+
"""An enum value paired with the display title clients show for it."""
1963+
1964+
const: str
1965+
title: str
1966+
1967+
1968+
class UntitledSingleSelectEnumSchema(_OpenSchemaModel):
1969+
"""Single-selection enum form field whose options have no display titles."""
1970+
1971+
type: Literal["string"]
1972+
title: str | None = None
1973+
description: str | None = None
1974+
enum: list[str]
1975+
default: str | None = None
1976+
1977+
1978+
class TitledSingleSelectEnumSchema(_OpenSchemaModel):
1979+
"""Single-selection enum form field with a display title for each option."""
1980+
1981+
type: Literal["string"]
1982+
title: str | None = None
1983+
description: str | None = None
1984+
one_of: list[EnumOption]
1985+
default: str | None = None
1986+
1987+
1988+
SingleSelectEnumSchema: TypeAlias = UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema
1989+
"""Single-selection enum form field, with or without display titles for its options."""
1990+
1991+
1992+
class UntitledMultiSelectEnumItems(_OpenSchemaModel):
1993+
"""The `items` schema of a multi-select enum whose options have no display titles."""
1994+
1995+
type: Literal["string"]
1996+
enum: list[str]
1997+
1998+
1999+
class UntitledMultiSelectEnumSchema(_OpenSchemaModel):
2000+
"""Multiple-selection enum form field whose options have no display titles."""
2001+
2002+
type: Literal["array"]
2003+
title: str | None = None
2004+
description: str | None = None
2005+
min_items: int | None = None
2006+
max_items: int | None = None
2007+
items: UntitledMultiSelectEnumItems
2008+
default: list[str] | None = None
2009+
2010+
2011+
class TitledMultiSelectEnumItems(_OpenSchemaModel):
2012+
"""The `items` schema of a multi-select enum with a display title for each option."""
2013+
2014+
any_of: list[EnumOption]
2015+
2016+
2017+
class TitledMultiSelectEnumSchema(_OpenSchemaModel):
2018+
"""Multiple-selection enum form field with a display title for each option."""
2019+
2020+
type: Literal["array"]
2021+
title: str | None = None
2022+
description: str | None = None
2023+
min_items: int | None = None
2024+
max_items: int | None = None
2025+
items: TitledMultiSelectEnumItems
2026+
default: list[str] | None = None
2027+
2028+
2029+
MultiSelectEnumSchema: TypeAlias = UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema
2030+
"""Multiple-selection enum form field, with or without display titles for its options."""
2031+
2032+
2033+
class LegacyTitledEnumSchema(_OpenSchemaModel):
2034+
"""Enum form field using `enum` / `enumNames`; the spec will remove this in a future version.
2035+
2036+
Use `TitledSingleSelectEnumSchema` instead.
2037+
"""
2038+
2039+
type: Literal["string"]
2040+
title: str | None = None
2041+
description: str | None = None
2042+
enum: list[str]
2043+
enum_names: list[str] | None = None
2044+
default: str | None = None
2045+
2046+
2047+
EnumSchema: TypeAlias = SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema
2048+
"""Any of the elicitation enum form-field schemas."""
2049+
2050+
PrimitiveSchemaDefinition: TypeAlias = StringSchema | NumberSchema | BooleanSchema | EnumSchema
2051+
"""Restricted schema definitions allowed as elicitation form fields: primitives only, no nesting."""
2052+
2053+
2054+
class ElicitRequestedSchema(_OpenSchemaModel):
2055+
"""A restricted subset of JSON Schema: a flat object whose properties are all primitive-typed.
2056+
2057+
This is the only schema type the typed send methods (`ServerSession.elicit_form`,
2058+
`ClientPeer.elicit_form`) accept, so a nested-object or array-of-objects property is
2059+
unconstructible on the send side. The wire field `ElicitRequestFormParams.requested_schema`
2060+
stays a plain dict so the inbound parse remains lenient (see that field's docstring).
2061+
"""
2062+
2063+
schema_: Annotated[str | None, Field(alias="$schema")] = None
2064+
type: Literal["object"] = "object"
2065+
properties: dict[str, PrimitiveSchemaDefinition]
2066+
required: list[str] | None = None
2067+
2068+
def to_wire(self) -> dict[str, Any]:
2069+
"""Serialize to the plain dict that `ElicitRequestFormParams.requested_schema` carries.
2070+
2071+
This type never appears on the wire itself: the wire field is a deliberately
2072+
lenient `dict[str, Any]` so the client's inbound parse tolerates non-conforming
2073+
schemas from older servers. This method is the one place a typed schema becomes
2074+
that wire dict, and the typed send methods (`ServerSession.elicit_form`,
2075+
`ClientPeer.elicit_form`) both call it.
2076+
"""
2077+
# exclude_none, not exclude_unset: a keyword-constructed instance must keep its
2078+
# defaulted `type` literals on the wire; only the None-valued optionals are dropped.
2079+
return self.model_dump(by_alias=True, exclude_none=True)
19172080

19182081

19192082
class ElicitRequestFormParams(RequestParams):
@@ -1929,10 +2092,16 @@ class ElicitRequestFormParams(RequestParams):
19292092
message: str
19302093
"""The message to present to the user describing what information is being requested."""
19312094

1932-
requested_schema: ElicitRequestedSchema
2095+
requested_schema: dict[str, Any]
19332096
"""
19342097
A restricted subset of JSON Schema defining the structure of the expected response.
19352098
Only top-level properties are allowed, without nesting.
2099+
2100+
Deliberately a plain dict, not `ElicitRequestedSchema`: this field is also the client's
2101+
inbound parse, and older servers emit `anyOf` for `Optional` form fields, which must
2102+
still reach the user's elicitation callback. The spec's primitives-only restriction is
2103+
enforced on the send side, where `ServerSession.elicit_form` and `ClientPeer.elicit_form`
2104+
only accept `ElicitRequestedSchema`.
19362105
"""
19372106

19382107
task: TaskMetadata | None = None

0 commit comments

Comments
 (0)