Skip to content

Commit ef53c4d

Browse files
committed
Persist elicited answers as raw wire content; reject ambiguous Elicit returns
request_state entries now store exactly the content the client sent (which already passed validation) instead of re-deriving it from the validated model with model_dump - persistence and restore previously used two codecs that diverge whenever validation and serialization aliases differ, dropping a legitimate stored answer on the third round and re-asking forever. Encode and restore are now the identity on the client's bytes, so no serialization knob can break the round-trip; the earlier by-alias dump is gone with the rest of the model-dumping path. A return annotation with more than one distinct Elicit arm now raises InvalidSignature at registration: the restore path validates against the single statically-known schema, so `-> Elicit[A] | Elicit[B]` either never converges (the stored B answer fails A validation each round) or silently injects a wrong-typed model when the shapes happen to overlap. Like cycles and unclassifiable parameters, the ambiguous shape is rejected up front. Legacy parity: elicit_with_validation wraps a schema-mismatched accepted answer in the same stable error the 2026 path uses, instead of letting the raw pydantic ValidationError text reach the client; noted in the migration guide since callers catching ValidationError see ValueError now.
1 parent 85756a7 commit ef53c4d

6 files changed

Lines changed: 163 additions & 60 deletions

File tree

docs/migration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,8 @@ Positional calls (`await ctx.info("hello")`) are unaffected.
786786

787787
`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.
788788

789+
A schema-mismatched *accepted* answer also fails differently: the call now raises `ValueError` with a stable message ("Received an accepted elicitation whose content does not match the requested schema") instead of letting pydantic's `ValidationError` escape with its internals. Code that caught `ValidationError` around `ctx.elicit()` should catch `ValueError` (or rely on the tool's error result).
790+
789791
### Replace `RootModel` by union types with `TypeAdapter` validation
790792

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

docs/tutorial/elicitation.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ A refusal is not an error. The tool decides what declining means (here, no booki
7676

7777
!!! tip
7878
The answer is validated against your model before your code sees it. A client that sends
79-
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the
80-
`ValidationError`, your `if` never runs.
79+
`"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a
80+
schema-mismatch error, your `if` never runs.
8181

8282
## Ask before the tool runs
8383

src/mcp/server/elicitation.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ async def elicit_with_validation(
116116
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
117117
118118
Raises:
119-
ValueError: If the client accepted the elicitation without supplying content.
119+
ValueError: If the client accepted the elicitation without supplying
120+
content, or with content that does not match the requested schema.
120121
"""
121122
json_schema = render_elicitation_schema(schema)
122123

@@ -129,8 +130,12 @@ async def elicit_with_validation(
129130
if result.action == "accept":
130131
if result.content is None:
131132
raise ValueError("Received an accepted elicitation with no content")
132-
# Validate and parse the content using the schema
133-
validated_data = schema.model_validate(result.content)
133+
try:
134+
validated_data = schema.model_validate(result.content)
135+
except ValidationError as e:
136+
raise ValueError(
137+
"Received an accepted elicitation whose content does not match the requested schema"
138+
) from e
134139
return AcceptedElicitation(data=validated_data)
135140
if result.action == "decline":
136141
return DeclinedElicitation()

src/mcp/server/mcpserver/resolve.py

Lines changed: 42 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -183,21 +183,30 @@ def _contains_resolve(annotation: Any) -> bool:
183183
return any(_contains_resolve(arg) for arg in get_args(annotation))
184184

185185

186-
def _elicit_return_schema(return_annotation: Any) -> type[BaseModel] | None:
186+
def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] | None:
187187
"""Extract `T` from a resolver return type's `Elicit[T]` arm, if present.
188188
189189
Handles a bare `-> Elicit[T]` and a `-> T | Elicit[T]` union. Lets an elicited
190190
outcome restored from `request_state` (a plain dict) be re-validated into its
191191
model so dependent resolvers and tools receive a typed value.
192+
193+
Raises:
194+
InvalidSignature: If the annotation has more than one `Elicit[...]` arm;
195+
the runtime can honor only one static question schema per resolver.
192196
"""
193197
# A bare `Elicit[T]` is itself a candidate; a union contributes its members.
194198
candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,)
195-
for candidate in candidates:
196-
if get_origin(candidate) is Elicit:
197-
schema = get_args(candidate)[0]
198-
if isinstance(schema, type) and issubclass(schema, BaseModel):
199-
return schema
200-
return None
199+
# Typing dedupes equal union members, so two arms here are genuinely distinct.
200+
arms = [c for c in candidates if get_origin(c) is Elicit]
201+
if len(arms) > 1:
202+
raise InvalidSignature(
203+
f"Resolver {name!r} return annotation has multiple Elicit arms; "
204+
"a resolver asks one question - split it into separate resolvers"
205+
)
206+
if not arms:
207+
return None
208+
schema = get_args(arms[0])[0]
209+
return schema if isinstance(schema, type) and issubclass(schema, BaseModel) else None
201210

202211

203212
def _is_union(annotation: Any) -> bool:
@@ -290,9 +299,8 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None:
290299
"expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
291300
)
292301

293-
plans[key] = _ResolverPlan(
294-
fn, params, is_async_callable(fn), _elicit_return_schema(hints.get("return")), wire_key
295-
)
302+
elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn))
303+
plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key)
296304
for dep in nested:
297305
analyze(dep, stack + (key,))
298306

@@ -342,11 +350,13 @@ def __init__(
342350
self.answers: InputResponses = context.input_responses or {} if input_required else {}
343351
self.state = _decode_state(context.request_state) if input_required else {}
344352
# In-call dedup keyed by resolver identity (distinguishes two instances of
345-
# the same bound method); `elicited` holds only outcomes that came from an
346-
# elicitation, keyed by their wire key - these are what `request_state`
347-
# persists, since pure resolvers are cheap to re-run each round.
353+
# the same bound method); `persist` holds the wire-shaped record of each
354+
# elicited outcome, keyed by its wire key - exactly what the next round's
355+
# `request_state` carries. Entries are the client's own (validated) wire
356+
# data, never re-derived from a model, so encode-restore is the identity.
357+
# Pure resolvers are cheap to re-run each round and are not persisted.
348358
self.cache: dict[Hashable, ElicitationResult[Any]] = {}
349-
self.elicited: dict[str, ElicitationResult[Any]] = {}
359+
self.persist: dict[str, _StateEntry] = {}
350360
self.pending: InputRequests = {}
351361

352362

@@ -399,7 +409,7 @@ async def resolve_arguments(
399409
injected[name] = outcome if wants_union else _unwrap(outcome, name)
400410

401411
if res.pending:
402-
return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.elicited))
412+
return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist))
403413
return injected
404414

405415

@@ -456,7 +466,6 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
456466

457467
if _is_elicit(result):
458468
outcome = await _elicit(result, wire_key, res)
459-
res.elicited[wire_key] = outcome
460469
else:
461470
# A resolver may return any type (not just `BaseModel`), so accept it as the
462471
# outcome without validating against the schema bound. Plain outcomes are not
@@ -496,9 +505,14 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
496505
raise ToolError(
497506
f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema"
498507
) from e
508+
# Persist the exact wire content that just passed validation - never the
509+
# model - so restoring next round revalidates the same bytes the client sent.
510+
res.persist[key] = _StateEntry(action="accept", data=answer.content)
499511
return AcceptedElicitation(data=data)
500512
if answer.action == "decline":
513+
res.persist[key] = _StateEntry(action="decline")
501514
return DeclinedElicitation()
515+
res.persist[key] = _StateEntry(action="cancel")
502516
return CancelledElicitation()
503517

504518

@@ -591,18 +605,13 @@ def _decode_state(request_state: str | None) -> dict[str, _StateEntry]:
591605
return state.outcomes if state.v == _STATE_VERSION else {}
592606

593607

594-
def _encode_state(outcomes: Mapping[str, ElicitationResult[Any]]) -> str:
595-
"""Encode resolved outcomes (keyed by resolver path) for the next round."""
596-
entries: dict[str, _StateEntry] = {}
597-
for path, outcome in outcomes.items():
598-
data = outcome.data if isinstance(outcome, AcceptedElicitation) else None
599-
if isinstance(data, BaseModel):
600-
# By alias: the stored shape must round-trip through
601-
# `schema.model_validate` on restore, which expects the alias-keyed
602-
# form the client answered with (the rendered schema is alias-keyed).
603-
data = data.model_dump(mode="json", by_alias=True)
604-
entries[path] = _StateEntry(action=outcome.action, data=data)
605-
return _State(v=_STATE_VERSION, outcomes=entries).model_dump_json()
608+
def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str:
609+
"""Encode recorded elicitation outcomes (keyed by wire key) for the next round.
610+
611+
Entries already hold the client's wire-shaped data exactly as it was sent (and
612+
validated), so encoding is pure wrapping: encode-restore is the identity.
613+
"""
614+
return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json()
606615

607616

608617
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]:
@@ -629,9 +638,10 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None)
629638
the `_decode_state` treatment - dropped as if no progress was recorded, so the
630639
question is asked again - rather than surfacing a validation error.
631640
632-
Carries a restored outcome forward in `res.elicited`: if a later resolver is
633-
still pending, the next round's `request_state` is built from `res.elicited`,
634-
so an earlier answer must stay there or it would be dropped and re-asked.
641+
Carries the original decoded entry forward unchanged in `res.persist`: if a
642+
later resolver is still pending, the next round's `request_state` is built from
643+
`res.persist`, so an earlier answer must stay there - byte-identical, never
644+
re-derived - or it would be dropped and re-asked.
635645
"""
636646
entry = res.state.get(key)
637647
if entry is None:
@@ -641,7 +651,7 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None)
641651
except ValidationError:
642652
del res.state[key]
643653
return None
644-
res.elicited[key] = outcome
654+
res.persist[key] = entry
645655
return outcome
646656

647657

tests/docs_src/test_elicitation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams)
124124
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
125125
assert result.is_error
126126
assert isinstance(result.content[0], TextContent)
127-
assert "Input should be a valid boolean" in result.content[0].text
127+
assert "does not match the requested schema" in result.content[0].text
128128

129129

130130
class Address(BaseModel):

0 commit comments

Comments
 (0)