Drive resolver elicitation over the 2026-07-28 input_required flow

Merged
KL
4mo ago
+1521-83

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 an InputRequiredResult carrying the batched questions and resumes when the client retries call_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 synchronous elicitation/create request 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_arguments becomes a resumable DAG walk: it reads ctx.input_responses / ctx.request_state, runs each resolver, and collects any Elicit it can't yet answer into a pending map. If anything is pending, Tool.run returns the InputRequiredResult instead of running the body.
  • Only elicited answers persist in request_state across rounds, keyed by a worker-stable module:qualname wire 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_state is 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 pydantic ValidationError text reach the client; that change is noted in the migration guide).
  • A return annotation with more than one distinct Elicit arm (-> Elicit[A] | Elicit[B]) raises InvalidSignature at 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 -32021 missing-required-client-capability error with the requiredCapabilities payload, instead of questions it can't render.
  • New render_elicitation_schema() helper in elicitation.py (shared by elicit_with_validation and the input_requests rendering). 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_factory id don't bind across rounds). Multi-round-trip requests no longer claims @mcp.tool() has no high-level path to InputRequiredResult - resolver DI is that path now.
  • examples/stories/refund_desk flips from era = "legacy" to era = "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_state is 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-sealing request_state with 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.

AI Disclaimer