Skip to content

Commit d078a9d

Browse files
committed
Pin recorded resolver answers to the rendered question
Resolver state entries (now v2) record a digest of the exact rendered elicitation per outcome — accepts, declines, and cancels alike. A stored answer is honored only while the live question renders byte-identically; a redeploy that rewords a question or changes its schema re-asks instead of silently reusing a stale answer, and a recorded decline cannot suppress a reworded question. Cryptographic failure stays a wire error; a digest mismatch is a quiet re-ask, since the seal already proved the client honest and the server is what changed.
1 parent 4d200f4 commit d078a9d

2 files changed

Lines changed: 546 additions & 77 deletions

File tree

src/mcp/server/mcpserver/resolve.py

Lines changed: 47 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828

2929
from __future__ import annotations
3030

31+
import base64
32+
import hashlib
3133
import inspect
3234
import types
3335
import typing
@@ -73,7 +75,7 @@
7375
# `InputRequiredResult` rather than as a standalone server-to-client request.
7476
# Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added).
7577
_INPUT_REQUIRED_VERSION = "2026-07-28"
76-
_STATE_VERSION = 1
78+
_STATE_VERSION = 2 # v2 adds per-entry question digests
7779

7880

7981
class Resolve:
@@ -494,12 +496,19 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
494496
if not res.input_required:
495497
return await res.context.elicit(elicit.message, elicit.schema)
496498

499+
# Every recorded outcome - accept, decline, AND cancel - is pinned to the exact
500+
# question it answered: a decline of one wording must not suppress a reworded
501+
# question that reuses the same wire key after a redeploy. The digest is
502+
# computed once per question per round and shared by restore and persist.
503+
q = _question_digest(elicit)
504+
497505
# A recorded outcome from a prior round is consulted only here, after the body
498506
# decided to ask, so a `request_state` entry can never stand in for a resolver's
499-
# own computation. Re-validate it against the live `Elicit.schema`. A recorded
500-
# outcome wins over a re-sent answer; an invalid entry self-deletes and falls
501-
# through to the fresh answer (or to re-asking).
502-
outcome = _restore_outcome(res, key, elicit.schema)
507+
# own computation. It is honored only for the exact question being asked, and
508+
# accept data is re-validated against the live `Elicit.schema`. A recorded
509+
# outcome wins over a re-sent answer; a stale or invalid entry self-deletes and
510+
# falls through to the fresh answer (or to re-asking).
511+
outcome = _restore_outcome(res, key, elicit.schema, q)
503512
if outcome is not None:
504513
return outcome
505514

@@ -521,12 +530,12 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
521530
) from e
522531
# Persist the exact wire content that just passed validation - never the
523532
# model - so restoring next round revalidates the same bytes the client sent.
524-
res.persist[key] = _StateEntry(action="accept", data=answer.content)
533+
res.persist[key] = _StateEntry(action="accept", data=answer.content, q=q)
525534
return AcceptedElicitation(data=data)
526535
if answer.action == "decline":
527-
res.persist[key] = _StateEntry(action="decline")
536+
res.persist[key] = _StateEntry(action="decline", q=q)
528537
return DeclinedElicitation()
529-
res.persist[key] = _StateEntry(action="cancel")
538+
res.persist[key] = _StateEntry(action="cancel", q=q)
530539
return CancelledElicitation()
531540

532541

@@ -595,6 +604,21 @@ class _StateEntry(BaseModel):
595604

596605
action: Literal["accept", "decline", "cancel"]
597606
data: Any = None
607+
q: str | None = None
608+
"""Digest of the exact rendered question this outcome answered."""
609+
610+
611+
def _question_digest(elicit: Elicit[Any]) -> str:
612+
"""Pin an outcome to the exact rendered question the client was shown.
613+
614+
Computed over the rendered ElicitRequest params bytes - the same bytes the
615+
client displayed - so a recorded outcome survives only as long as the
616+
question is byte-identical. A redeploy that rewords the message or changes
617+
the schema re-asks instead of silently reusing a stale answer.
618+
"""
619+
rendered = _elicit_request(elicit).params.model_dump_json(by_alias=True, exclude_none=True)
620+
digest = hashlib.sha256(rendered.encode()).digest()[:16]
621+
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
598622

599623

600624
class _State(BaseModel):
@@ -607,8 +631,11 @@ class _State(BaseModel):
607631
def _decode_state(request_state: str | None) -> dict[str, _StateEntry]:
608632
"""Decode the per-call resolution progress from `request_state`.
609633
610-
`request_state` is client-trusted (integrity sealing is a follow-up); validate
611-
it through `_State` and treat anything malformed as "no progress yet".
634+
The string arrives boundary-authenticated (the middleware only forwards
635+
plaintext this server minted), so anything malformed or version-mismatched
636+
here is inner-format drift within the operator's own fleet - e.g. a rolling
637+
upgrade - where treating it as "no progress yet" and re-asking is exactly
638+
right.
612639
"""
613640
if not request_state:
614641
return {}
@@ -642,12 +669,15 @@ def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> Elicitat
642669
return _accepted(schema.model_validate(entry.data))
643670

644671

645-
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> ElicitationResult[Any] | None:
672+
def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None:
646673
"""Restore `key`'s recorded outcome from a prior round, or `None` when absent.
647674
648-
`request_state` is client-trusted, so an entry whose data fails validation gets
649-
the `_decode_state` treatment - dropped as if no progress was recorded, so the
650-
question is asked again - rather than surfacing a validation error.
675+
An entry is honored only for the exact question being asked - `q` is the
676+
live question's digest, precomputed by the caller: one pinned to a different
677+
rendered question (the server reworded or reshaped it since the outcome was
678+
recorded), or whose accepted data fails validation against the live
679+
`schema`, is dropped as if no progress was recorded - so the question is
680+
asked again - rather than surfacing an error.
651681
652682
Carries the original decoded entry forward unchanged in `res.persist`: if a
653683
later resolver is still pending, the next round's `request_state` is built from
@@ -657,6 +687,9 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> Eli
657687
entry = res.state.get(key)
658688
if entry is None:
659689
return None
690+
if entry.q != q:
691+
del res.state[key]
692+
return None
660693
try:
661694
outcome = _outcome_from_state(entry, schema)
662695
except ValidationError:

0 commit comments

Comments
 (0)