cd /news/ai-agents/requeststate-sealing-the-state-your-… · home › topics › ai-agents › article
[ARTICLE · art-154116] src=pournasserian.com ↗ pub= topic=ai-agents verified=true sentiment=· neutral

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

Under the Model Context Protocol's 2026-07-28 specification, servers must treat the `requestState` string returned by clients as attacker-controlled input and protect its integrity with HMAC or AEAD when it influences authorization, resource access or business logic, according to a guide published in October 2026. The official Python SDK, `mcp` 2.3.0, seals and verifies `requestState` by default for servers built on `MCPServer`, using AES-256-GCM, binding the client ID, issuer and subject of the access token, a 600-second expiry that restarts each round, and a digest of the method and arguments. The guide recommends binding the authenticated principal, a short expiry and an identifier of the original request to bound replay, enforcing single-use values server-side, and avoiding side effects before the final input point.

by read8 min views1 publishedOct 20, 2026
requestState: sealing the state your MCP server hands to the client
Image: Pournasserian (auto-discovered)

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. 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 checksrequestState 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 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 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), 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:

import os

from mcp.server.mcpserver import MCPServer, RequestStateSecurity

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:

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."""
    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
    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 declareelicitation .

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.

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.
── more in #ai-agents 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/requeststate-sealing…] indexed:0 read:8min 2026-10-20 · —