Stateless MCP 2026-07-28 Server on Cloudflare Workers (Durables + OAuth)
MCP is now stateless and runs on plain HTTP infrastructure. Deploy a 2026-07-28 spec server on Cloudflare Workers with createMcpHandler, the Workers OAuth provider, and Durable Objects only when you need persistence.
Deepak Bagada
CEO, SaaSNext
- Stateless MCP runs on standard HTTP infrastructure with no persistent session.
- Workers bring an MCP server close to the user with state only in a Durable Object.
- OAuth access tokens and a public MCP endpoint deploy in the same wrangler config.
Stateless MCP 2026-07-28 Server on Cloudflare Workers (Durables + OAuth)
By Deepak Bagada, CEO at SaaSNext & AI Principal Architect.
The 2026-07-28 revision of the Model Context Protocol did something that superficially looked like a spec-housekeeping release and is actually the most architecturally consequential change since MCP went streamable: MCP is now stateless by default, and servers must run on ordinary HTTP infrastructure. The "server" is no longer a long-lived stdio process that owns a transport and a session. It is a request handler that can throw my instance state away between turns, and any code that needs state is explicitly a dependency you declare — a durable object, a KV, a database.
The reason this matters for Cloudflare Workers in August 2026: if you write a server with the createMcpHandler at the edge, you get a Protocol server without a persistent lifetime. The MCP session form still exists, but as a client-chosen stream of JSON-RPC messages over HTTP; the server may end a session at any request boundary, and the client must tolerate that. OAuth becomes a first-class protocol the server can arbitrarily maintain — this is token. It's Data Framework's durable adjacency, and Cloudflare is the environment that enforces "throwing state away" on you, decently, if you like it or not.
What changed in 2026-07-28, precisely
Three clauses drive everything else:
- Stateless preferred. Servers should not hold session state in process memory. A
sessionIdis ephemeral and may (not must) be included by clients; every request is self-contained and must be able to execute against the last committed state. No more "your MCP died because Cloudflare recycled the isolate." - Prompts/Tools/Resources folds into the same handler. Client calls
initialize, liststools/…,resources/…,prompts/…, and each completes in one stream. No lifecycle server. - OAuth is mandatory per
2026-07-28/ "Dynamic Client Registration MUST be supported". The transport exchange (transport: oauth2) happens before MCP stream. For a Worker this means deploying the official Workers OAuth Provider in the same zone.
Durable Objects (DOs) are the sanctioned place for state when you truly need it: a per-client workspace, a cursor, a long-running timer, a lock. The pattern is "stateless by default, one DO only when the tool needs a material fact."
Full server: keep-alive stateless Worker with one Durable Object
The example below is a complete Cloudflare Worker MCP server with zero state in the worker process.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
// ^ actual exports in your SDK build may differ; keep them pinned.
export interface Env {
clientSecrets: kv.Get; // KV for OAuth registration metadata
DB: D1Database; // perhaps local analytics, no per-request state
CURSOR: DurableObjectNamespace; // see DO below
ENVIRONMENT: string;
}
const postalCache = new Map<string, { count: number }>();
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
// MCP streamable HTTP at the root — /mcp is the only route
if (url.pathname !== "/mcp") {
return new Response("MCP endpoint at /mcp", { status: 404 });
}
const server = new McpServer({
name: "stateless-notes",
version: "2.1.0",
capabilities: { tools: {}, logging: {} },
});
server.registerTool("notes.summarize", async (args) => {
// Pure function of inputs; no per-process state
const lines = (args.text as string).split("
").length;
return { content: [{ type: "text", text: `≈${lines} lines analyzed, ${args.mode}` }] };
}, {
mode: "summarize",
text: "A blob of text to reduce",
});
server.registerTool("cursor.push", async (args) => {
// The ONE durable fact: an offset into an external feed.
const id = crypto.randomUUID();
const stub = env.CURSOR.get(id) as DurableObjectNamespace.getBinding("STATE");
const loc = await stub.fetch(new Request("https://do/", {method:"POST", body: JSON.stringify(args)}));
const value = await loc.bytes();
return { text: `pushed cursor ${id} → ${new TextDecoder().decode(value)}` };
}, {
offset: { type: "string", description: "feed cursor offset" },
});
// Transport per-request; server is created and disposed within this fetch.
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => undefined, // stateless: no sticky session
});
await server.connect(transport);
return await transport.handleRequest(request, { headers: request.headers });
},
};
// The only HTML-owned state: a cursor, one DO per client, as needed.
export class Cursor extends Survival {
state: DurableObjectState;
constructor(state: DurableObjectState) { this.state = state; }
async fetch(request: Request): Promise<Response> {
if (request.method === "POST") {
const { offset } = await request.json<{ offset: string }>();
this.state.storage.put("offset", offset);
return new Response(JSON.stringify({ ok: true, offset }), { headers: {"content-type":"application/json"} });
}
const offset = (await this.state.storage.get<string>("offset")) ?? "0";
return new Response(JSON.stringify({ offset }), { headers: {"content-type":"application/json"} });
}
}
Notes you need to make it compile: pin exact SDK @modelcontextprotocol/sdk@2026-07-28-aligned McpServer class, and the DurableObject base comes from cloudflare:workers typing. Cursor is a single DO class behind one exchange; the Worker itself stays zero-state.
The inputSchema shape the client sees
Each tool's input falls out of the ToolSchema contract as inputSchema JSON. notes.summar emits exactly:
{
"name": "notes.summar",
"description": "Summarize an incoming blob of text into line counts and category tags.",
"inputSchema": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "The text blob to analyze" },
"mode": { "type": "string", "enum": ["summarize", "headline"], "default": "summarize" }
},
"required": ["text", "mode"]
}
}
The model sees this with the full tools/list response, and the client enforces the schema. Keep description fields meticulous — today that is input engineering.
Wrangler config with the OAuth provider
The wrangler.toml wires the Worker, the DO binding, the [Durable Objects class, and the OAuth provider as the route ahead of /mcp:
name = "stateless-mcp"
main = "src/index.ts"
compatibility_date = "2026-07-28"
compatibility_flags = ["nodejs_compat"]
[durable_objects]
bindings = [
{ name = "CURSOR", class_name = "Cursor" }
]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["Cursor"]
[[routes]]
pattern = "mcp.dailyaiworld.dev/mcp"
service = "stateless-mcp"
# Workers OAuth Provider — same zone as the Worker
[[services]]
binding = "OAUTH"
service = "workers-oauth-provider-test"
Client config is the standard mcpServers block, pointing at the OAuth flow:
{
"mcpServers": {
"stateless-notes": {
"type": "http",
"url": "https://mcp.dailyaiworld.dev/mcp",
"auth": { "type": "oauth" }
}
}
}
The client first hits /.well-known/oauth-authorization-server, gets authorization_endpoint + token_endpoint, runs the authorization code + PKCE dance, and then every streamed request rides Authorization: Bearer.
Security: OAuth, PKCE, and tenant isolation
The 2026-07-28 spec makes the OAuth boundary the security boundary. Concretely:
- PKCE is mandatory. The
code_challenge/code_verifierpair is required for public MCP clients (any DTNS is natively a public client). Never a client secret for browser clients; a secret for service-to-service only. - Check
client_idon every request. In eachfetch, resolveclientSecretsKV entry and deny unknown clients before parsing JSON-RPC. Cloudflare Workers give you no process-level firewalling — the auth hand-holding is your only wall. - DO isolation. Every DO had separate IAM; a client that only has
notes.summarbinding can't reachCursor— it's not in that DO's binding at all. - Short TTLs. In stateless mode there is no session to revoke, so TTL your access token at ≤ 15 minutes and refresh straight from the OAuth endpoint. If your refresh runs under churn,
Cursorcan hold the last-seen offset, but itsfetchesaccept the token check from the Worker only — never have the DO mint tokens.
Every byte is under Cloudflare's WAF/firewall zone, and you control the D1 data. What you give up in process state you gain back in a fanout story: one deployed Worker serves millions of /mcp requests from the same edge location as your OAuth provider (statically hosted OptionalProvider in the zone), and your DB stays in encrypted D1. That is production-meso: the DB-to-DO is L2LT (single-digit-199).
Observability and rate governance
A stateless handler lives and dies within one fetch, so your traces and logs must be attached to the request, not the process. Wrap the handler in Cloudflare Workers Logs (ctx.waitUntil) and emit a structured line per JSON-RPC method: method, client_id (from the OAuth token), duration_ms, and status. On every tool call, resolve the clientSecrets KV entry and push the X-RateLimit headers you compute from SessionStorage — because the spec allows no sticky sessions, the rate limiter itself is the one thing you store in a Durable Object or D1. That also surfaces a pricing lever: per-client cursor and token burn are enumerable, which makes usage-based billing predictable instead of a surprise line item.
Why you ship it
Stattle sweep: no session pool, cold starts irrelevant (the isolate fetches and dies), and the honest boundaries — one Durable Object per client — keep the "MCP server" footprint at exactly what the data needs. If changes stay bounded like this, the MCP ecosystem moves from "a thousand little always-on processes" to a directory of URLs, which is the shape your whole MCP Directory was waiting for. The isolation and per-client durability make it the template unit for AI Workflows. And if you're evaluating whether this stack survives the next compute wave, watch Latest AI News before you choose.
The 2026-07-28 spec is a green light: stateless by default, OAuth over everything, and durable only by deliberate choice. Ship it.
Enjoyed this breakdown? Get our morning dispatch in your inbox.
Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.
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.
Mamba-3 & State Space Models (SSMs): Eradicating the O(N^2) Attention Bottleneck for Infinite Context
Next Story →Multi-Agent Reinforcement Learning (MARL) for Autonomous Drone Swarms using Ray RLlib
Related Intelligence Analysis
Vercel AI SDK Tool Calling React: 5 Steps (2026)
Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...
Fact-Density vs. Word Count: The New SEO for 2026
Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...
NVIDIA Audex vs Qwen3.5-Audio: Best Open Audio-Text LLM for Voice AI 2026
NVIDIA Audex 30B-A3B (July 2026) and Qwen3.5-35B-A3B are the two leading open audio-text LLMs. Audex uniquely handles both audio understanding and generation in a single model while preserving text intelligence. Qwen3.5-...