Skip to content

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:

  1. requestState is 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.
  2. When a handler calls ctx.elicit(...) and no answer is available, the call raises an internal control signal. The dispatcher converts it into an InputRequiredResult carrying the built inputRequests and a fresh requestState that folds in everything answered so far.
  3. 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 incoming inputResponses into the ledger, and re-dispatches the original call (method + originalParams) from the top. Each ctx.elicit(...) call site is identified by its call ordinal — the Nth elicit reached during execution. Answered ordinals return their result immediately; the first unanswered ordinal yields a new InputRequiredResult.
  4. 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).