Skip to content
2 changes: 2 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1528,6 +1528,8 @@ async def delete_folder(

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.

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. Elicited answers are carried in `request_state` across rounds, so each question is asked once; a resolver that resolves without eliciting is pure and may re-run each round.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 docs/migration.md line 1478 still says "Each resolver runs at most once per tools/call", but this PR changes that contract — the new paragraph at line 1531 (and the rewritten resolve.py docstring) say only elicited answers are carried across rounds while a non-eliciting resolver is pure and may re-run each round. Reword or remove the surviving sentence (e.g. "each question is asked once; non-eliciting resolvers may re-run each round") so the section is internally consistent.

Extended reasoning...

What the issue is. The "Resolver dependency injection" feature section in docs/migration.md still opens with the v1-era guarantee at line 1478: "Each resolver runs at most once per tools/call." A few paragraphs later, the paragraph this PR adds at line 1531 states the new, narrower contract: "Elicited answers are carried in request_state across rounds, so each question is asked once; a resolver that resolves without eliciting is pure and may re-run each round." Within one section the doc now asserts both "runs at most once" and "may re-run each round" about the same resolvers.

Why this is a contract change introduced by this PR, not just stale wording. The PR itself removed the equivalent line — "Each resolver runs at most once per tools/call (memoized by function identity)" — from the module docstring of src/mcp/server/mcpserver/resolve.py and replaced it with the multi-round wording ("Only elicited outcomes are carried in request_state across rounds … a resolver that returns a value without eliciting is pure and may re-run each round"). The reviewer's guide comment on this PR makes the same point explicitly: only elicited outcomes persist, so non-eliciting resolvers "must be idempotent". The author already considered the old phrasing inaccurate enough to delete it from the code docstring; the copy in migration.md was simply missed.

Code path / proof. Take the PR's own test test_non_serializable_sibling_resolver_does_not_break_rounds: a tool has a pure clock resolver (returns a datetime) and an eliciting ask resolver. Round 1 (protocol 2026-07-28): resolve_arguments runs clock, then ask returns Elicit(...) with no answer yet, so an InputRequiredResult is returned and only the elicited outcome map (empty for clock, since _encode_state(res.elicited) persists only elicited outcomes) goes into request_state. Round 2: the client retries; clock is run again (its outcome was never persisted), then ask consumes its answer and the tool body runs. Within one logical tools/call the pure resolver executed twice — directly contradicting line 1478. Similarly, test_eliciting_resolver_without_elicit_arm_restores_a_typed_model relies on an eliciting resolver re-running across rounds to recover its live schema.

Why nothing else prevents the confusion. One could argue the sentence is literally true per wire request (each round dedups by resolver identity via res.cache), but that is not how a tool author reads a feature overview: it implies resolvers never re-run within one logical call and therefore need not be idempotent — exactly the opposite of what the new flow requires and what the paragraph three paragraphs later says.

Impact. Documentation-only; no runtime effect. But the section the PR edits is the author-facing description of the feature's execution guarantees, and an author relying on "runs at most once" could write a non-idempotent resolver (e.g. one with side effects) that misbehaves under the >= 2026-07-28 multi-round flow.

How to fix. Reword line 1478 to match the new semantics, e.g. "Each elicitation question is asked at most once per logical tools/call; elicited answers are carried across rounds, while a resolver that resolves without eliciting may re-run each round and should be idempotent" — or simply delete the sentence, since the paragraph added at line 1531 already covers the contract.


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.

`ElicitationResult` is now a subscriptable generic alias (so `ElicitationResult[T]` works in annotations) instead of a plain union. A runtime `isinstance(result, ElicitationResult)` therefore raises `TypeError`; check against the member classes directly - `isinstance(result, AcceptedElicitation)` (or `DeclinedElicitation` / `CancelledElicitation`).
Expand Down
15 changes: 13 additions & 2 deletions src/mcp/server/elicitation.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None:
) from None


def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]:
"""Render a model as the spec-valid `requested_schema` for an elicitation.
Raises:
TypeError: If a field renders as something the spec's
`PrimitiveSchemaDefinition` does not accept.
"""
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
_validate_rendered_properties(json_schema)
return json_schema


async def elicit_with_validation(
session: ServerSession,
message: str,
Expand All @@ -103,8 +115,7 @@ async def elicit_with_validation(
For sensitive data like credentials or OAuth flows, use elicit_url() instead.
"""
json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema)
_validate_rendered_properties(json_schema)
json_schema = render_elicitation_schema(schema)

result = await session.elicit_form(
message=message,
Expand Down
5 changes: 5 additions & 0 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,11 @@ def request_id(self) -> str:
"""Get the unique ID for this request."""
return str(self.request_context.request_id)

@property
def protocol_version(self) -> str | None:
"""The negotiated protocol version, or `None` outside of an active request."""
return self._request_context.protocol_version if self._request_context is not None else None

@property
def input_responses(self) -> InputResponses | None:
"""Client responses to a prior `InputRequiredResult.input_requests`.
Expand Down
Loading
Loading