Sign MCP requestState for paid render approvals: user, TTL, digest

MCP says requestState is attacker-controlled. For a paid render approval, bind it to the user, an expiry and an argument digest with an HMAC. Python included.

5 min readSume
All posts

Sign the state. The MCP 2026-07-28 spec says servers MUST treat requestState as attacker-controlled and protect its integrity with an HMAC or AEAD, and it recommends putting the principal, a TTL and a digest of the request inside the payload. For a paid render approval that means a user cannot replay an old approval, move it to another account, or change the prompt after agreeing to a cost. Source: the MRTR pattern page, read 2026-10-03.

What each field prevents

Each recommended field closes one specific replay. Leave one out and the matching attack works.

requestState contents and what they stop (spec read 2026-10-03)
FieldAttack it stops
PrincipalUsing one user's approval on another account
TTLReplaying an approval days later at a changed price
Request digestEditing the prompt or duration after the user agreed to the cost
HMAC or AEAD over all of itForging or editing any field

Why idempotency keys are not approval

Sume's hosted MCP requires an idempotency_key on write and paid tools, and its docs describe it as a stable key for transport and dedup, not human approval. A repeated key protects against double submission. It does not prove a person agreed to a cost. If you want proof, you need a signed approval of your own, and requestState is a natural carrier.

The same docs offer dry_run for a cost preview and an optional max_spend_usd cap. Put the previewed estimate inside the signed payload so the approved number is the one you enforce.

A signing helper

The Python below builds and checks a state token. It refuses an empty secret, compares in constant time and rejects a mismatched principal, an expired token or changed arguments. Store the secret outside the code, and rotate it on a schedule.

import base64, hashlib, hmac, json, time


def _digest(args: dict) -> str:
    return hashlib.sha256(json.dumps(args, sort_keys=True).encode()).hexdigest()


def make_state(secret: str, principal: str, args: dict, ttl: int = 600) -> str:
    if not secret:
        raise ValueError("empty secret")
    body = json.dumps({"p": principal, "exp": int(time.time()) + ttl, "d": _digest(args)})
    raw = base64.urlsafe_b64encode(body.encode()).decode()
    mac = hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
    return f"{raw}.{mac}"


def check_state(secret: str, state: str, principal: str, args: dict) -> bool:
    if not secret or "." not in state:
        return False
    raw, mac = state.rsplit(".", 1)
    good = hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(mac, good):
        return False
    body = json.loads(base64.urlsafe_b64decode(raw.encode()))
    return body["p"] == principal and body["exp"] >= time.time() and body["d"] == _digest(args)

Operational notes

Keep the TTL short; minutes, not hours. Derive the principal from the authenticated session, never from a field the client sends. Hash the arguments after you normalize them, so key order does not change the digest.

Log a failed check without logging the state itself. If you accept a state once, store its id to refuse a second use inside the TTL. And keep the approval separate from the credential: a Sume OAuth token is not an API key, and the docs warn against pasting either into prompts.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume