Context

The upcoming MCP 2026-07-28 specification introduces Multi Round-Trip Requests (MRTR), a new model for interactive workflows. At first glance, the changes look mostly infrastructural:

  • Mcp-Session-Id disappears.
  • Stateful HTTP sessions disappear.
  • Server-Sent Events disappear.
  • initialize becomes server/discover.
  • Any request can land on any server instance.

That sounds like a transport problem. While adapting Neva to the new protocol, I discovered it was something else entirely.

The difficult part wasn’t removing sessions. The difficult part was losing continuations.

The Original API

Neva already had support for MCP elicitation. A tool could request additional input from the client:

let answer = ctx.elicit(ElicitRequestParams::form(form)).await?;

From the tool author’s perspective, this felt like ordinary async Rust. Execution paused, the client responded, execution resumed. The continuation was hidden somewhere inside the framework, and most users never had to think about it.

Then MRTR Happened

Under MRTR, the server can no longer suspend execution and wait. Instead, it must terminate the request and return:

{
  "resultType": "input_required",
  ...
}

The client gathers the requested input, then sends a completely new request — possibly to a completely different server instance. There is no suspended future, no pending task, no continuation. The handler starts from the beginning. Again.

Replay Is the Obvious Solution

The simplest implementation is replay. Suppose we extend elicitation slightly:

let approval = ctx.elicit("approval", params).await?;

On the first run, there is no cached answer, the framework returns input_required, and execution stops. When the client retries, the answer is loaded from requestState, ctx.elicit("approval", ...) returns immediately, and execution continues.

Simple. Elegant. Stateless.

And completely broken.

The Side Effect Problem

Consider this handler:

create_ticket();

let approval = ctx.elicit("approval", params).await?;

Replay means the handler runs again from the top. So the ticket is created again. And again. And again.

The continuation problem is solved. The side effect problem remains.

The obvious answer is:

Don’t perform side effects before an elicitation point.

Technically correct, practically painful — because it introduces a new rule: every operation before an elicitation point must be replay-safe. Not merely idempotent. Replay-safe.

The Temptation of Hidden Magic

At this point there is an obvious temptation: hide the complexity. Maybe the framework can generate checkpoint identifiers automatically. Maybe source locations are enough. Maybe call order is enough. After all, React manages to identify hooks somehow.

The problem is that replay state must survive refactoring. What happens when someone inserts a new call, adds a conditional, introduces a loop, extracts code into a helper function, or reorders operations? A hidden checkpoint identifier that changes during refactoring is worse than no checkpoint identifier at all.

Eventually I arrived at a simple conclusion:

Once continuations become data, their names become part of the API.

The framework cannot reliably invent those names. The application must own them.

The Alternative We Rejected

There was another possibility: push all of this responsibility onto application code. Something like:

enum State {
    Start,
    NeedApproval,
    Complete,
}

And require every tool author to manually track workflow state, serialize it into requestState, restore it on retries, handle replay, and manage side effects. This would be extremely flexible — and it would also turn every tool into a miniature workflow engine. The original handler:

async fn create_order(...)

would stop being a tool handler and become a state machine interpreter. Technically powerful. Terrible ergonomics.

The Middle Ground

Neva eventually ended up with a different model. Instead of exposing workflow state directly, it exposes replay-aware primitives:

let quote = ctx.memo("quote", async {
    pricing.fetch(&order).await
}).await?;

ctx.once("charge_card", async {
    billing.charge(&order).await
}).await?;

let approval = ctx.elicit("approval", params).await?;

ctx.on_commit(async {
    mailer.send_receipt(&order).await
});

Each primitive exists because replay exposed a hidden assumption:

  • elicit — a replayable input checkpoint.
  • memo — a deterministic computation whose value should survive future rounds.
  • once — a side effect that should execute at most once across replay rounds.
  • on_commit — work that should only run when the workflow successfully reaches its final result.

The interesting part is not the API itself. The interesting part is how it emerged: every primitive appeared because replay broke something.

A Familiar Pattern

At some point this started to feel strangely familiar. React function components have a similar problem — the render function can execute repeatedly. As a result, React separates rendering, memoized computation, and side effects.

MRTR introduces a similar separation, not because of UI rendering, but because of resumable execution.

There is one important difference. React identifies state by call order, which is why React has the rules of hooks: no conditionals, no early returns around a hook. MRTR could not afford that constraint — replay must survive refactoring — so checkpoint identities are explicit strings:

ctx.memo("quote", ...)
ctx.once("charge_card", ...)
ctx.elicit("approval", ...)

The cost is a little extra typing. The benefit is that once inside a conditional is fine, extracting code into a helper is fine, and a primitive that only appears on round two is simply computed fresh and stored then. The keys are not boilerplate. The keys are the addresses of resumable computations — and choosing them deliberately is what buys back the freedom React had to give up.

Effect Happened, Checkpoint Did Not

There is one caveat worth naming honestly.

Suppose the handler successfully runs ctx.once("charge_card", ...). The card is charged. Then, before the framework can persist the updated requestState back to the client, something fails: serialization errors out, the response is lost on the wire, the process crashes. The next round will have no record of the effect ever running, and will charge the card again.

The guarantee is at-most-once-per-successful-checkpoint, not durable once. The effect ran. The checkpoint did not.

This is the same tension any system has when an external side effect happens before its commit log is durable — it is why outbox patterns exist. MRTR primitives narrow the window dramatically compared to naive replay, but they do not close it.

The honest framing is that once is safe for effects that are already idempotent at the destination: writes keyed by an external request id, upserts, metric increments where double-counting is tolerable. For effects that are not naturally idempotent — a card charge being the canonical example — the right model is still to give the external system an idempotency key it honours, and let once guard only the local “did we already try” bookkeeping.

This is not a flaw of MRTR specifically. It is the durability boundary of any in-process replay model.

What Stateless MCP Actually Changed

Initially I thought stateless MCP was primarily an infrastructure change. Now I think it is primarily a programming model change. Removing sessions is easy; removing continuations is hard.

The old MCP model hid continuations inside the transport. MRTR removes that illusion. The continuation does not disappear — it simply becomes data. And once it becomes data, it must be named, serialized, validated, replayed, and committed.

Final Thoughts

The most interesting part of implementing MRTR was not adapting HTTP. It was discovering how much state had been hidden inside a seemingly simple API:

let answer = ctx.elicit(...).await?;

The session disappeared.

The state did not.