# requestState: sealing the state your MCP server hands to the client

> Source: <https://pournasserian.com/writing/mcp-2026-request-state>
> Published: 2026-10-20 00:00:00+00:00

# requestState: sealing the state your MCP server hands to the client

If your server has to ask for more mid-call, it has to remember where it was. It hands that memory to the client: a string, `requestState`, that comes back with the retry and can hold the step the request has reached and the answers so far. What I’d want you to remember is where it waits between rounds: in the client’s hands. That’s why the specification treats it as attacker-controlled input.

This is part 14 of my series on what a Model Context Protocol (MCP) server does under the 2026-07-28 specification, after [multi round-trip requests in part 13](https://pournasserian.com/writing/mcp-2026-multi-round-trip-requests). The facts are as I read them in October 2026.

## In brief

1. **`requestState` comes back from the client, so treat it as hostile.** If it influences authorization, resource access or business logic, the server MUST protect its integrity.
2. **Bind three things inside the seal:** the authenticated principal, a short expiry and the original request. That bounds replay but doesn’t make the state single-use.
3. **In Python you don’t write the seal.** In the official Python software development kit (SDK),`MCPServer` seals and checks`requestState` by default; give every instance the same key.
4. **A multi-step wizard is a state machine the client carries for you,** with new state for each round.
5. **My advice: no side effects before the last input point.** The client may never come back, so write, send or charge only on the final round.

## `requestState` is attacker-controlled input

As part 13 showed, a client MUST echo `requestState` exactly and MUST NOT inspect or modify it. A hostile client can still change it, replay it or hand it to someone else. So the specification’s [Multi Round-Trip Requests page](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr) says that if the state influences authorization, resource access or business logic, the server MUST protect its integrity, for example with a Hash-based Message Authentication Code (HMAC) or Authenticated Encryption with Associated Data (AEAD), and MUST reject state that fails verification.

To prevent replay, put these inside the protected payload and check them on the way back:

- the **authenticated principal** , rejecting state presented by someone else;
- a **short expiry** ;
- an **identifier of the original request** : the method name plus a digest of its key parameters.

These bound replay but don’t make the state single-use. If a value may be consumed at most once, such as a one-time redemption, enforce that on the server.

Sealing on one side of the round trip, opening on the other:

## What the Python SDK seals for you

My guide sketches this in C# with a hand-written codec that seals on the way out and opens on the way back. In the official Python SDK, `mcp` 2.3.0, a server built on `MCPServer` needs no codec: `MCPServer` installs a [middleware](https://py.sdk.modelcontextprotocol.io/handlers/multi-round-trip/) that does both jobs. It opens and checks the state before your handler runs, so `ctx.request_state` is the plaintext you wrote, and it seals the state your handler returns. Here is how that maps onto the diagram:

| The diagram’s step | What `MCPServer` does | 
|---|---|
| Encrypt and authenticate | AES-256-GCM (the Advanced Encryption Standard in Galois/Counter Mode), an AEAD cipher, so the client can neither read the state nor change it unnoticed | 
| Principal | The client ID, issuer and subject of the access token, when the server authenticates requests; an unauthenticated server has no principal to bind | 
| Expiry | 600 seconds by default, starting again each time a round is sealed | 
| Method and arguments | The method, the tool or prompt name (or the resource’s Uniform Resource Identifier, URI), and a digest of the arguments | 
| Beyond the diagram | The server’s name, as the state’s audience; and with the SDK’s `Resolve` form, a digest of the exact question asked | 

Every failure gets one answer, a JSON-RPC 2.0 error with code `-32602` and the message `Invalid or expired requestState`; the reason goes only to the server log.

### More than one instance

By default each `MCPServer` generates its own key when you create it and keeps it in memory, which suits stdio or a single worker. On Streamable HTTP, every request is its own POST ([part 7](https://pournasserian.com/writing/mcp-2026-transports)), so the retry may reach another instance, or a restarted one, and either rejects the state. My notes say any instance can serve the retry; in Python that holds once they share a key:

``` python
import os

from mcp.server.mcpserver import MCPServer, RequestStateSecurity

# Every instance reads the same secret, at least 32 bytes, from its environment.
shared = RequestStateSecurity(keys=[os.environ["REQUEST_STATE_KEY"]])
mcp = MCPServer("reports", request_state_security=shared)
```

A key shorter than 32 bytes is refused. The first key seals and every key opens, so you can rotate: deploy `[old, new]`, then `[new, old]`, then `[new]` after one expiry period, each phase fully rolled out first. Give every instance the same server name and every other service a different one: the name is the state’s audience, so another service sharing the key can’t use your state. `MCPServer` refuses shared keys on a server with no name, unless you set an `audience=` instead.

## Multi-step wizards

Each round can return a new `requestState` holding the next step, so a multi-step interaction becomes a server-sealed state machine that the client carries for you. A three-round wizard that creates a report:

In the SDK you can write this by hand: return an `InputRequiredResult` with your question and your state, then, on the retry, read the answers from `ctx.input_responses` and the state from `ctx.request_state`. This tool goes on the server above:

``` python
import json

from mcp import MCPError
from mcp.server.mcpserver import Context
from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult
from mcp.types import MISSING_REQUIRED_CLIENT_CAPABILITY, InputRequiredResult

QUESTIONS = {"dataset": "Which dataset?", "range": "Which range?"}  # asked in order

def ask(ctx: Context, field: str, state: dict) -> InputRequiredResult:
    caps = ctx.client_capabilities
    if caps is None or caps.elicitation is None:  # never ask what it can't answer
        need = {"requiredCapabilities": {"elicitation": {}}}
        raise MCPError(MISSING_REQUIRED_CLIENT_CAPABILITY, "elicitation required", need)
    prop = {field: {"type": "string"}}
    schema = {"type": "object", "properties": prop, "required": [field]}
    form = ElicitRequestFormParams(message=QUESTIONS[field], requested_schema=schema)
    return InputRequiredResult(
        input_requests={f"pick_{field}": ElicitRequest(params=form)},
        request_state=json.dumps({**state, "asked": field}),  # sealed by the SDK
    )

@mcp.tool()
async def create_report(ctx: Context) -> str | InputRequiredResult:
    """Create a report. Asks which dataset, then which range."""
    # The SDK has already verified and opened the state: the plaintext we wrote.
    state = json.loads(ctx.request_state) if ctx.request_state else {}
    asked = state.pop("asked", None)
    answer = (ctx.input_responses or {}).get(f"pick_{asked}")
    if asked and isinstance(answer, ElicitResult):  # only the answer we asked for
        if answer.action != "accept":
            return "Report cancelled."
        state[asked] = (answer.content or {}).get(asked)
    for field in QUESTIONS:
        if not state.get(field):
            return ask(ctx, field, state)  # a missing answer is simply asked again
    # Every input is in: only now does the tool create anything.
    return f"Report on {state['dataset']} for {state['range']} created."
```

Writing it by hand leaves you three jobs that part 13’s `Resolve` form does for you:

- **Matching answers to questions.** The state records what was asked, and the tool reads only that answer.
- **Asking again.** A retry without the answer gets the question again, not an error.
- **Checking the capability.**`ask` raises error`-32021` , a missing required client capability, when the request didn’t declare`elicitation` .

### Running it, and tampering with it

In the same file, with `REQUEST_STATE_KEY` set (I used `secrets.token_hex(32)`), the in-memory client takes the state of round one and sends the first retry three ways: with one character of the state changed, to an instance with its own key, and to one with the shared key.

``` php
import anyio
from mcp import Client

async def fill_in(context, params) -> ElicitResult:  # stands in for the user
    field = next(iter(params.requested_schema["properties"]))
    picks = {"dataset": "sales", "range": "Q3"}
    return ElicitResult(action="accept", content={field: picks[field]})

async def main() -> None:
    async with Client(mcp, elicitation_callback=fill_in) as client:
        first = await client.session.call_tool(
            "create_report", {}, allow_input_required=True
        )
    state, i = first.request_state, len(first.request_state) // 2
    tampered = state[:i] + ("A" if state[i] != "A" else "B") + state[i + 1:]
    stranger = MCPServer("reports")  # another instance, with a key of its own
    twin = MCPServer("reports", request_state_security=shared)  # same name, same key
    for server in (stranger, twin):
        server.add_tool(create_report)
    answer = ElicitResult(action="accept", content={"dataset": "sales"})
    for label, server, sent in (
        ("tampered", mcp, tampered),
        ("own key", stranger, state),
        ("same key", twin, state),
    ):
        async with Client(server, elicitation_callback=fill_in) as client:
            try:  # the Client answers any further question and retries for you
                result = await client.call_tool(
                    "create_report", {},
                    input_responses={"pick_dataset": answer}, request_state=sent,
                )
                print(label, result.content[0].text)
            except MCPError as exc:
                print(label, exc.error.code, exc.error.message)

anyio.run(main)
tampered -32602 Invalid or expired requestState
own key -32602 Invalid or expired requestState
same key Report on sales for Q3 created.
```

The first two get the same error, and the server log says why: `seal`, then `unknown key`. The third finishes the report on an instance that never saw round one.

## My rule: no side effects before the last input point

The specification stops at sealing; my advice goes one step further. The client may never come back, so everything that runs before you return `input_required` must be safe to repeat, or safe never to finish. Validate, look things up and plan freely; write, send, deploy and charge only on the final round. That’s why the wizard creates nothing until both answers are in.

In my run, sending the last retry again created the report again, so if the last step must happen at most once, record it on the server.

## Method and caveats

- Built from chapter 13 of my guide to MCP servers, written against the 2026-07-28 specification.
- My guide’s C# codec becomes the Python SDK’s built-in sealing in `mcp` 2.3.0, as the package and its documentation show it. Where my notes say any instance can serve the retry, this article follows the SDK, which needs a shared key.
- The Python sample ran against `mcp` 2.3.0 on 9 October 2026 and printed the output shown; the log reasons and the repeated last retry come from the same run.
