Skip to content

Commit dc84bb3

Browse files
committed
Drive resolver elicitation over the 2026-07-28 input_required flow
Resolvers that return Elicit[T] now negotiate the transport by protocol version: at >= 2026-07-28 the framework returns an InputRequiredResult carrying the batched questions and resumes when the client retries with input_responses/request_state; at <= 2025-11-25 it keeps the synchronous ctx.elicit() request. Author-facing code (Resolve/Elicit) is unchanged. resolve_arguments becomes a resumable DAG walk: it reads ctx.input_responses / ctx.request_state, memoizes resolver outcomes by a process-stable module:qualname key, batches independent pending elicitations into one round, serializes dependent ones across rounds, and carries resolved outcomes in request_state so each resolver resolves once per logical call. Outcomes restored from request_state are re-validated into their model via the Elicit[T] return arm. request_state is client-trusted for now (HMAC sealing is a follow-up). Add a render_elicitation_schema helper to elicitation.py, MRTR-loop and codec tests, and document the transport in the migration guide.
1 parent 8f677c9 commit dc84bb3

5 files changed

Lines changed: 500 additions & 36 deletions

File tree

docs/migration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1515,6 +1515,8 @@ async def delete_folder(
15151515

15161516
The `confirm_delete` resolver reads the tool's own `path` argument by name, lists the folder, and only elicits when the folder is non-empty - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. Because `delete_folder` annotates the result union, it handles every outcome: the user accepting and confirming, accepting but declining to delete (`ok=False`), declining the elicitation, or cancelling it.
15171517

1518+
The framework drives elicitation over whichever transport the negotiated protocol provides, so the resolver and tool code above is unchanged either way. At `2026-07-28` and later it returns an `InputRequiredResult` carrying the questions and resumes when the client retries `call_tool(..., input_responses=..., request_state=...)` (independent resolvers are batched into one round; a resolver that depends on another's answer is asked in a later round). At `2025-11-25` and earlier it issues a synchronous `elicitation/create` request mid-call. Resolved outcomes are carried in `request_state` across rounds so each resolver resolves once per call.
1519+
15181520
Resolved parameters are omitted from the tool's input schema, so the client never supplies them. Resolver parameters that cannot be classified, and cyclic resolver dependencies, raise at registration time.
15191521

15201522
## Need Help?

src/mcp/server/elicitation.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
8787
) from None
8888

8989

90+
def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]:
91+
"""Render a model as the spec-valid `requested_schema` for an elicitation.
92+
93+
Raises:
94+
TypeError: If a field renders as something the spec's
95+
`PrimitiveSchemaDefinition` does not accept.
96+
"""
97+
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
98+
_validate_rendered_properties(json_schema)
99+
return json_schema
100+
101+
90102
async def elicit_with_validation(
91103
session: ServerSession,
92104
message: str,
@@ -103,8 +115,7 @@ async def elicit_with_validation(
103115
104116
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
105117
"""
106-
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
107-
_validate_rendered_properties(json_schema)
118+
json_schema = render_elicitation_schema(schema)
108119

109120
result = await session.elicit_form(
110121
message=message,

src/mcp/server/mcpserver/resolve.py

Lines changed: 211 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,40 @@
77
`Elicit[T]` to ask the client; the framework runs the elicitation and injects the
88
answer.
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+
1018
Whether 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

2026
from __future__ import annotations
2127

2228
import inspect
29+
import json
2330
import typing
2431
from collections.abc import Callable, Hashable, Mapping
2532
from typing import Annotated, Any, Generic, cast, get_args, get_origin
2633

2734
import 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
2844
from pydantic import BaseModel
2945
from typing_extensions import TypeVar
3046

@@ -33,6 +49,7 @@
3349
CancelledElicitation,
3450
DeclinedElicitation,
3551
ElicitationResult,
52+
render_elicitation_schema,
3653
)
3754
from mcp.server.mcpserver.context import Context
3855
from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError
@@ -43,6 +60,11 @@
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

4769
class 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
79101
class _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

88119
def _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+
128174
def _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+
229315
async 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+
293416
def _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

Comments
 (0)