Skip to content

Extend resolver DI to sampling and roots requests#3049

Open
maxisbey wants to merge 2 commits into
mainfrom
resolve-server-requests
Open

Extend resolver DI to sampling and roots requests#3049
maxisbey wants to merge 2 commits into
mainfrom
resolve-server-requests

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Resolvers can now return Sample(...) or ListRoots() in addition to Elicit, covering all three request kinds the multi-round-trip flow allows (SEP-2322): elicitation, sampling, and roots.

Motivation and Context

The resolver dependency-injection API (#2969, #2986) only supported asking the user via Elicit. The multi-round-trip inputRequests union is a closed set of three request kinds, and the client half already dispatches all three to the standard callbacks — this fills in the server half so a tool dependency can also sample the client's LLM or fetch its roots:

def suggest_title(genre: str) -> Sample:
    prompt = f"Suggest one {genre} book title."
    return Sample([SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], max_tokens=50)

@mcp.tool()
async def recommend_book(genre: str, suggestion: Annotated[CreateMessageResult, Resolve(suggest_title)]) -> str:
    ...

Design notes:

  • One rendering, both eras. A single _render_request produces the wire request used both as the 2026-07-28 inputRequests entry and as the pre-2026 back-channel payload, so the two transports send identical shapes by construction. The legacy legs for sampling/roots call send_request directly rather than the @deprecated session wrappers: the deprecated thing (SEP-2577) is the standalone feature, and marker-routed compatibility sends shouldn't warn — direct ctx.session.create_message() still does.
  • No decline arm. Only elicitation has an accept/decline/cancel union; a client refuses sampling/roots by erroring. Consumers annotate the result type directly (CreateMessageResult, CreateMessageResultWithTools when the request carries tools, ListRootsResult).
  • Results persist across rounds. Sampling/roots results ride requestState like elicited answers, pinned to the exact rendered request, so the client pays for an LLM call once per tool call rather than once per retry round. The state encoding is unchanged and byte-compatible with in-flight state.
  • Answers are validated against the expected model, not the response union. The InputResponses union cannot discriminate a no-tool-use answer to a tools request (a single content block parses as the plain result shape), so trusting the union member would reject spec-valid responses.
  • Per-kind capability gate. The existing elicitation-only check generalizes: before sending any of the three kinds, on either transport, the server verifies the client declared the matching capability (elicitation form, sampling — plus sampling.tools when the request carries tools/tool_choice — or roots) and refuses with -32021 MISSING_REQUIRED_CLIENT_CAPABILITY carrying the full requiredCapabilities payload. On 2026-07-28 an absent declaration is meaningful by contract (capabilities arrive per-request, and servers must not infer them from prior requests), so the gate fires; on pre-2026 sessions it applies only when the handshake's declaration is visible - on a stateless server the declaration is merely invisible, and that session has no back-channel to receive the request anyway, so the send path's no-back-channel error remains the truthful one.
  • Client gains sampling_capabilities so sampling sub-capabilities like tools support can finally be declared from the high-level client (ClientSession already accepted it).

How Has This Been Tested?

Beyond the unit/e2e suite (all three kinds batched in one round, cross-kind resolver chains over three rounds, capability refusals on both eras, state restore, no-tool-use answers to tools requests), the branch was exercised as a real application: an MCPServer process on streamable HTTP with a separate client process — 2026-07-28 auto negotiation with elicit+sample+roots fulfilled through the retry loop, 2025-11-25 legacy over the back-channel with MCPDeprecationWarning promoted to error (none fired), a stdio subprocess negotiating 2026-07-28, and live -32021 probes verifying the payloads and that the session stays usable after a refusal.

Wire compatibility: no new wire shapes — the conformance everything-server already exercises all three embedded kinds, and the suite is unaffected. One gap worth noting: the conformance suite covers -32021 mechanics elsewhere but has no dedicated scenario for the "server MUST NOT send an inputRequests entry the client has not declared support for" egress rule specifically; happy to raise that on the conformance repo.

Breaking Changes

Resolver-routed requests now enforce the capability egress rule on pre-2026 sessions too: a 2025-11-25 client that answered elicitations without declaring the elicitation capability now gets -32021 instead of being asked. Documented in docs/migration.md (declare the capability — the SDK client does this automatically when the callback is set — or drop the asking dependency). Direct ctx.elicit() / ctx.session.* calls outside resolvers are unaffected.

Types of changes

  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Out of scope, noted for follow-ups: ClientSessionGroup cannot declare sampling sub-capabilities (the same pre-existing gap Client had before this PR), and the elicitation legacy leg's validation still lives in elicit_with_validation while sampling/roots go through send_request — kept as-is to leave the shipped elicitation path untouched.

AI Disclaimer

Resolvers can now return Sample(...) or ListRoots() in addition to
Elicit: on 2026-07-28 sessions the request batches into the
multi-round-trip InputRequiredResult flow, on 2025-11-25 it goes over
the standalone back-channel request. One rendering produces the
identical wire request on both transports, and marker-routed legacy
sends bypass the deprecated session wrappers so no SEP-2577 warning
fires for the compatibility path.

Sampling and roots results are persisted in request_state like
elicited answers (the client pays for an LLM call once per tool call,
not once per round), pinned to the exact rendered request. Because the
response union cannot always discriminate the two sampling result
shapes, an answer is validated against the marker's expected model
rather than trusting the union member.

The elicitation-only capability check generalizes to a per-kind gate
applied before sending on either transport: sampling, roots, and
elicitation - including sampling.tools when the request carries tools,
reported in full in the -32021 requiredCapabilities payload. This also
gates the previously unchecked 2025 elicitation leg (documented in the
migration guide).

Client gains sampling_capabilities so sampling sub-capabilities like
tools support can be declared alongside sampling_callback.
@maxisbey maxisbey marked this pull request as ready for review July 1, 2026 22:47
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

📚 Documentation preview

Preview https://pr-3049.mcp-python-docs.pages.dev
Deployment https://56b9c2bc.mcp-python-docs.pages.dev
Commit 1a12981
Triggered by @maxisbey
Updated 2026-07-01 22:55:17 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant