ADR-0021 — MRTR elicitation via replay¶
- Status: Accepted — supersedes ADR-0008
- Date: 2026-06-11
Context¶
MCP 2026-07-28 removes server-initiated requests: servers MUST NOT send
JSON-RPC requests on SSE streams. In their place, SEP-2322 defines
Multi Round-Trip Requests (MRTR). When a server needs input
mid-execution, it does not push a request to the client — it returns
an InputRequiredResult containing the inputRequests it needs
answered and an opaque requestState token. The client gathers the
answers and retries the original call, attaching inputResponses and
the unmodified requestState. The round trip repeats until the handler
can finish.
Mocapi's previous elicitation design
(ADR-0008) assumed the old
model: the handler's virtual thread blocked on a Substrate Mailbox
until the client's response arrived, possibly on another node. With no
server-initiated request channel left in the protocol, that rendezvous
has nothing to rendezvous. The question is how ctx.elicit(...) —
which from the handler author's perspective still looks like "ask a
question, get an answer" — maps onto a request/retry protocol in a
stateless server (ADR-0020).
Decision¶
Mocapi implements MRTR elicitation with the replay pattern: on each
retry the handler re-executes from the top, and ctx.elicit(...)
consults a ledger of accumulated responses to decide whether to return
or to yield.
Rules:
requestStateis a self-contained AES-256-GCM blob containing{method, originalParams, inputResponses[], issuedAt, principal}. The server stores nothing; the token is the state. Clients treat it as opaque per the spec; any tampering fails the GCM authentication tag.- When a handler calls
ctx.elicit(...)and no answer is available, the call raises an internal control signal. The dispatcher converts it into anInputRequiredResultcarrying the builtinputRequestsand a freshrequestStatethat folds in everything answered so far. - On retry, the server verifies and decrypts
requestState— rejecting a tampered/expired token, a method or target that doesn't match, or a principal that doesn't match the current caller — merges the incominginputResponsesinto the ledger, and re-dispatches the original call (method+originalParams) from the top. Eachctx.elicit(...)call site is identified by its call ordinal — the Nthelicitreached during execution. Answered ordinals return their result immediately; the first unanswered ordinal yields a newInputRequiredResult. - The flat-schema
RequestedSchemaBuilder(ADR-0015) is unchanged — the schema vocabulary handlers use to describe the input they need is the same; only the delivery mechanism moved.
Integrity, confidentiality, and replay prevention. The spec requires
only that requestState be integrity-protected ("HMAC or AEAD") and that
state failing verification be rejected; it does not require encryption.
Mocapi uses AES-256-GCM (AEAD): the auth tag is the required integrity
check, and encryption keeps the accumulated ledger opaque. Honestly,
encryption buys little wire confidentiality — the elicited questions and
answers cross the wire in cleartext as inputRequests/inputResponses
regardless — so AEAD's value over a plain HMAC is (a) defense-in-depth for
the one concentrated, client-held, potentially-persisted artifact, and
(b) making the spec's "opaque" contract impossible to violate (clients
can't decode and couple to the internal shape). A HMAC-signed,
never-decrypted token is an equally spec-valid alternative, recorded here
as considered; AEAD was kept because it costs ~nothing over HMAC and adds
those two properties. For replay prevention the token follows the spec's
SHOULDs: a short TTL (issuedAt + mocapi.mrtr.ttl), the originating
request (method + originalParams, rejecting cross-method/cross-target
reuse), and the authenticated principal — bound via the
McpPrincipalSource seam so a token minted for one caller cannot be
replayed by another. The core default is unauthenticated (null principal);
mocapi-oauth2 ships a SecurityContextMcpPrincipalSource (the Spring
Security principal / JWT subject), wired ahead of the core default, and a
user bean overrides both. It reads SecurityContextHolder, which the
transport already propagates to the dispatch virtual thread via
ContextSnapshotFactory.captureAll() (Spring Security registers a
SecurityContextHolderThreadLocalAccessor through the service loader). Single-use is not
enforced (the replay model is intentionally idempotent); a handler needing
exactly-once semantics must enforce that itself.
The honest consequence: handlers must be idempotent up to their
last elicit() call. Code before an elicit() re-runs once per round
trip. A handler that charges a credit card and then elicits a
confirmation will charge the card again on every retry. Side effects
belong after the final elicit(), or behind the application's own
idempotency keys.
Rejected alternative: park-and-relay on the Substrate Mailbox. The
incumbent design could have been adapted: park the handler's virtual
thread mid-execution, return InputRequiredResult with a requestState
that names the parked continuation, and have the retry deliver
inputResponses to the Mailbox to wake it. That preserves blocking
semantics, imposes no idempotency requirement, and is cluster-correct
via a shared store. It was rejected because it holds a parked virtual
thread plus durable state per round trip; the parked work dies on
deploys and restarts; and its requestState would not be
self-contained — it would be a pointer into server-side state,
defeating exactly the statelessness this migration adopts
(ADR-0020).
Consequences¶
What this buys us. Elicitation works with zero server-side state: any node can serve any retry, restarts lose nothing (the client holds the token), and there is no rendezvous store, no timeout-and-cancel machinery, and no parked-thread accounting. The handler-author API stays a plain blocking-looking call — no continuation-passing leaks into tool code.
Costs. The idempotency contract is real and falls on handler
authors; it is documented prominently in the guides and in the
ctx.elicit(...) javadoc. Re-execution also re-pays the cost of the
code before the last elicit() on every round trip — handlers doing
expensive pre-elicitation work should cache via their own means.
requestState grows with the number of accumulated responses and rides
the wire on every retry; the flat-schema constraint keeps answers
small, but a handler with many round trips pays linearly.
Non-goals. No durable continuation store, no "resume exactly where you left off" semantics, and no framework-level deduplication of side effects — idempotency is the handler's responsibility. Sampling does not move to MRTR; it is removed outright (deprecated by SEP-2577; see ADR-0022).
This ADR supersedes ADR-0008 (Substrate Mailbox rendezvous for elicitation/sampling).
Code anchors: mocapi-server/.../server/mrtr/MrtrElicitationEngine.java
(the replay engine), mocapi-server/.../server/mrtr/RequestStateCodec.java
(AES-256-GCM requestState encode/decode) with
RequestStatePayload.java and ResponseLedgerEntry.java,
mocapi-server/.../server/mrtr/McpPrincipalSource.java (principal-binding
seam; mocapi-oauth2/.../SecurityContextMcpPrincipalSource.java is the
Spring Security implementation), and the mocapi-server/.../server/mrtr/
exception family (InputRequiredException, InvalidRequestStateException,
ExpiredRequestStateException, ElicitationLedgerMismatchException).