Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe

MRTR Human-Approval Workflow on Stateless MCP 2026-07-28

Architect a stateless MCP server on Cloudflare Workers that survives restarts between rounds of a human approval gate using MRTR requestState, the tasks extension, and RFC 9207 OAuth verification.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 11, 2026 Published
|
Aug 11, 2026 Updated
|
17 Minutes Reading Time
Core Takeaways for Founders & Builders
  • MCP 2026-07-28 removed the handshake (SEP-2575) and Mcp-Session-Id (SEP-2567); version and capabilities now travel in _meta on every request.
  • MRTR (SEP-2322) enables human-in-the-loop approval on stateless servers via InputRequiredResult, requestState, and reissued calls with inputResponses.
  • The tasks extension (SEP-2663) offloads long-running work behind taskId polling so nothing blocks on execution.
  • A shared durable state store, not memory, is what makes round-robin and scale-to-zero deployments safe.
  • Every request needs OAuth with RFC 9207 exact iss verification and RFC 8707 resource indicators.

The Stateful Assumption Is Gone

On July 28, 2026, the MCP specification finalized a radical change: MCP became a fully stateless core. The handshake (SEP-2575) and the Mcp-Session-Id header (SEP-2567) are removed. Protocol version and client capabilities now travel inside _meta on every single request. The consequence is huge for operators: an MCP server no longer needs sticky sessions. Servers can sit behind round-robin load balancers, run on serverless platforms like Cloudflare Workers and Cloud Run, and spin down to zero when idle.

That statelessness is liberating, but it collides with a very common requirement: human-in-the-loop approval. A finance agent wants to approve a wire transfer. An infra agent wants to approve a production database migration. Both involve a human clicking yes or no, which takes seconds or minutes, in the middle of a tool call. On the old stateful model, you could hold the session open while waiting. On the stateless core, the server may be recycled between the request and the response, and any server instance must be able to pick the conversation back up.

MCP 2026-07-28 answers this with Multi Round-Trip Requests (MRTR, SEP-2322). This article designs a stateless approval-gate server for infrastructure change management using FastMCP 4.0, MRTR, the tasks extension, and OAuth 2.0 with RFC 9207 issuer verification. If you want more building blocks, browse the Daily AI World MCP Directory and the AI Workflows library.

How MRTR Works on a Stateless Core

MRTR inverts the normal request-response cycle. Instead of blocking the tool call, the server returns an InputRequiredResult that carries a requestState payload:

Client                     Server
  |  tools/call              |
  |------------------------->|  (any instance)
  | <---- InputRequiredResult|
  |      + requestState      |
  |  Human approves/rejects  |
  |  tools/call (reissue)    |
  |------------------------->|  (any instance, round-robin)
  |      + inputResponses    |
  |      + echoed requestState
  | <---- tool result        |

The client gathers the user's input and reissues the exact same call with two extra fields: inputResponses (the user's answers, keyed by prompt id) and the echoed requestState. Because there is no session, the server must resolve requestState from a shared store (KV, DynamoDB, or Redis) rather than from local memory. Any server instance can pick up the retry, which is exactly the property that makes round-robin load balancing safe.

Complementing MRTR is the tasks extension (SEP-2663): long-running tools return a taskId immediately, and the client polls tasks/get and tasks/update until the task completes. We use tasks for the background steps of an approved change so nothing blocks on a long execution.

The spec also modernized the wire protocol:

  • New headers (SEP-2243): Mcp-Method and Mcp-Name identify the JSON-RPC method and server name without parsing the body, which makes load-balancer routing and logging cheap.
  • Caching (SEP-2549): servers advertise ttlMs and cacheScope on read operations so immutable resources are served from caches for a bounded lifetime.
  • Deprecations: Roots, Sampling, Logging, Dynamic Client Registration (DCR), and the legacy HTTP+SSE transport are all deprecated. New servers should use Streamable HTTP only.

Architecture Diagram: Stateless Approval Gate

graph TD
    A[Agent client - Kiro / Copilot] -->|POST /mcp Mcp-Method: tools/call| LB[Cloudflare Load Balancer - round-robin]
    LB --> W1[Worker instance A]
    LB --> W2[Worker instance B]
    W1 -->|requestState lookup| KV[(Durable KV - state store)]
    W2 -->|requestState lookup| KV
    W1 -->|InputRequiredResult| A
    A --> H[Human approval UI]
    H -->|approve / reject| A
    A -->|reissue + inputResponses| LB
    LB --> W3[Worker instance C]
    W3 -->|validate requestState| KV
    W3 -->|tasks/create| T[Task Runner]
    T -->|tasks/update| KV
    A -->|tasks/get poll| LB
    W1 --> OAuth[RFC 9207 iss check + RFC 8707 resource]

