Elicitation — MRTR Replay¶
MCP 2026-07-28 removes server-initiated requests: a server can no longer
push an elicitation/create JSON-RPC request at the client and block a
thread waiting for the reply. In its place the spec defines Multi
Round-Trip Requests (MRTR): when a handler needs input mid-execution, the
server returns an InputRequiredResult naming the inputs it needs; the
client gathers answers and retries the original call; the server
re-executes the handler with the answers available. Sampling does not move
to MRTR — it is removed outright (deprecated by SEP-2577; see
ADR-0022).
This document describes the implemented mechanics. The decision record is ADR-0021, which supersedes the Mailbox rendezvous (ADR-0008).
The shape of one conversation¶
Round trip 1:
client → tools/call {name, arguments, _meta}
handler runs … ctx.elicit("Your email?") has no answer
server → result {resultType: "input_required",
inputRequests: {"elicit-1": {method: "elicitation/create",
params: {message, requestedSchema}}},
requestState: "<opaque token>"}
Round trip 2:
client → tools/call {name, arguments,
inputResponses: {"elicit-1": {action: "accept", content: {…}}},
requestState: "<same token>", _meta}
handler runs FROM THE TOP … ctx.elicit("Your email?") returns the answer
server → result {resultType: "complete", …}
The loop repeats once per unanswered elicitation: a handler with two
sequential elicit() calls completes in three round trips. Exactly three
RPC methods participate — tools/call, prompts/get, resources/read —
because those are the only methods whose params extend the spec's
InputResponseRequestParams and whose responses admit
InputRequiredResult.
The shared replay core and its two carriers¶
The call-ordinal cursor, per-slot fingerprints, and the conversion of an
unanswered elicitation into an InputRequiredResult are extracted into a
standalone class, ReplayExecutor, that MrtrElicitationEngine (below)
calls into rather than implements inline. This exists because
MCP Tasks needed the identical replay mechanics for a second
ledger carrier — a TaskStore record keyed by taskId — without the
wire-token machinery (RequestStateCodec, principal/target
verification) that only applies to the request/retry transport. Ordinals,
fingerprints, and error mapping exist exactly once; the wire engine and
the tasks engine cannot drift semantically. See
ADR-0038 for the extraction
and tasks.md for
the second carrier.
The four moving parts¶
All of this lives in mocapi-server's
com.callibrity.mocapi.server.mrtr package.
1. RequestStateCodec — the token is the state¶
The server stores nothing between round trips. Everything needed to resume
the conversation is folded into the requestState token:
{
"method": "tools/call",
"originalParams": { "name": "onboard", "arguments": { "plan": "pro" } },
"inputResponses": [
{ "key": "elicit-1", "fingerprint": "sha256:…", "response": { "action": "accept", "content": { … } } },
{ "key": "elicit-2", "fingerprint": "sha256:…" }
],
"issuedAt": 1781524800000,
"principal": "user-123"
}
(principal is the authenticated caller the token was bound to, or null
when unauthenticated; supplied by the McpPrincipalSource seam — the core
is auth-agnostic, so the default is unauthenticated and an OAuth2/app bean
provides the real principal. A retry by a different principal is rejected.)
(originalParams is the request's params minus _meta, inputResponses,
and requestState.)
That payload is JSON-serialized, encrypted with AES-256-GCM (fresh random 96-bit nonce per encode, prepended to the ciphertext), and Base64URL-encoded. GCM is an AEAD mode, so the token is simultaneously opaque (the client cannot read the ledger) and tamper-evident (any modification, or a decode under a different key, fails authentication). Clients must treat it as an opaque blob per the spec; mocapi enforces that by construction.
Configuration:
mocapi.mrtr.secret— Base64-encoded 256-bit key, shared by every instance behind a load balancer. When unset, an ephemeral key is generated at startup with a prominent WARN: in-flight elicitations then die on restart and cannot be retried against another instance.mocapi.mrtr.ttl— token time-to-live (Duration, defaultPT5M, carried over from the retiredmocapi.elicitation.timeoutdefault). A token older than the TTL is rejected with a typedExpiredRequestStateException.
2. The response ledger and call ordinals¶
Each ctx.elicit(...) call site is identified by its call ordinal:
the Nth elicit() reached during a handler execution maps to ledger
position N (key elicit-<N>, 1-based). On replay:
- An answered ordinal returns its recorded
ElicitResultimmediately — the handler never notices the round trip happened. - The first unanswered ordinal stops the execution (see the signal
below) and becomes the next
InputRequiredResult.
Every ledger slot stores a SHA-256 fingerprint of the elicitation it
was issued for (the serialized message + requested schema). On replay,
the elicit call reaching position N must produce the same fingerprint; a
handler that asks a different question at an answered position has
violated the idempotency contract, and the request is rejected with
-32602 and a diagnostic naming the contract. Without the fingerprint
check, a non-deterministic handler would silently receive answer N to a
question it never asked.
A decline or cancel ElicitResult is an answer like any other: it is
recorded in the ledger and returned to the handler, which decides what to
do (ElicitResult.isAccepted() semantics are unchanged).
3. InputRequiredException — internal unwinding¶
When ctx.elicit(...) reaches an unanswered ordinal, it cannot "block
until the client replies" — there is no reply channel. Instead it throws
InputRequiredException, an internal RuntimeException (stack-trace
suppressed; it is control flow, not an error — and it is named for the
InputRequiredResult it becomes) carrying the issued key and
the built ElicitRequestFormParams. The exception unwinds the handler stack
to the MRTR engine, which converts it into the InputRequiredResult.
It is never user-visible and never crosses the dispatch boundary; the
tool-error wrapping in McpToolsService explicitly rethrows it (along
with ElicitationLedgerMismatchException and
McpElicitationNotSupportedException) instead of converting it into an
isError tool result.
4. MrtrElicitationEngine — the wire-token carrier over ReplayExecutor¶
ReplayExecutor (see above) is the production implementation of the
internal ElicitationDispatcher seam that DefaultMcpToolContext.elicit(...)
delegates to. MrtrElicitationEngine wraps it with the wire carrier's
concerns — token decode/encode, retry validation — around handler
invocation inside the three @JsonRpcMethod service methods
(McpToolsService.callTool, McpPromptsService.getPrompt,
McpResourcesService.readResource):
execute(method, params, inputResponses, requestState, invocation)
├── ledger ← decode + validate requestState, merge inputResponses
├── run invocation with the ledger bound (ScopedValue)
│ └── ctx.elicit(...) consults the ledger by ordinal
├── InputRequiredException → InputRequiredResult + fresh requestState
└── otherwise → the handler's own result
Why that seam and not DefaultMcpServer or per-handler interceptors: the
service methods cover exactly the three MRTR-capable RPC methods (the
server entry point covers every method, including ones that may not
return input_required), they have the typed params carrying
inputResponses/requestState, and they are the last frame that can
return a value before ripcurl's catch-all exception translation would
swallow the signal. Their declared return type is Object because the
spec declares the response as a union (CallToolResult |
InputRequiredResult, etc.); ripcurl serializes the runtime type.
Retry validation — what gets rejected with -32602¶
The engine never replays against state it cannot trust. Each of these is
rejected as JSON-RPC -32602 (Invalid params), HTTP 400 on the
Streamable HTTP transport:
| Condition | Why |
|---|---|
Tampered / wrong-key / malformed requestState |
fails AES-GCM authentication |
Expired requestState (older than mocapi.mrtr.ttl) |
conversation lapsed |
| Retry method ≠ the method the token was issued for | token is not transferable across methods |
| Retry principal ≠ the principal the token was issued for | token is bound to its authenticated caller; no cross-principal replay |
Retry name/uri ≠ the original target |
token is not transferable across tools/prompts/resources |
inputResponses without requestState |
answers with no conversation |
inputResponses key the server never issued |
unknown slot |
inputResponses key already answered in a prior round trip |
replayed answer |
Non-elicitation InputResponse at an elicitation key |
mocapi only ever issues elicitation/create requests |
| Fingerprint mismatch at an answered ordinal | handler violated the idempotency contract |
A retry that simply doesn't answer the pending question is not an
error: the handler re-executes, reaches the same unanswered ordinal, and
the same InputRequiredResult (with a re-minted token) goes back out.
The user-facing surface: McpElicitor and MrtrContext¶
McpElicitor (ADR-0024) owns the elicit(...) API. Since ADR-0025 it is
one half of MrtrContext — the shared base interface
(McpElicitor + McpProgressSource) of the three MRTR-capable handler
contexts, McpToolContext, McpPromptContext, and McpResourceContext.
A handler can declare its leaf context (to elicit and report progress)
or a bare McpElicitor parameter (elicitation only); both resolve through
the structural ScopedValueResolver pattern (McpToolContextResolver,
McpPromptContextResolver, McpResourceContextResolver,
McpElicitorResolver). Each of the three services builds a context
(AbstractMrtrContext subclass) and binds it to both its leaf
CURRENT and McpElicitor.CURRENT around handler invocation, with the
capability pre-check living in AbstractMrtrContext.elicit(...).
Capability gating¶
Form-mode elicitation requires the client to declare the elicitation
capability in the per-request _meta
(io.modelcontextprotocol/clientCapabilities); a bare
"elicitation": {} counts as form-capable
(McpExchange.supportsElicitationForm()). When a handler elicits against
a client that did not declare it, AbstractMrtrContext throws
McpElicitationNotSupportedException, which
ElicitationNotSupportedExceptionTranslator maps onto the spec's
MissingRequiredClientCapabilityError: JSON-RPC code -32021 with
data.requiredCapabilities = {"elicitation": {"form": {}}} (HTTP 400).
The idempotency contract¶
Replay's honest consequence, stated once here and repeated in the
interactive tools guide: code before
your last elicit() call re-executes once per round trip — put side
effects after the final elicitation or make them idempotent. A handler
that charges a credit card and then elicits a confirmation charges the
card once per round trip. The framework deliberately provides no
deduplication; idempotency belongs to the handler (or the application's
own idempotency keys).
The flip side: zero server-side state. Any node can serve any retry, restarts lose nothing the client doesn't still hold, and there is no rendezvous store, no parked-thread accounting, and no timeout-and-cancel machinery.
Limitations¶
- Elicit from the dispatch thread only. The ledger is bound via a
ScopedValuefor the duration of the dispatch. A tool that returns aCompletionStageand callsctx.elicit(...)from a detached async thread gets anIllegalStateException— the signal could not unwind a foreign stack even if the ledger were visible there. - Token size grows linearly with the number of accumulated answers; it rides the wire on every retry. The flat-schema constraint keeps answers small, but handlers with many round trips pay linearly.
- Pre-elicitation work is re-paid every round trip. Handlers doing
expensive work before an
elicit()should cache by their own means. - Concurrent retries of the same token are safe but independent. The
engine is stateless and the ledger is immutable inside the token, so two
simultaneous retries carrying the same
requestStateeach replay correctly and return independent results. The client owns serializing round trips if it wants exactly-once handler completion. - Broad
catch (Exception)blocks inside a handler are a hazard. The pending signal unwinds the handler's stack as aRuntimeException; a handler that catches and swallows it converts a pending elicitation into whatever the catch block returns, silently ending the round trip. Catch specific exception types aroundelicit()calls, or rethrow anything you did not expect (see the interactive-tools guide).
The elicitation schema is constrained¶
Unchanged from before the migration: RequestedSchemaBuilder
(mocapi-api) is the only public way to construct a requestedSchema,
and it structurally forbids nested objects and arrays — clients render a
form, not a JSON editor. See
ADR-0015. Only
the delivery mechanism moved; the schema vocabulary did not.
Form mode only; URL mode is not implemented¶
Mocapi implements form-mode elicitation only. URL-mode
(ElicitRequestURLParams, notifications/elicitation/complete) is
declared not implemented in
ADR-0022.
The model types exist in mocapi-model for 1:1 schema fidelity
(ADR-0014) but are not
reachable through McpToolContext.
Related¶
- MCP Tasks — the second ledger carrier: the same replay
core (
ReplayExecutor), resumed viatasks/updateagainst aTaskStorerecord instead of a wire retry. - Interactive tools guide — user-facing
documentation for
ctx.elicit(...)and progress. - ADR-0021 — the replay decision, including the rejected park-and-relay alternative.
- ADR-0025 — the
MrtrContextsuper-interface and typed progress emitters. - ADR-0020 — the stateless request model MRTR depends on.
- ADR-0015 — flat-only schema builder.
- ADR-0022 — URL-mode elicitation and sampling omissions.