Stateless MCP: The Boring Update That Makes Agent Tools Deployable by One Person
The Model Context Protocol just shipped a spec revision and a stateless transport pattern that turns agent-callable tools into plain HTTP functions. Here's a real, small-business build-along — an invoice lookup MCP on Cloudflare Workers — plus the security corner nobody writes down.
This, not that: a mac-mini-on-a-desk as the deployment target, not a server rack in a cold room. Stateless MCP collapses the hosting story down to whatever runs a plain HTTP function. Illustration: ctrlaltorion.
The Model Context Protocol has quietly become the USB port of the agent world. Claude speaks it. ChatGPT speaks it. Cursor, VS Code, and every serious coding-agent runtime speaks it. When a customer says “I want the assistant to look up an order in our system,” MCP is now the boring, obvious answer for how the assistant reaches that system.
The problem was never the idea. It was the hosting.
Classic MCP servers held session state — a live connection, subscriptions, per-client context — which meant real deployment pain: sticky sessions, long-lived processes, websockets, retry logic, the kind of infrastructure a solo builder does not want to babysit at 2 a.m. The path of least resistance became “run it locally on the operator’s laptop,” which is fine for demos and useless for a shop with three employees and one iPad at the register.
That excuse just expired. Two things landed in the last week of July 2026:
- The MCP spec’s 2026-07-28 revision (blog.modelcontextprotocol.io, ~1 week old as of this writing) — a substantive update that, among other things, cleans up transport and auth in ways that make remote hosting realistic for teams that aren’t Fortune 500 platform engineers.
- Simon Willison publicly re-falling in love with stateless MCP (simonwillison.net, Jul 31, 2026 — 3 days old) and shipping two projects,
mcp-exploreranddatasette-mcp, as evidence that the pattern works and is genuinely simple.
Between those two beats, “deploy a tool your AI assistant can call” quietly moved from “weekend project with infra homework” to “afternoon project with a wrangler deploy.” For a small business, that’s not a protocol-nerd story. It means the gap between “I wish an assistant could check our inventory” and “it can” is now measured in hours, not sprints.
This piece is the build-along I wish had existed when I first tried to expose a tool to Claude for a client. We’ll walk through what stateless MCP actually is, what the spec update fixed, and then ship a real, working invoice-lookup MCP server on Cloudflare Workers — the kind of thing you could hand to a bookkeeper’s AI assistant tomorrow. Then we’ll pin down the security corner that nobody wants to write about, and the honest cases where you still want a stateful server.
The 60-second MCP recap (for operators, not spec readers)
MCP is a small, JSON-RPC-shaped protocol that standardizes how an AI client (Claude, ChatGPT, Cursor, your favorite coding agent) discovers and calls tools, reads resources, and applies prompts exposed by a server. You write the server. Every compliant client can use it. That’s the whole trick.
Before MCP, every vendor rolled their own “function calling” or “actions” or “plugins” schema, and if you wanted your invoice lookup to work in both Claude and ChatGPT and Cursor, you wrote it three times. MCP means you write it once, and any client that speaks the protocol can talk to it. It’s the reason people call it the USB port of the agent world — not because the analogy is elegant, but because the unification is finally real.
The 2026-07-28 spec revision (official post, release-candidate notes here) is the second full revision of the year and continues the trajectory of making MCP actually deployable across a network — as opposed to something you run locally and pretend that’s fine. Read the post for the full changelist; for our purposes, the operational takeaways are:
- Streamable HTTP is the transport that matters — a single HTTP endpoint that can serve request/response in one shot and optionally stream. No websockets required. No sticky sessions required if your server is stateless.
- Auth is a first-class concern, not a bolted-on afterthought. The spec increasingly reflects that “expose this to the whole internet with an OAuth token or bearer” is a real deployment, not an edge case.
- Tool definitions are self-describing in a way clients can render sanely — which is why exposing a well-named tool to a good client feels like magic and exposing a garbage tool feels like the model hallucinating.
None of that requires you to read the spec cover-to-cover. It requires you to write a tiny HTTP handler that answers a handful of JSON-RPC methods. Let’s do that.
Why hosting classic MCP used to suck
If you’ve only used MCP through client-side stdio (the “run this binary locally, we’ll pipe JSON-RPC over stdin/stdout” pattern), you’ve been shielded from the pain that made remote MCP hard. The pain shape:
- Persistent connections. Long-lived SSE or websocket sessions per client. Great for pushing updates. Terrible for serverless, terrible for scaling horizontally, terrible for
wrangler deploy. - Per-session state. A subscription created in one connection had to be honored across that connection’s lifetime. If your load balancer routed the next request to a different worker, you had to either share state (Redis, Durable Objects, a real database) or pin the client (sticky sessions).
- Reconnection semantics. What happens when the client drops? What does the server remember? The answers were non-trivial and vendor-specific.
None of this is hard for a platform team with an SRE. All of it is a wall for a solo builder who just wants an LLM to be able to look up an invoice.
Stateless MCP is the observation that for a huge class of useful tools, none of that machinery is necessary. A tool that answers “give me the status of invoice #4471” doesn’t need to remember anything between calls. Neither does “search the product catalog.” Neither does “add this expense to QuickBooks.” Each call is self-contained; the request carries everything the server needs. Which means the server can be a plain HTTP function that scales to zero when nobody’s calling it and to whatever your platform can handle when they are.
Willison’s Jul 31 post frames it exactly right: the stateless subset of MCP is what makes the protocol deployable by humans who don’t run platforms. His mcp-explorer is a Python-native way to inspect these servers; datasette-mcp is a plugin that turns a Datasette instance into an MCP server exposing its SQL queries as tools. Both are single-file-ish, both are shockingly small. That’s the point.
Build-along: an invoice lookup MCP on Cloudflare Workers
Let’s build something a real small business could use tomorrow: an MCP server that exposes two tools — lookup_invoice and list_recent_invoices — backed by a tiny data store (we’ll use a hardcoded in-memory table for the tutorial; the swap-in for Cloudflare D1, Airtable, or your accounting system is one function). Deployed to Cloudflare Workers, callable from Claude Desktop or any MCP-speaking client, secured by a bearer token.
This is not pseudocode. This is the file.
Prereqs
You need Node 20+, a Cloudflare account (free tier is fine), and wrangler installed:
npm install -g wrangler
wrangler login
Create a project directory:
mkdir invoice-mcp && cd invoice-mcp
npm init -y
npm install --save-dev wrangler typescript @cloudflare/workers-types
wrangler.toml
name = "invoice-mcp"
main = "src/index.ts"
compatibility_date = "2026-07-15"
[vars]
# override with `wrangler secret put MCP_TOKEN` in production
MCP_TOKEN = "dev-token-change-me"
src/index.ts — the whole server
// Minimal stateless MCP server over Streamable HTTP.
// Answers JSON-RPC 2.0 methods: initialize, tools/list, tools/call.
// One endpoint. One request in, one response out. No sessions.
type JsonRpcRequest = {
jsonrpc: "2.0";
id?: string | number | null;
method: string;
params?: any;
};
type JsonRpcResponse = {
jsonrpc: "2.0";
id: string | number | null;
result?: any;
error?: { code: number; message: string; data?: any };
};
// --- fake invoice store; swap for D1 / Airtable / QuickBooks call ---
type Invoice = {
id: string;
customer: string;
amount_cents: number;
status: "draft" | "sent" | "paid" | "overdue";
issued_at: string;
};
const INVOICES: Invoice[] = [
{ id: "INV-4471", customer: "Acme Coffee", amount_cents: 24500, status: "paid", issued_at: "2026-07-14" },
{ id: "INV-4472", customer: "Blue Barn LLC", amount_cents: 189000, status: "overdue", issued_at: "2026-06-30" },
{ id: "INV-4473", customer: "Chen Dental", amount_cents: 42000, status: "sent", issued_at: "2026-07-28" },
];
function lookupInvoice(id: string): Invoice | null {
return INVOICES.find(i => i.id.toLowerCase() === id.toLowerCase()) ?? null;
}
function listRecentInvoices(limit: number): Invoice[] {
return [...INVOICES]
.sort((a, b) => b.issued_at.localeCompare(a.issued_at))
.slice(0, Math.max(1, Math.min(limit, 25))); // hard cap in code, not in prose
}
// --- tool schemas (self-describing so clients render them well) ---
const TOOLS = [
{
name: "lookup_invoice",
description:
"Look up a single invoice by its ID (e.g. 'INV-4471'). Returns customer, amount, status, and issue date. Read-only.",
inputSchema: {
type: "object",
properties: {
invoice_id: { type: "string", description: "The invoice ID to look up." },
},
required: ["invoice_id"],
},
},
{
name: "list_recent_invoices",
description:
"List the most recent invoices, newest first. Read-only. Max 25 results.",
inputSchema: {
type: "object",
properties: {
limit: { type: "integer", minimum: 1, maximum: 25, default: 5 },
},
},
},
];
// --- JSON-RPC dispatch ---
async function handleRpc(req: JsonRpcRequest): Promise<JsonRpcResponse> {
const id = req.id ?? null;
switch (req.method) {
case "initialize":
return {
jsonrpc: "2.0",
id,
result: {
protocolVersion: "2026-07-28",
capabilities: { tools: {} },
serverInfo: { name: "invoice-mcp", version: "0.1.0" },
},
};
case "tools/list":
return { jsonrpc: "2.0", id, result: { tools: TOOLS } };
case "tools/call": {
const { name, arguments: args } = req.params ?? {};
try {
if (name === "lookup_invoice") {
const inv = lookupInvoice(String(args?.invoice_id ?? ""));
if (!inv) {
return {
jsonrpc: "2.0", id,
result: {
content: [{ type: "text", text: `No invoice found for ID "${args?.invoice_id}".` }],
isError: false,
},
};
}
return {
jsonrpc: "2.0", id,
result: {
content: [{ type: "text", text: JSON.stringify(inv, null, 2) }],
isError: false,
},
};
}
if (name === "list_recent_invoices") {
const limit = Number.isFinite(args?.limit) ? Number(args.limit) : 5;
const rows = listRecentInvoices(limit);
return {
jsonrpc: "2.0", id,
result: {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
isError: false,
},
};
}
return {
jsonrpc: "2.0", id,
error: { code: -32601, message: `Unknown tool: ${name}` },
};
} catch (e: any) {
return {
jsonrpc: "2.0", id,
error: { code: -32000, message: e?.message ?? "Tool error" },
};
}
}
default:
return {
jsonrpc: "2.0", id,
error: { code: -32601, message: `Method not found: ${req.method}` },
};
}
}
// --- HTTP entry point ---
export default {
async fetch(request: Request, env: { MCP_TOKEN: string }): Promise<Response> {
if (request.method !== "POST") {
return new Response("MCP server. POST JSON-RPC.", { status: 405 });
}
// bearer auth — no token, no service.
const auth = request.headers.get("authorization") ?? "";
if (auth !== `Bearer ${env.MCP_TOKEN}`) {
return new Response("unauthorized", { status: 401 });
}
let body: JsonRpcRequest;
try {
body = await request.json();
} catch {
return new Response("bad json", { status: 400 });
}
const resp = await handleRpc(body);
return new Response(JSON.stringify(resp), {
headers: { "content-type": "application/json" },
});
},
};
That’s the entire server. About 130 lines including whitespace. No sessions, no websockets, no database driver, no framework. One handler, three methods, two tools.
Deploy it
# set a real token for production; keep the dev one out of prod
wrangler secret put MCP_TOKEN
# paste a long random string
wrangler deploy
Wrangler prints a URL like https://invoice-mcp.<your-subdomain>.workers.dev. That URL is your MCP endpoint.
Smoke-test it with curl
export URL="https://invoice-mcp.<your-subdomain>.workers.dev"
export TOK="the-token-you-put-in-secrets"
# 1. initialize
curl -s -X POST "$URL" \
-H "authorization: Bearer $TOK" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize"}'
# 2. list tools
curl -s -X POST "$URL" \
-H "authorization: Bearer $TOK" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 3. call lookup_invoice
curl -s -X POST "$URL" \
-H "authorization: Bearer $TOK" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{"name":"lookup_invoice","arguments":{"invoice_id":"INV-4471"}}}'
If all three return sensible JSON, you have shipped a real MCP server that any MCP-speaking client on the internet can call. From a Cloudflare free-tier account. In a few hundred lines and a wrangler deploy. That is the change stateless MCP unlocks.
Point a client at it
Every MCP-speaking client has its own config file — Claude Desktop, Cursor, VS Code’s Copilot Chat, and the various coding-agent runtimes each have a slightly different JSON blob. The shape is always the same: give it the URL, give it the auth header. For Simon’s mcp-explorer, inspection is a one-liner:
uvx mcp-explorer https://invoice-mcp.<your-subdomain>.workers.dev \
--header "authorization: Bearer $TOK"
You’ll see the two tools discovered, their schemas rendered, and be able to invoke them interactively. That’s your development loop. Iterate on the server, redeploy, refresh the explorer. Once it feels right, wire it into whatever assistant your operator actually uses.
For remote MCP on Cloudflare specifically, their Remote MCP Server guide is worth reading if you want a more opinionated framework — but the raw-JSON-RPC version above is deliberately dependency-free, so you can port it to Lambda, Deno Deploy, Vercel Functions, or a $5 VPS with Bun.serve in an evening.
The security corner nobody writes down
Here’s the part that gets glossed over in “look how easy MCP is now!” posts, and it maps directly to the hard-won lesson from Your Agent Read the Handbook. It Still Broke the Rules.: the rules that matter live in code, not in prose. A stateless MCP server is a public HTTP endpoint. Treat it like one.
Non-negotiable defaults for a small-shop MCP server:
- Auth on every request. The example above uses a bearer token. That’s the floor, not the ceiling. Rotate it. Store it in
wrangler secret, notwrangler.toml. Never commit it. The 2026-07-28 spec makes auth first-class; use it. - Read-only by default. Both tools in the example are read-only. If you add a
create_invoiceormark_paidtool, that is a separate deployment decision with a separate review. Read tools and write tools should not live in the same server with the same token unless you’ve thought carefully about blast radius. If you haven’t read the AI agent wallet checklist, read it before you give any agent write access to money-adjacent systems. - Enforce limits in code, not descriptions. The
list_recent_invoicestool capslimitto 25 in the actual function —Math.max(1, Math.min(limit, 25))— not just in the schema description. A malicious or hallucinating client that ignores the schema still can’t pull 10,000 rows. Every hard limit that exists as prose in a docstring is a limit that doesn’t exist. - Log every call. Cloudflare’s tail logs are free. Log the tool name, the arguments (redact PII if applicable), the outcome. When an operator asks “did the assistant look up customer X’s invoice yesterday?” you need to answer with data, not vibes.
- Scope the data. The invoice tool exposes invoices. It does not expose the customers table, the payroll table, or the bank connection. Least privilege at the tool level is your friend. A small MCP server that does one thing well is easier to reason about than a monolith with fourteen tools.
- Rate-limit at the platform. Cloudflare Workers has Rate Limiting primitives you can wire up in a few lines. Do it. Agents in loops are not a hypothetical failure mode.
If any of those feel like overkill for a tool a single LLM will call, remember: MCP endpoints are addressable by any client with the URL and token. Tokens leak. URLs get pasted into shared Notion pages. Design for the world where someone with your token calls your endpoint at 3 a.m., and the design ceases to feel paranoid — it just feels correct.
When stateless isn’t the answer
Being honest about limits is part of the pitch. Stateless MCP is the right default for a lot of useful tools, but not all of them. Reach for a stateful server when:
- You need subscriptions or push updates. “Notify me when this changes” fundamentally needs a live connection or a callback URL. Stateless HTTP can’t do it in one round-trip.
- You have long-running jobs. A tool that kicks off a 10-minute report generation and returns progress works badly as a single request/response. You want either a job-queue pattern (submit → poll a job ID) or a real streaming session.
- You need cross-call context the client can’t carry. Rare in practice — clients can carry a lot in arguments — but real for things like multi-turn negotiations or resource handles that the server owns.
- You’re building the tool for one very specific desktop client and the operator’s laptop. stdio-based MCP is still the easiest thing on earth for that case. Don’t overbuild.
For a small shop, my honest cut is: 80%+ of the tools you’d actually want to expose to an agent — lookups, searches, single-shot writes with idempotency keys, catalog reads — are a natural fit for stateless. Build stateless first. Add state only when a specific tool demands it, and put that tool in its own server. Don’t turn one Worker into a monster because one out of six tools needs a websocket.
What this unlocks for a three-person shop
Zoom back out. The specific tools in the demo (lookup_invoice, list_recent_invoices) are less important than the pattern they represent. Every small business has a set of “I wish the assistant could just check that” workflows:
- The bookkeeper’s assistant should be able to check invoice status without the bookkeeper opening QuickBooks.
- The counter staff’s assistant should be able to check whether an item is in stock across the two shops — the same problem I dug into in Automate Your Inventory Sync Across 3 Platforms, now with a client-side tool the assistant can call directly.
- The owner’s assistant should be able to pull “yesterday’s sales by category” without waiting for a report to be generated on Monday.
- The support chatbot should be able to look up an order status by number without paying $200/month to a “conversational commerce” SaaS to do it.
Each of those is a stateless MCP tool. Each is under 200 lines of code. Each deploys to a serverless platform for pennies. And each — critically — can be plugged into whichever assistant your team actually uses, because the protocol is the same. You are not locking yourself into a vendor by writing an MCP server; you are un-locking yourself from the vendor’s plugin store.
That’s why this spec revision and the stateless-MCP pattern matter more for small shops than for enterprises. Enterprises can afford bespoke integrations, and they already have them. Small shops couldn’t, and now they can. The floor of “what a solo builder can ship for an assistant to reach” dropped by an order of magnitude in the last week of July 2026.
What to do this week
If you’re a builder or an operator, here’s the concrete follow-through:
- Pick one internal lookup your team does daily. Order status. Invoice state. Stock level. Vendor contact. It doesn’t matter which — pick the smallest one.
- Write it as a stateless MCP tool on Cloudflare Workers (or Lambda, or Deno Deploy — the code above ports in an evening). Read-only, one endpoint, bearer auth.
- Wire it into the assistant your team already uses. Claude Desktop, Cursor, whatever. If you don’t have one, wire it into
mcp-explorerlocally and use it from there. - Log every call for a week. Look at what the team actually asked for. That log is your product roadmap.
- Add the second tool. Repeat until the shape of “the assistant already knows about our stuff” starts feeling normal. It will happen faster than you expect.
The other tempting move — waiting until the spec settles, or until a hosting platform offers “one-click MCP servers,” or until someone builds a no-code MCP designer — is a fine strategy if you’re building a platform. If you’re a small shop trying to make the assistant useful, the code above is already enough. Ship the tool. Watch what happens.
If you want a sanity check before you point an agent at anything money-adjacent, my rules for that live in the agent wallet checklist and the handbook-vs-controls piece — they exist precisely because the “just tell it what to do in the prompt” pattern is measurably not enough. Your MCP server is the good place to put those controls. Use it.
Sources
- Stateless MCP has recaptured my interest (and inspired mcp-explorer and datasette-mcp) — Simon Willison, July 31, 2026 (~3 days old). Primary source for the stateless-MCP pattern’s practical resurgence and the two reference projects cited.
simonw/mcp-explorer— Python CLI for inspecting stateless MCP servers, referenced above as the recommended dev-loop tool.datasette/datasette-mcp— Datasette plugin exposing SQL queries as MCP tools; live evidence a small Python codebase can be a real MCP server.- The 2026-07-28 MCP Specification — official spec post for the revision cited throughout (~1 week old).
- The 2026-07-28 Release Candidate notes — background on the change set; useful for readers who want the full diff.
- Model Context Protocol — specification repository — canonical spec source and issue tracker.
- Cloudflare — Build a Remote MCP Server — Cloudflare’s own guide for the Workers deploy target used in the build-along; useful if you prefer their framework over raw JSON-RPC.
- Prior ctrlaltorion: Your Agent Read the Handbook. It Still Broke the Rules. — the governance argument for putting rules in code, referenced in the security corner.
- Prior ctrlaltorion: The AI Agent Wallet Checklist for Small Business — companion piece on locking down agent-callable systems that touch money.
- Prior ctrlaltorion: Automate Your Inventory Sync Across 3 Platforms — the inventory workflow this pattern plugs into.
Freshness note: this piece is written August 3, 2026, using signals from July 28 – August 1, 2026 (all under one week old at publication). Re-verify the spec version and Cloudflare guide URLs before shipping to production if you’re reading this more than a month later — MCP is moving fast on purpose.