Drive resolver elicitation over the 2026-07-28 input_required flow
Summary
#2969 added the Resolve/Elicit resolver dependency-injection API for @mcp.tool() functions, eliciting via a synchronous ctx.elicit() request mid-call - the 2025 model, which doesn't exist at 2026-07-28 (sampling/roots/elicitation are only embedded in InputRequiredResult there).
This PR makes the resolver Elicit[T] path negotiate the transport by protocol version, so the same author-facing code works on both:
>= 2026-07-28- the framework returns anInputRequiredResultcarrying the batched questions and resumes when the client retriescall_tool(..., input_responses=..., request_state=...). Independent resolvers are asked together in one round; a resolver that depends on another's answer is asked in a later round.<= 2025-11-25- it keeps the synchronouselicitation/createrequest mid-call (no regression for current clients).
Author-facing code is unchanged - resolvers still return T / Elicit[T]; the framework drives the multi-round loop. And since Client.call_tool auto-drives InputRequiredResult retries through the client's normal callbacks (SEP-2322, #2998), a client with an elicitation_callback handles resolver questions transparently on either protocol version - several tests here prove that loop end-to-end.
async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]:
if not list_files(path):
return Confirm(ok=True) # empty folder: no question, no round-trip
return Elicit(f"{path} has files. Delete?", Confirm)
@mcp.tool()
async def delete_folder(path: str, confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)]) -> str:
match confirm:
case AcceptedElicitation(data=Confirm(ok=True)): ... # delete
case DeclinedElicitation(): ... # user declined the prompt
case CancelledElicitation(): ... # user cancelled
How it works
resolve_argumentsbecomes a resumable DAG walk: it readsctx.input_responses/ctx.request_state, runs each resolver, and collects anyElicitit can't yet answer into apendingmap. If anything is pending,Tool.runreturns theInputRequiredResultinstead of running the body.- Only elicited answers persist in
request_stateacross rounds, keyed by a worker-stablemodule:qualnamewire key, so each question is asked exactly once per logical call and two consumers of one resolver share one question. A resolver that resolves without eliciting is pure and may re-run on each round; an eliciting resolver runs again to consume its answer. - Persisted entries store the exact content the client sent (which already passed validation) - encode and restore are the identity on the client's bytes, so no alias or serialization configuration can break the round-trip. Restored entries are re-validated into the resolver's
Elicit[T]model;request_stateis client-supplied, so an entry that fails validation is treated as no-progress and the question is re-asked (the same stance the state decoder takes for a malformed envelope). A fresh answer whose content doesn't match the requested schema is an explicit tool error - on both transports (the legacy path previously let the raw pydanticValidationErrortext reach the client; that change is noted in the migration guide). - A return annotation with more than one distinct
Elicitarm (-> Elicit[A] | Elicit[B]) raisesInvalidSignatureat registration, like cycles and unclassifiable parameters: the restore path validates against one statically-known schema, so the ambiguous shape either never converges or silently injects a wrong-typed model when the shapes overlap. - The embedded elicitation is gated on the client's declared capability, per the spec's MUST: a client that didn't declare (form) elicitation support gets a
-32021missing-required-client-capability error with therequiredCapabilitiespayload, instead of questions it can't render. - New
render_elicitation_schema()helper inelicitation.py(shared byelicit_with_validationand theinput_requestsrendering). The legacy path also now reports accept-with-no-content explicitly instead of an "unexpected action" error.
Docs & examples
- The Dependencies tutorial's "Ask when you must" section explains the version-negotiated transport, scopes the run-once guarantee to what is actually true (each question is asked once; an eliciting resolver runs again to consume its answer; a non-eliciting resolver may re-run when a call resumes), and documents the determinism constraint (a question must derive from tool args and earlier answers - per-round generated values like a
default_factoryid don't bind across rounds). Multi-round-trip requests no longer claims@mcp.tool()has no high-level path toInputRequiredResult- resolver DI is that path now. examples/stories/refund_deskflips fromera = "legacy"toera = "dual"- the manifest comment said to do exactly that once this capability landed - so the story runs the full matrix on both protocol generations with identical author code.
Out of scope (follow-ups)
request_stateis client-trusted here: forged entries can't smuggle unvalidated or wrongly-typed values into tool parameters (restore validation, above), but a client can still fabricate a valid-shaped outcome (e.g. a confirmation it never showed the user) - the same thing a lying client can do by answering the live question, on any transport. What sealing adds is integrity against third-party tampering and replay/cross-request binding; HMAC-sealingrequest_statewith a per-server key is a separate PR (the typescript-sdk staged it the same way).
Tests
tests/server/mcpserver/test_resolve.py: the MRTR loop is tested both manually (per-round wire shapes via client.session.call_tool(..., allow_input_required=True): first-round question shape, independent questions batched into one round, factory closures with distinct wire keys, non-elicitation response) and end-to-end through the client auto-driver (accept/decline/cancel outcomes, dependent chains asked across rounds with each question answered exactly once, deep chains, restored typed models, accept-with-no-content). Hardening tests: forged request_state (non-dict and schema-failing data) re-asks instead of erroring or injecting, declines persist across rounds, unknown wire keys are ignored, aliased and divergent-alias models round-trip, schema-mismatched fresh answers produce the explicit error on both transports, an 11-deep chain trips the client's input_required_max_rounds, multi-arm Elicit annotations are rejected at registration, and the -32021 capability gate. The synchronous legacy path keeps its own legs, and the docs/story suites run both transports. 100% branch coverage.