2626from __future__ import annotations
2727
2828import inspect
29- import json
3029import typing
3130from collections .abc import Callable , Hashable , Mapping
32- from typing import Annotated , Any , Generic , cast , get_args , get_origin
31+ from typing import Annotated , Any , Generic , Literal , TypeGuard , get_args , get_origin
3332
3433import anyio .to_thread
3534from mcp_types import (
4140 InputResponses ,
4241)
4342from mcp_types .version import LATEST_MODERN_VERSION , is_version_at_least
44- from pydantic import BaseModel
43+ from pydantic import BaseModel , ValidationError
4544from typing_extensions import TypeVar
4645
4746from mcp .server .elicitation import (
@@ -298,7 +297,7 @@ def __init__(
298297 self .answers : InputResponses = context .input_responses or {} if input_required else {}
299298 self .state = _decode_state (context .request_state ) if input_required else {}
300299 self .cache : dict [str , ElicitationResult [Any ]] = {}
301- self .pending : dict [ str , ElicitRequest ] = {}
300+ self .pending : InputRequests = {}
302301
303302
304303def _state_key (fn : Callable [..., Any ]) -> str :
@@ -342,10 +341,7 @@ async def resolve_arguments(
342341 injected [name ] = outcome if wants_union else _unwrap (outcome , name )
343342
344343 if res .pending :
345- return InputRequiredResult (
346- input_requests = cast ("InputRequests" , res .pending ),
347- request_state = _encode_state (res .cache ),
348- )
344+ return InputRequiredResult (input_requests = res .pending , request_state = _encode_state (res .cache ))
349345 return injected
350346
351347
@@ -379,23 +375,24 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul
379375 dep_outcome = await _resolve (param_plan .resolve .fn , res )
380376 kwargs [param_name ] = dep_outcome if param_plan .wants_union else _unwrap (dep_outcome , param_name )
381377
378+ result : Any
382379 if plan .is_async :
383380 result = await fn (** kwargs )
384381 else :
385382 result = await anyio .to_thread .run_sync (lambda : fn (** kwargs ))
386383
387- if isinstance (result , Elicit ):
388- outcome = await _elicit (cast ( "Elicit[BaseModel]" , result ) , key , res )
384+ if _is_elicit (result ):
385+ outcome = await _elicit (result , key , res )
389386 else :
390- # A resolver may return any type (not just `BaseModel`); `model_construct`
391- # wraps it as an accepted result without validating against the schema bound.
392- outcome = cast ( "AcceptedElicitation[Any]" , AcceptedElicitation . model_construct ( data = result ) )
387+ # A resolver may return any type (not just `BaseModel`), so accept it as the
388+ # outcome without validating against the schema bound.
389+ outcome = _accepted ( result )
393390
394391 res .cache [key ] = outcome
395392 return outcome
396393
397394
398- async def _elicit (elicit : Elicit [BaseModel ], key : str , res : _Resolution ) -> ElicitationResult [Any ]:
395+ async def _elicit (elicit : Elicit [Any ], key : str , res : _Resolution ) -> ElicitationResult [Any ]:
399396 """Turn a resolver's `Elicit` into an outcome via the negotiated transport."""
400397 if not res .input_required :
401398 return await res .context .elicit (elicit .message , elicit .schema )
@@ -419,6 +416,20 @@ def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any:
419416 raise ToolError (f"Resolver for parameter { name !r} could not resolve: elicitation was { outcome .action } " )
420417
421418
419+ def _is_elicit (value : Any ) -> TypeGuard [Elicit [Any ]]:
420+ """Runtime narrow of a resolver's return value to a (parameter-erased) `Elicit`."""
421+ return isinstance (value , Elicit )
422+
423+
424+ def _accepted (data : Any ) -> AcceptedElicitation [Any ]:
425+ """Wrap a resolved value as an accepted outcome without schema validation.
426+
427+ A resolver may return any type (the schema bound only constrains `Elicit[T]`),
428+ and a value restored from `request_state` is already validated.
429+ """
430+ return AcceptedElicitation [Any ].model_construct (data = data )
431+
432+
422433def uses_input_required (protocol_version : str | None ) -> bool :
423434 """True when this request must elicit via `InputRequiredResult` (>= 2026-07-28).
424435
@@ -434,50 +445,56 @@ def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest:
434445 return ElicitRequest (params = ElicitRequestFormParams (message = elicit .message , requested_schema = json_schema ))
435446
436447
437- def _decode_state (request_state : str | None ) -> dict [str , dict [str , Any ]]:
448+ class _StateEntry (BaseModel ):
449+ """One resolver's recorded outcome inside `request_state`."""
450+
451+ action : Literal ["accept" , "decline" , "cancel" ]
452+ data : Any = None
453+
454+
455+ class _State (BaseModel ):
456+ """The decoded `request_state`: resolver outcomes from earlier rounds."""
457+
458+ v : int
459+ outcomes : dict [str , _StateEntry ] = {}
460+
461+
462+ def _decode_state (request_state : str | None ) -> dict [str , _StateEntry ]:
438463 """Decode the per-call resolution progress from `request_state`.
439464
440- `request_state` is client-trusted (integrity sealing is a follow-up); decode
441- defensively and treat anything malformed as "no progress yet".
465+ `request_state` is client-trusted (integrity sealing is a follow-up); validate
466+ it through `_State` and treat anything malformed as "no progress yet".
442467 """
443468 if not request_state :
444469 return {}
445470 try :
446- decoded : Any = json .loads (request_state )
447- except json .JSONDecodeError :
448- return {}
449- if not isinstance (decoded , dict ):
450- return {}
451- payload = cast ("dict[str, Any]" , decoded )
452- if payload .get ("v" ) != _STATE_VERSION :
471+ state = _State .model_validate_json (request_state )
472+ except ValidationError :
453473 return {}
454- outcomes = payload .get ("outcomes" )
455- return cast ("dict[str, dict[str, Any]]" , outcomes ) if isinstance (outcomes , dict ) else {}
474+ return state .outcomes if state .v == _STATE_VERSION else {}
456475
457476
458477def _encode_state (outcomes : Mapping [str , ElicitationResult [Any ]]) -> str :
459478 """Encode resolved outcomes (keyed by resolver path) for the next round."""
460- encoded : dict [str , dict [ str , Any ] ] = {}
479+ entries : dict [str , _StateEntry ] = {}
461480 for path , outcome in outcomes .items ():
462- entry : dict [str , Any ] = {"action" : outcome .action }
463- if isinstance (outcome , AcceptedElicitation ):
464- data = outcome .data
465- entry ["data" ] = data .model_dump (mode = "json" ) if isinstance (data , BaseModel ) else data
466- encoded [path ] = entry
467- return json .dumps ({"v" : _STATE_VERSION , "outcomes" : encoded })
481+ data = outcome .data if isinstance (outcome , AcceptedElicitation ) else None
482+ if isinstance (data , BaseModel ):
483+ data = data .model_dump (mode = "json" )
484+ entries [path ] = _StateEntry (action = outcome .action , data = data )
485+ return _State (v = _STATE_VERSION , outcomes = entries ).model_dump_json ()
468486
469487
470- def _outcome_from_state (entry : Mapping [ str , Any ] , schema : type [BaseModel ] | None ) -> ElicitationResult [Any ]:
488+ def _outcome_from_state (entry : _StateEntry , schema : type [BaseModel ] | None ) -> ElicitationResult [Any ]:
471489 """Rebuild an `ElicitationResult` from a decoded `request_state` entry."""
472- action = entry .get ("action" )
473- if action == "decline" :
490+ if entry .action == "decline" :
474491 return DeclinedElicitation ()
475- if action == "cancel" :
492+ if entry . action == "cancel" :
476493 return CancelledElicitation ()
477- data = entry .get ( " data" )
494+ data = entry .data
478495 if schema is not None and isinstance (data , dict ):
479496 data = schema .model_validate (data )
480- return cast ( "AcceptedElicitation[Any]" , AcceptedElicitation . model_construct ( data = data ) )
497+ return _accepted ( data )
481498
482499
483500__all__ = [
0 commit comments