The Approval-Gate Server

We build the server with FastMCP 4.0 in Python. Start with the environment:

# .env
MCP_SERVER_NAME=change-approval-gate
MCP_ENDPOINT=/mcp
PORT=8787

# State store (serverless-safe, no local memory)
STATE_KV_URL=kv.cloudflare.com/namespaces/change-gate
STATE_KV_TOKEN=${STATE_KV_TOKEN}

# OAuth - RFC 9207 issuer + RFC 8707 resource indicators
AUTH_ISSUER=https://auth.acme.dev
AUTH_RESOURCE_INDICATOR=https://mcp.acme.dev/change-gate
JWT_AUDIENCE=change-gate-server

# MRTR + tasks
MRTR_STATE_TTL_SECONDS=900
TASK_POLL_INTERVAL_SECONDS=2
TASK_TTL_SECONDS=3600
APPROVAL_REQUIRED=true

# Retry policy
RETRY_BASE_SECONDS=1
RETRY_MAX_SECONDS=60
RETRY_MULTIPLIER=2.0
RETRY_JITTER=true

Typed schemas keep the gate honest:

# schemas.py
from __future__ import annotations

from enum import Enum
from typing import Literal

from pydantic import BaseModel, Field


class ChangeKind(str, Enum):
    FINANCE_TRANSFER = "finance_transfer"
    DB_MIGRATION = "db_migration"
    INFRA_DEPLOY = "infra_deploy"


