Skip to content

Commit 49e3b5f

Browse files
committed
Harden the resolver input_required path; adapt its tests to the client driver
- request_state entries are validated unconditionally when the resolver's model is known; an entry that fails validation is dropped and the question re-asked, matching _decode_state's malformed-means-no-progress stance. Accepted models are encoded by alias so the stored shape round-trips through the same validation the client's fresh answer passed. - Recorded state now wins over a re-sent answer on both restore paths (previously only resolvers without an Elicit[T] return arm honored a changed answer). - A fresh answer whose content does not match the requested schema raises an explicit ToolError instead of leaking pydantic internals. - Embedded elicitation is gated on the client's declared form-elicitation capability: a client that did not declare it gets -32021 with the requiredCapabilities payload (the spec says servers MUST NOT send inputRequests the client has not declared support for). - Legacy parity: elicit_with_validation reports accept-with-no-content explicitly; the unexpected-action arm is gone (the action literal makes the remainder provably cancel). - uses_input_required -> _uses_input_required (module-internal). - Tests drive the loop through the real client surfaces - manual wire shapes via session.call_tool(..., allow_input_required=True), outcomes end-to-end through Client.call_tool's auto-driver - plus regressions for forged state, declined-outcome persistence, unknown keys, aliased models, schema-mismatched answers and the rounds cap.
1 parent b6d6e07 commit 49e3b5f

3 files changed

Lines changed: 558 additions & 117 deletions

File tree

src/mcp/server/elicitation.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ async def elicit_with_validation(
114114
the user or automatically generating a response.
115115
116116
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
117+
118+
Raises:
119+
ValueError: If the client accepted the elicitation without supplying content.
117120
"""
118121
json_schema = render_elicitation_schema(schema)
119122

@@ -123,17 +126,15 @@ async def elicit_with_validation(
123126
related_request_id=related_request_id,
124127
)
125128

126-
if result.action == "accept" and result.content is not None:
129+
if result.action == "accept":
130+
if result.content is None:
131+
raise ValueError("Received an accepted elicitation with no content")
127132
# Validate and parse the content using the schema
128133
validated_data = schema.model_validate(result.content)
129134
return AcceptedElicitation(data=validated_data)
130-
elif result.action == "decline":
135+
if result.action == "decline":
131136
return DeclinedElicitation()
132-
elif result.action == "cancel":
133-
return CancelledElicitation()
134-
else: # pragma: no cover
135-
# This should never happen, but handle it just in case
136-
raise ValueError(f"Unexpected elicitation action: {result.action}")
137+
return CancelledElicitation()
137138

138139

139140
async def elicit_url(

src/mcp/server/mcpserver/resolve.py

Lines changed: 85 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,17 @@
3434

3535
import anyio.to_thread
3636
from mcp_types import (
37+
MISSING_REQUIRED_CLIENT_CAPABILITY,
38+
ClientCapabilities,
39+
ElicitationCapability,
3740
ElicitRequest,
3841
ElicitRequestFormParams,
3942
ElicitResult,
43+
FormElicitationCapability,
4044
InputRequests,
4145
InputRequiredResult,
4246
InputResponses,
47+
MissingRequiredClientCapabilityErrorData,
4348
)
4449
from mcp_types.version import is_version_at_least
4550
from pydantic import BaseModel, ValidationError
@@ -55,6 +60,7 @@
5560
from mcp.server.mcpserver.context import Context
5661
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
5762
from mcp.shared._callable_inspection import is_async_callable
63+
from mcp.shared.exceptions import MCPError
5864

5965
T = TypeVar("T", bound=BaseModel)
6066

@@ -189,7 +195,7 @@ def _elicit_return_schema(return_annotation: Any) -> type[BaseModel] | None:
189195
for candidate in candidates:
190196
if get_origin(candidate) is Elicit:
191197
schema = get_args(candidate)[0]
192-
if isinstance(schema, type) and issubclass(schema, BaseModel): # pragma: no branch
198+
if isinstance(schema, type) and issubclass(schema, BaseModel):
193199
return schema
194200
return None
195201

@@ -383,7 +389,7 @@ async def resolve_arguments(
383389
# called directly builds such a `Context`, and a tool whose resolvers never elicit
384390
# must still work there. A missing version means the synchronous (non-input_required)
385391
# transport, which never reaches a server-to-client request anyway.
386-
res = _Resolution(plans, tool_args, context, uses_input_required(context.protocol_version))
392+
res = _Resolution(plans, tool_args, context, _uses_input_required(context.protocol_version))
387393
injected: dict[str, Any] = {}
388394
for name, (marker, wants_union) in resolved_params.items():
389395
try:
@@ -417,13 +423,10 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
417423
# `-> ... Elicit[T]`), fall through and re-run the resolver so `_elicit` can
418424
# re-validate the stored answer against the live `Elicit.schema`.
419425
if wire_key in res.state and (plan.elicit_schema is not None or res.state[wire_key].action != "accept"):
420-
outcome = _outcome_from_state(res.state[wire_key], plan.elicit_schema)
421-
res.cache[cache_key] = outcome
422-
# Carry the restored answer forward: if a later resolver is still pending,
423-
# the next round's `request_state` is built from `res.elicited`, so an
424-
# earlier answer must stay there or it would be dropped and re-asked.
425-
res.elicited[wire_key] = outcome
426-
return outcome
426+
outcome = _restore_outcome(res, wire_key, plan.elicit_schema)
427+
if outcome is not None:
428+
res.cache[cache_key] = outcome
429+
return outcome
427430

428431
kwargs: dict[str, Any] = {}
429432
dep_pending = False
@@ -470,22 +473,30 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
470473
return await res.context.elicit(elicit.message, elicit.schema)
471474

472475
# Answered in a prior round (restored without a known schema, e.g. an unannotated
473-
# resolver): re-validate the stored entry against the live `Elicit.schema`.
474-
if key in res.state and key not in res.answers:
475-
outcome = _outcome_from_state(res.state[key], elicit.schema)
476-
res.elicited[key] = outcome
476+
# resolver): re-validate the stored entry against the live `Elicit.schema`. A
477+
# recorded outcome wins over a re-sent answer; an invalid entry self-deletes and
478+
# falls through to the fresh answer (or to re-asking).
479+
outcome = _restore_outcome(res, key, elicit.schema)
480+
if outcome is not None:
477481
return outcome
478482

479483
answer = res.answers.get(key)
480484
if answer is None:
485+
_require_form_elicitation(res.context, key)
481486
res.pending[key] = _elicit_request(elicit)
482487
raise _Pending
483488
if not isinstance(answer, ElicitResult):
484489
raise ToolError(f"Resolver {key!r} received a non-elicitation response")
485490
if answer.action == "accept":
486491
if answer.content is None:
487492
raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content")
488-
return AcceptedElicitation(data=elicit.schema.model_validate(answer.content))
493+
try:
494+
data = elicit.schema.model_validate(answer.content)
495+
except ValidationError as e:
496+
raise ToolError(
497+
f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema"
498+
) from e
499+
return AcceptedElicitation(data=data)
489500
if answer.action == "decline":
490501
return DeclinedElicitation()
491502
return CancelledElicitation()
@@ -511,7 +522,7 @@ def _accepted(data: Any) -> AcceptedElicitation[Any]:
511522
return AcceptedElicitation[Any].model_construct(data=data)
512523

513524

514-
def uses_input_required(protocol_version: str | None) -> bool:
525+
def _uses_input_required(protocol_version: str | None) -> bool:
515526
"""True when this request must elicit via `InputRequiredResult` (>= 2026-07-28).
516527
517528
Older revisions still carry a standalone `elicitation/create` server-to-client
@@ -520,6 +531,31 @@ def uses_input_required(protocol_version: str | None) -> bool:
520531
return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION)
521532

522533

534+
def _require_form_elicitation(context: Context[Any, Any], key: str) -> None:
535+
"""Assert the client declared form elicitation before queueing a question for it.
536+
537+
The spec forbids sending an `input_requests` entry the client has not declared a
538+
capability for. A bare `elicitation: {}` declaration (the only shape before modes
539+
existed) counts as form support; an explicit url-only declaration does not.
540+
541+
Raises:
542+
MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a
543+
`requiredCapabilities` payload when form elicitation is not declared.
544+
"""
545+
capabilities = context.client_capabilities
546+
elicitation = capabilities.elicitation if capabilities is not None else None
547+
if elicitation is not None and (elicitation.form is not None or elicitation.url is None):
548+
return
549+
data = MissingRequiredClientCapabilityErrorData(
550+
required_capabilities=ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability()))
551+
)
552+
raise MCPError(
553+
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
554+
message=f"Client did not declare the form elicitation capability required by resolver {key!r}",
555+
data=data.model_dump(by_alias=True, mode="json", exclude_none=True),
556+
)
557+
558+
523559
def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest:
524560
"""Render an `Elicit[T]` as the embedded `elicitation/create` request for `input_requests`."""
525561
json_schema = render_elicitation_schema(elicit.schema)
@@ -561,23 +597,54 @@ def _encode_state(outcomes: Mapping[str, ElicitationResult[Any]]) -> str:
561597
for path, outcome in outcomes.items():
562598
data = outcome.data if isinstance(outcome, AcceptedElicitation) else None
563599
if isinstance(data, BaseModel):
564-
data = data.model_dump(mode="json")
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)
565604
entries[path] = _StateEntry(action=outcome.action, data=data)
566605
return _State(v=_STATE_VERSION, outcomes=entries).model_dump_json()
567606

568607

569608
def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]:
570-
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry."""
609+
"""Rebuild an `ElicitationResult` from a decoded `request_state` entry.
610+
611+
Raises:
612+
ValidationError: If `schema` is known and the entry's data does not
613+
validate against it.
614+
"""
571615
if entry.action == "decline":
572616
return DeclinedElicitation()
573617
if entry.action == "cancel":
574618
return CancelledElicitation()
575619
data = entry.data
576-
if schema is not None and isinstance(data, dict):
620+
if schema is not None:
577621
data = schema.model_validate(data)
578622
return _accepted(data)
579623

580624

625+
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) -> ElicitationResult[Any] | None:
626+
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent.
627+
628+
`request_state` is client-trusted, so an entry whose data fails validation gets
629+
the `_decode_state` treatment - dropped as if no progress was recorded, so the
630+
question is asked again - rather than surfacing a validation error.
631+
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.
635+
"""
636+
entry = res.state.get(key)
637+
if entry is None:
638+
return None
639+
try:
640+
outcome = _outcome_from_state(entry, schema)
641+
except ValidationError:
642+
del res.state[key]
643+
return None
644+
res.elicited[key] = outcome
645+
return outcome
646+
647+
581648
__all__ = [
582649
"Resolve",
583650
"Elicit",

0 commit comments

Comments
 (0)