77`Elicit[T]` to ask the client; the framework runs the elicitation and injects the
88answer.
99
10+ The framework picks the elicitation transport from the negotiated protocol. At
11+ >= 2026-07-28 it returns an `InputRequiredResult` carrying the batched questions
12+ and resumes when the client retries with `input_responses`/`request_state`
13+ (independent resolvers are asked in one round; a resolver depending on another's
14+ answer is asked in a later round). At <= 2025-11-25 it issues a synchronous
15+ `elicitation/create` request mid-call. Resolved outcomes are carried in
16+ `request_state` across rounds, so each resolver resolves once per logical call.
17+
1018Whether the consumer receives the unwrapped model or the full
1119`ElicitationResult` union is decided by the consumer's annotation:
1220
1321- `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call.
1422- `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the
1523 full outcome; the consumer branches on accept/decline/cancel.
16-
17- Each resolver runs at most once per `tools/call` (memoized by function identity).
1824"""
1925
2026from __future__ import annotations
2127
2228import inspect
29+ import json
2330import typing
2431from collections .abc import Callable , Hashable , Mapping
2532from typing import Annotated , Any , Generic , cast , get_args , get_origin
2633
2734import anyio .to_thread
35+ from mcp_types import (
36+ ElicitRequest ,
37+ ElicitRequestFormParams ,
38+ ElicitResult ,
39+ InputRequests ,
40+ InputRequiredResult ,
41+ InputResponses ,
42+ )
43+ from mcp_types .version import LATEST_MODERN_VERSION , is_version_at_least
2844from pydantic import BaseModel
2945from typing_extensions import TypeVar
3046
3349 CancelledElicitation ,
3450 DeclinedElicitation ,
3551 ElicitationResult ,
52+ render_elicitation_schema ,
3653)
3754from mcp .server .mcpserver .context import Context
3855from mcp .server .mcpserver .exceptions import InvalidSignature , ToolError
4360# The union members the framework injects when a consumer opts into the outcome.
4461_ELICITATION_RESULT_MEMBERS = (AcceptedElicitation , DeclinedElicitation , CancelledElicitation )
4562
63+ # First protocol revision whose `tools/call` carries elicitation inside
64+ # `InputRequiredResult` rather than as a standalone server-to-client request.
65+ _INPUT_REQUIRED_VERSION = LATEST_MODERN_VERSION # "2026-07-28"
66+ _STATE_VERSION = 1
67+
4668
4769class Resolve :
4870 """Marker for `Annotated[T, Resolve(fn)]`: fill the parameter by running `fn`."""
@@ -79,10 +101,19 @@ def __init__(self, kind: str, resolve: Resolve | None = None, wants_union: bool
79101class _ResolverPlan :
80102 """A resolver's parameters and whether it is async, analyzed once."""
81103
82- def __init__ (self , fn : Callable [..., Any ], params : dict [str , _ParamPlan ], is_async : bool ) -> None :
104+ def __init__ (
105+ self ,
106+ fn : Callable [..., Any ],
107+ params : dict [str , _ParamPlan ],
108+ is_async : bool ,
109+ elicit_schema : type [BaseModel ] | None ,
110+ ) -> None :
83111 self .fn = fn
84112 self .params = params
85113 self .is_async = is_async
114+ # The `T` from the resolver's `Elicit[T]` return arm, if annotated. Used to
115+ # re-validate an outcome restored from `request_state` into a model.
116+ self .elicit_schema = elicit_schema
86117
87118
88119def _type_hints (fn : Callable [..., Any ]) -> dict [str , Any ]:
@@ -125,6 +156,21 @@ def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve,
125156 return resolved
126157
127158
159+ def _elicit_return_schema (return_annotation : Any ) -> type [BaseModel ] | None :
160+ """Extract `T` from a resolver return type's `Elicit[T]` arm, if present.
161+
162+ Lets an outcome restored from `request_state` (a plain dict) be re-validated
163+ into its model so dependent resolvers and tools receive a typed value.
164+ """
165+ candidates = get_args (return_annotation ) if get_origin (return_annotation ) is not None else (return_annotation ,)
166+ for candidate in candidates :
167+ if get_origin (candidate ) is Elicit :
168+ schema = get_args (candidate )[0 ]
169+ if isinstance (schema , type ) and issubclass (schema , BaseModel ): # pragma: no branch
170+ return schema
171+ return None
172+
173+
128174def _wants_union (type_arg : Any ) -> bool :
129175 """True when `type_arg` is an `ElicitationResult` member (or a union of them).
130176
@@ -202,7 +248,7 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None:
202248 "expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name"
203249 )
204250
205- plans [key ] = _ResolverPlan (fn , params , is_async_callable (fn ))
251+ plans [key ] = _ResolverPlan (fn , params , is_async_callable (fn ), _elicit_return_schema ( hints . get ( "return" )) )
206252 for dep in nested :
207253 analyze (dep , stack + (key ,))
208254
@@ -226,76 +272,214 @@ def _is_context_annotation(annotation: Any) -> bool:
226272 return any (isinstance (c , type ) and issubclass (c , Context ) for c in candidates )
227273
228274
275+ class _Pending (Exception ):
276+ """Internal: a resolver needs client input not yet available this round."""
277+
278+
279+ class _Resolution :
280+ """Per-`tools/call` resolution state, shared across the DAG walk.
281+
282+ `input_required` selects the transport: at >= 2026-07-28 elicitations are
283+ batched into `pending` and surfaced as an `InputRequiredResult`; at older
284+ revisions each `Elicit` is answered synchronously via `ctx.elicit`.
285+ """
286+
287+ def __init__ (
288+ self ,
289+ plans : Mapping [Hashable , _ResolverPlan ],
290+ tool_args : Mapping [str , Any ],
291+ context : Context [Any , Any ],
292+ input_required : bool ,
293+ ) -> None :
294+ self .plans = plans
295+ self .tool_args = tool_args
296+ self .context = context
297+ self .input_required = input_required
298+ self .answers : InputResponses = context .input_responses or {} if input_required else {}
299+ self .state = _decode_state (context .request_state ) if input_required else {}
300+ self .cache : dict [str , ElicitationResult [Any ]] = {}
301+ self .pending : dict [str , ElicitRequest ] = {}
302+
303+
304+ def _state_key (fn : Callable [..., Any ]) -> str :
305+ """Process-stable wire key for a resolver.
306+
307+ `id`-based keys aren't stable across `input_required` rounds (a retry may land
308+ on a different worker), so memoize and key `input_requests`/`request_state` by
309+ the resolver's `module:qualname`. Two consumers of the same resolver therefore
310+ share one cache entry, one question, and one stored outcome.
311+ """
312+ return f"{ getattr (fn , '__module__' , '' )} :{ getattr (fn , '__qualname__' , fn )} "
313+
314+
229315async def resolve_arguments (
230316 resolved_params : Mapping [str , tuple [Resolve , bool ]],
231317 plans : Mapping [Hashable , _ResolverPlan ],
232318 tool_args : Mapping [str , Any ],
233319 context : Context [Any , Any ],
234- ) -> dict [str , Any ]:
320+ ) -> dict [str , Any ] | InputRequiredResult :
235321 """Resolve every `Resolve`-marked tool parameter into a concrete value.
236322
237- Each resolver runs at most once (memoized by function identity). Returns a
238- mapping of tool parameter name to the value to inject.
323+ Returns the mapping of tool parameter name to injected value when every
324+ resolver is satisfied. When a resolver still needs client input (and the
325+ negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult`
326+ carrying the batched questions instead; the tool body is not run.
327+
328+ Each resolver runs at most once per logical call - across multiple
329+ `input_required` rounds, resolved outcomes are carried in `request_state`.
239330
240331 Raises:
241332 ToolError: If an elicited value is declined or cancelled and the consumer
242333 asked for the unwrapped model (rather than the result union).
243334 """
244- cache : dict [ Hashable , ElicitationResult [ Any ]] = {}
335+ res = _Resolution ( plans , tool_args , context , uses_input_required ( context . request_context . protocol_version ))
245336 injected : dict [str , Any ] = {}
246337 for name , (marker , wants_union ) in resolved_params .items ():
247- outcome = await _resolve (marker .fn , plans , tool_args , context , cache )
338+ try :
339+ outcome = await _resolve (marker .fn , res )
340+ except _Pending :
341+ continue
248342 injected [name ] = outcome if wants_union else _unwrap (outcome , name )
343+
344+ if res .pending :
345+ return InputRequiredResult (
346+ input_requests = cast ("InputRequests" , res .pending ),
347+ request_state = _encode_state (res .cache ),
348+ )
249349 return injected
250350
251351
252- async def _resolve (
253- fn : Callable [..., Any ],
254- plans : Mapping [Hashable , _ResolverPlan ],
255- tool_args : Mapping [str , Any ],
256- context : Context [Any , Any ],
257- cache : dict [Hashable , ElicitationResult [Any ]],
258- ) -> ElicitationResult [Any ]:
259- key = _resolver_key (fn )
260- if key in cache :
261- return cache [key ]
352+ async def _resolve (fn : Callable [..., Any ], res : _Resolution ) -> ElicitationResult [Any ]:
353+ """Resolve one resolver, memoized by its process-stable state key.
354+
355+ Raises `_Pending` when the resolver (or one of its dependencies) needs client
356+ input that has not arrived yet.
357+ """
358+ key = _state_key (fn )
359+ if key in res .cache :
360+ return res .cache [key ]
361+ if key in res .pending :
362+ # Already asked this round by another consumer; don't run the resolver again.
363+ raise _Pending
364+
365+ plan = res .plans [_resolver_key (fn )]
366+ if key in res .state :
367+ outcome = _outcome_from_state (res .state [key ], plan .elicit_schema )
368+ res .cache [key ] = outcome
369+ return outcome
262370
263- plan = plans [key ]
264371 kwargs : dict [str , Any ] = {}
265372 for param_name , param_plan in plan .params .items ():
266373 if param_plan .kind == "context" :
267- kwargs [param_name ] = context
374+ kwargs [param_name ] = res . context
268375 elif param_plan .kind == "by_name" :
269- kwargs [param_name ] = tool_args [param_name ]
376+ kwargs [param_name ] = res . tool_args [param_name ]
270377 else :
271378 assert param_plan .resolve is not None
272- dep_outcome = await _resolve (param_plan .resolve .fn , plans , tool_args , context , cache )
379+ dep_outcome = await _resolve (param_plan .resolve .fn , res )
273380 kwargs [param_name ] = dep_outcome if param_plan .wants_union else _unwrap (dep_outcome , param_name )
274381
275382 if plan .is_async :
276383 result = await fn (** kwargs )
277384 else :
278385 result = await anyio .to_thread .run_sync (lambda : fn (** kwargs ))
279386
280- outcome : ElicitationResult [Any ]
281387 if isinstance (result , Elicit ):
282- elicit = cast ("Elicit[BaseModel]" , result )
283- outcome = await context .elicit (elicit .message , elicit .schema )
388+ outcome = await _elicit (cast ("Elicit[BaseModel]" , result ), key , res )
284389 else :
285390 # A resolver may return any type (not just `BaseModel`); `model_construct`
286391 # wraps it as an accepted result without validating against the schema bound.
287392 outcome = cast ("AcceptedElicitation[Any]" , AcceptedElicitation .model_construct (data = result ))
288393
289- cache [key ] = outcome
394+ res . cache [key ] = outcome
290395 return outcome
291396
292397
398+ async def _elicit (elicit : Elicit [BaseModel ], key : str , res : _Resolution ) -> ElicitationResult [Any ]:
399+ """Turn a resolver's `Elicit` into an outcome via the negotiated transport."""
400+ if not res .input_required :
401+ return await res .context .elicit (elicit .message , elicit .schema )
402+
403+ answer = res .answers .get (key )
404+ if answer is None :
405+ res .pending [key ] = _elicit_request (elicit )
406+ raise _Pending
407+ if not isinstance (answer , ElicitResult ):
408+ raise ToolError (f"Resolver { key !r} received a non-elicitation response" )
409+ if answer .action == "accept" and answer .content is not None :
410+ return AcceptedElicitation (data = elicit .schema .model_validate (answer .content ))
411+ if answer .action == "decline" :
412+ return DeclinedElicitation ()
413+ return CancelledElicitation ()
414+
415+
293416def _unwrap (outcome : ElicitationResult [Any ], name : str ) -> Any :
294417 if isinstance (outcome , AcceptedElicitation ):
295418 return outcome .data
296419 raise ToolError (f"Resolver for parameter { name !r} could not resolve: elicitation was { outcome .action } " )
297420
298421
422+ def uses_input_required (protocol_version : str | None ) -> bool :
423+ """True when this request must elicit via `InputRequiredResult` (>= 2026-07-28).
424+
425+ Older revisions still carry a standalone `elicitation/create` server-to-client
426+ request, so the framework keeps the synchronous `ctx.elicit()` path for them.
427+ """
428+ return protocol_version is not None and is_version_at_least (protocol_version , _INPUT_REQUIRED_VERSION )
429+
430+
431+ def _elicit_request (elicit : Elicit [Any ]) -> ElicitRequest :
432+ """Render an `Elicit[T]` as the embedded `elicitation/create` request for `input_requests`."""
433+ json_schema = render_elicitation_schema (elicit .schema )
434+ return ElicitRequest (params = ElicitRequestFormParams (message = elicit .message , requested_schema = json_schema ))
435+
436+
437+ def _decode_state (request_state : str | None ) -> dict [str , dict [str , Any ]]:
438+ """Decode the per-call resolution progress from `request_state`.
439+
440+ `request_state` is client-trusted (integrity sealing is a follow-up); decode
441+ defensively and treat anything malformed as "no progress yet".
442+ """
443+ if not request_state :
444+ return {}
445+ 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 :
453+ return {}
454+ outcomes = payload .get ("outcomes" )
455+ return cast ("dict[str, dict[str, Any]]" , outcomes ) if isinstance (outcomes , dict ) else {}
456+
457+
458+ def _encode_state (outcomes : Mapping [str , ElicitationResult [Any ]]) -> str :
459+ """Encode resolved outcomes (keyed by resolver path) for the next round."""
460+ encoded : dict [str , dict [str , Any ]] = {}
461+ 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 })
468+
469+
470+ def _outcome_from_state (entry : Mapping [str , Any ], schema : type [BaseModel ] | None ) -> ElicitationResult [Any ]:
471+ """Rebuild an `ElicitationResult` from a decoded `request_state` entry."""
472+ action = entry .get ("action" )
473+ if action == "decline" :
474+ return DeclinedElicitation ()
475+ if action == "cancel" :
476+ return CancelledElicitation ()
477+ data = entry .get ("data" )
478+ if schema is not None and isinstance (data , dict ):
479+ data = schema .model_validate (data )
480+ return cast ("AcceptedElicitation[Any]" , AcceptedElicitation .model_construct (data = data ))
481+
482+
299483__all__ = [
300484 "Resolve" ,
301485 "Elicit" ,
0 commit comments