Skip to content

Commit d14f6d5

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 4cc9aad commit d14f6d5

18 files changed

Lines changed: 465 additions & 145 deletions

File tree

docs/migration.md

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

813813
`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.
814814

815+
### `ServerSession.elicit_form()` takes a typed `ElicitRequestedSchema`
816+
817+
`ServerSession.elicit_form()` (and the deprecated `elicit()` alias, and `ClientPeer.elicit_form()`)
818+
now take an `mcp_types.ElicitRequestedSchema` -- a Pydantic model of the spec's restricted
819+
requested-schema subset -- instead of an arbitrary `dict[str, Any]`. `ElicitRequestedSchema` was
820+
previously a `TypeAlias` for `dict[str, Any]`; it is now that model. A schema with a nested-object
821+
property, an array-of-objects property, or an `anyOf` union is rejected at construction.
822+
823+
**Why:** the spec restricts form-mode requested schemas to flat objects with primitive-typed
824+
properties only ("complex nested structures, arrays of objects ... are intentionally not
825+
supported"). Typing the send side makes a non-conforming schema impossible to construct rather
826+
than silently forwarded.
827+
828+
**How to migrate:** build the model in place of the dict, or validate an existing JSON Schema dict:
829+
830+
```python
831+
from mcp_types import BooleanSchema, ElicitRequestedSchema, StringSchema
832+
833+
await ctx.session.elicit_form(
834+
"Choose a username.",
835+
ElicitRequestedSchema(
836+
properties={"username": StringSchema(type="string"), "newsletter": BooleanSchema(type="boolean")},
837+
required=["username"],
838+
),
839+
)
840+
841+
# Or, if you already have a JSON Schema dict:
842+
await ctx.session.elicit_form("Choose a username.", ElicitRequestedSchema.model_validate(my_schema))
843+
```
844+
845+
The high-level `Context.elicit()` / `elicit_with_validation()` path, which generates the schema
846+
from a Pydantic model class, is unchanged. The wire type `ElicitRequestFormParams.requested_schema`
847+
is still a plain `dict[str, Any]`: the client's inbound parsing deliberately tolerates
848+
non-conforming schemas so older servers (which emit `anyOf` for `Optional` form fields) still
849+
reach the elicitation callback.
850+
815851
### Replace `RootModel` by union types with `TypeAdapter` validation
816852

817853
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/mrtr/server.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
"""Multi-round tool result (2026 era): a tool returns input_required and resumes from echoed state."""
22

3-
from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult
3+
from mcp_types import (
4+
BooleanSchema,
5+
ElicitRequest,
6+
ElicitRequestedSchema,
7+
ElicitRequestFormParams,
8+
ElicitResult,
9+
InputRequiredResult,
10+
)
411

512
from mcp.server.mcpserver import Context, MCPServer
613
from stories._hosting import run_server_from_args
714

8-
CONFIRM_SCHEMA: ElicitRequestedSchema = {
9-
"type": "object",
10-
"properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}},
11-
"required": ["confirm"],
12-
}
15+
CONFIRM_SCHEMA = ElicitRequestedSchema(
16+
properties={"confirm": BooleanSchema(type="boolean", description="Proceed with the deployment?")},
17+
required=["confirm"],
18+
)
1319

1420

1521
def build_server() -> MCPServer:
@@ -22,7 +28,7 @@ async def deploy(env: str, ctx: Context) -> str | InputRequiredResult:
2228
# First round: ask the client to elicit confirmation. request_state is opaque
2329
# to the client; here it carries the step name so the retry can verify the echo.
2430
ask = ElicitRequest(
25-
params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
31+
params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA.to_wire())
2632
)
2733
return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
2834
# Retry round: the client echoed request_state byte-exact and supplied the answer.

examples/stories/mrtr/server_lowlevel.py

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

11-
CONFIRM_SCHEMA: types.ElicitRequestedSchema = {
12-
"type": "object",
13-
"properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}},
14-
"required": ["confirm"],
15-
}
11+
CONFIRM_SCHEMA = types.ElicitRequestedSchema(
12+
properties={"confirm": types.BooleanSchema(type="boolean", description="Proceed with the deployment?")},
13+
required=["confirm"],
14+
)
1615
DEPLOY_INPUT_SCHEMA: dict[str, Any] = {
1716
"type": "object",
1817
"properties": {"env": {"type": "string"}},
@@ -42,7 +41,9 @@ async def call_tool(
4241
responses = params.input_responses
4342
if responses is None or "confirm" not in responses:
4443
ask = types.ElicitRequest(
45-
params=types.ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
44+
params=types.ElicitRequestFormParams(
45+
message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA.to_wire()
46+
)
4647
)
4748
return types.InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
4849
assert params.request_state == "awaiting-confirm", params.request_state

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",

0 commit comments

Comments
 (0)