class RiskTier(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


class ChangeRequest(BaseModel):
    change_id: str = Field(pattern=r"^chg_[a-z0-9]{8,}$")
    kind: ChangeKind
    title: str = Field(min_length=3, max_length=120)
    description: str = Field(default="")
    risk: RiskTier = RiskTier.MEDIUM
    impact_scope: str | None = None
    requested_by: str | None = None


class ApprovalPrompt(BaseModel):
    prompt_id: str
    prompt: str
    options: list[str] = Field(default_factory=lambda: ["approve", "reject"])
    expires_after: int = 900  # seconds


class ApprovalDecision(BaseModel):
    prompt_id: str
    decision: Literal["approve", "reject"]
    reason: str = Field(default="", max_length=500)
    approver: str | None = None


class Task(BaseModel):
    task_id: str
    status: Literal["pending", "running", "succeeded", "failed", "cancelled"]
    progress: float = 0.0
    detail: str = ""

The stateless handshake replacement lives in auth.py. Every request carries protocol version and capabilities in _meta, and every request must prove the caller's identity. RFC 9207 requires the iss claim to match exactly, and RFC 8707 resource indicators let the client tell the authorization server which resource it wants a token for:

# auth.py
from __future__ import annotations

import os
from typing import Any

import httpx
import jwt

ISSUER = os.environ["AUTH_ISSUER"]
RESOURCE = os.environ["AUTH_RESOURCE_INDICATOR"]
AUDIENCE = os.environ["JWT_AUDIENCE"]
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"


def verify_access_token(token: str) -> dict[str, Any]:
    """Verify a bearer token with strict RFC 9207 iss binding and RFC 8707 scope."""
    jwks = httpx.get(JWKS_URL, timeout=10).json()
    claims = jwt.decode(token, jwks, algorithms=["RS256"], audience=AUDIENCE)
    if claims.get("iss") != ISSUER:  # RFC 9207 - exact issuer match
        raise PermissionError(f"issuer mismatch: {claims.get('iss')!r} != {ISSUER!r}")
    granted = claims.get("resource", [])
    if granted and RESOURCE not in granted:
        raise PermissionError(f"resource indicator {RESOURCE!r} not granted")  # RFC 8707
    if claims.get("aud") != AUDIENCE:
        raise PermissionError("audience mismatch")
    return claims


def extract_capabilities(meta: dict[str, Any]) -> dict[str, Any]:
    """On MCP 2026-07-28, protocol version + client capabilities arrive in _meta."""
    return {
        "protocolVersion": meta.get("protocolVersion", "2026-07-28"),
        "clientCapabilities": meta.get("clientCapabilities", {}),
    }

Now the server itself. The approval gate is one tool with three states, expressed with MRTR results:

# server.py
from __future__ import annotations

import os
import time
from typing import Any

import httpx
from fastmcp import FastMCP

from auth import verify_access_token
from schemas import ApprovalDecision, ChangeRequest, Task

mcp = FastMCP(
    os.environ["MCP_SERVER_NAME"],
    endpoint=os.environ["MCP_ENDPOINT"],
    headers={"Mcp-Name": os.environ["MCP_SERVER_NAME"]},  # SEP-2243
)

KV = os.environ["STATE_KV_URL"]
KV_TOKEN = os.environ["STATE_KV_TOKEN"]


def _kv_write(key: str, value: dict) -> None:
    httpx.put(f"{KV}/{key}", json=value,
              headers={"Authorization": f"Bearer {KV_TOKEN}"}, timeout=10)


def _kv_read(key: str) -> dict | None:
    resp = httpx.get(f"{KV}/{key}",
                     headers={"Authorization": f"Bearer {KV_TOKEN}"}, timeout=10)
    return resp.json() if resp.status_code == 200 else None


@mcp.tool(name="submit_change", cache_ttl_ms=0)
def submit_change(authorization: str, change: ChangeRequest) -> dict[str, Any]:
    """Gate a sensitive change behind human approval using MRTR."""
    verify_access_token(authorization.replace("Bearer ", ""))
    state = {"change": change.model_dump(), "created_at": time.time(), "round": 0}
    state_id = f"mrtr:{change.change_id}"
    _kv_write(state_id, state)

    return {
        "type": "InputRequiredResult",  # MRTR SEP-2322
        "requestState": {"stateId": state_id, "changeId": change.change_id},
        "inputResponses": [],
        "prompts": [
            {
                "promptId": "approval",
                "prompt": f"Approve {change.kind} {change.title!r} ({change.risk} risk)?",
                "options": ["approve", "reject"],
            }
        ],
    }


@mcp.tool(name="resolve_approval")
def resolve_approval(authorization: str, request_state: dict,
                     input_responses: list[ApprovalDecision]) -> dict[str, Any]:
    """Server re-invocation after the client reissues with MRTR requestState + responses."""
    verify_access_token(authorization.replace("Bearer ", ""))
    state = _kv_read(request_state["stateId"])
    if state is None:
        raise RuntimeError("requestState expired or missing - client must restart the change")
    decision = input_responses[0] if input_responses else None
    if decision is None or decision.decision == "reject":
        return {"status": "rejected", "reason": decision.reason if decision else "no decision"}

    # Approved - offload long-running execution to the tasks extension (SEP-2663)
    task = {"taskId": f"task_{int(time.time())}", "status": "running", "progress": 0.0}
    _kv_write(f"task:{task['taskId']}", task)
    _kv_write(request_state["stateId"], {**state, "round": state["round"] + 1})
    return {"status": "approved", "taskId": task["taskId"], "hint": "Poll tasks/get for progress"}


@mcp.tool(name="tasks_get")
def tasks_get(authorization: str, task_id: str) -> Task:
    """Poll task progress - any instance can serve it from the shared store."""
    verify_access_token(authorization.replace("Bearer ", ""))
    task = _kv_read(f"task:{task_id}")
    if task is None:
        raise RuntimeError(f"unknown task {task_id}")
    return Task.model_validate(task)

Entry point that runs on Cloudflare Workers:

# main.py
from server import mcp

app = mcp

if __name__ == "__main__":
    mcp.run(transport="streamable-http")  # HTTP+SSE is deprecated on 2026-07-28

Why the State Store Is the Secret Sauce

Every instance reads and writes requestState and task state from the same durable store. That single decision makes round-robin load balancing correct. A client that submits a change to instance A and reissues approval to instance C gets the same result, because instance C resolves requestState from KV, not from memory. The same store backs the tasks extension, so tasks/get returns progress regardless of which instance answers.

Choose the store with your scale: Cloudflare Durable Objects or KV for Workers, Cloud Run + Redis/Firestore for GCP, DynamoDB for AWS. Give every state blob a TTL (MRTR_STATE_TTL_SECONDS, TASK_TTL_SECONDS) so abandoned approvals and orphaned tasks garbage-collect themselves. A rejected or expired change must be re-submitted from scratch, which keeps the audit trail simple.

Securing the Stateless Gate

Because sessions are gone, every request is authenticated. The OAuth 2.0 bearer token is validated per call with:

  • RFC 9207: the iss claim must match the configured issuer exactly. This blocks confused-deputy and cross-tenant token injection attacks.
  • RFC 8707: clients request a token for the resource indicator https://mcp.acme.dev/change-gate, and the server checks that the resource appears in the token's resource claim. The approval-gate endpoint refuses tokens minted for other resources.
  • Audience + algorithm pinning: only RS256/ES256 from the configured JWKS endpoint, with aud pinned to the server.

For production, add mutual TLS on the Streamable HTTP endpoint and rate-limit resolve_approval per change_id so a single token cannot brute-force approvals.

Retry & Resilience Rules

Stateless + serverless means retries are expected, so the rules are explicit:

  • Backoff policy: exponential with base 1s, factor 2.0, cap 60s, full jitter; 3 attempts for transient KV failures.
  • Idempotency: submit_change is keyed by change.change_id; a retry that finds an existing mrtr:{change_id} returns the existing requestState instead of creating a second prompt.
  • requestState expiry: TTL of 900s (15 min). If the human is slower, the server returns a clear error and the client restarts the flow with fresh state.
  • No infinite MRTR loops: hard cap of 3 rounds per state; a fourth reissue is rejected as invalid.
  • Task polling: clients poll tasks/get every 2s with jitter; tasks/update writes are rejected if the task is already terminal.
  • Circuit breaker: after 5 consecutive 5xx from the state store, the server returns a 503 with a Retry-After header so load balancers can drain and scale.
  • Cold start handling: warm start interval of 60s on Workers; mcp.run is behind the endpoint so scale-to-zero never loses a round-trip.

These rules keep the approval gate correct even when every individual request lands on a different, freshly-booted instance.

FAQ

Why can't I just keep a session open on the old MCP?

The 2026-07-28 core removed the handshake (SEP-2575) and Mcp-Session-Id (SEP-2567). Clients and servers now negotiate via _meta per request, so there is no session to hold. MRTR is the sanctioned replacement for multi-step interactions.

Where does MRTR store state between round trips?

MRTR is transport-agnostic about state. The server must persist requestState in a shared durable store (KV, DynamoDB, Redis). The client simply echoes it back; it never interprets it.

What happens if the human takes longer than the state TTL?

The server returns an expired-state error on reissue, and the client restarts the change with fresh requestState. Setting the TTL to match your SLA for human response time avoids most restarts.

Conclusion

The stateless MCP 2026-07-28 core is not a downgrade, it is an architectural upgrade: serverless, round-robin, zero-downtime, and scale-to-zero. MRTR (SEP-2322) plus the tasks extension (SEP-2663) give you the multi-round and long-running primitives you need for real human-in-the-loop workflows. Pair it with RFC 9207 issuer verification, RFC 8707 resource indicators, and a durable state store, and you get an approval gate that is safe, auditable, and fully serverless. Track the fast-moving protocol work in the Daily AI World MCP Directory and latest AI news.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
The 2026-07-28 core removed the handshake and Mcp-Session-Id header, so sessions no longer exist. MRTR (SEP-2322) is the sanctioned way to do multi-step interactions on the stateless core.
The server persists requestState in a shared durable store such as Cloudflare KV, DynamoDB, or Redis. The client only echoes it back; it never interprets the payload.
When the state TTL expires, the server returns an expired-state error and the client restarts the change with fresh requestState. Set the TTL to match your human-response SLA.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Research Breakdown AI Workflows

The Step-by-Step Guide to Automating Meeting Tasks with Whisper

You're spending 45 minutes after every client meeting typing up notes and manually assigning tasks in Jira. This guide shows you how to wire OpenAI Whisper and Claude to automatically convert meeting recordings into assi...

Deepak Bagada Deepak Bagada
9m read
Research Breakdown AI Workflows

Lovable AI UI-to-Code Pipeline: 2026 Tutorial

Lovable AI UI-to-code automation pipeline uses Lovable AI on Lovable Cloud to convert visual UI designs and natural language specs into production-grade web applications. UI/UX designers and frontend developers bridging...

Deepak Bagada Deepak Bagada
8m read
Breaking AI Workflows

Claude Code's New Browser: 5 Workflows That Save Hours Daily

Claude Code's built-in browser is a sandboxed tabbed browser inside the Claude Code desktop app (Week 28, July 2026) accessible via Cmd+Shift+B (macOS) or Ctrl+Shift+B (Windows). It lets Claude open websites, read docume...

Deepak Bagada Deepak Bagada
12m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc