Two things happened this week that turn “can I run an MCP server for free” from a compromise into a plain yes, with one measurable condition. Today the Model Context Protocol shipped its 2026-07-28 revision, and the headline change is that the protocol core went stateless: sessions, the Mcp-Session-Id header, and the initialize handshake are gone. The release post states the consequence outright:

Any request can now land on any server instance behind a plain round-robin load balancer without needing shared storage.

And yesterday, one day ahead of the release, Cloudflare’s own documentation flipped. The McpAgent class — the official path that backed every MCP server with a Durable Object because the transport needed somewhere to keep its session — is now marked deprecated and feature frozen, with a stateless request handler as the recommended replacement for new servers.

Those two moves close the same gap from both ends. A Worker on the free plan was always a natural place for a small MCP server except for one thing: the transport demanded state, and state meant a Durable Object and a sticky instance. Now the protocol itself promises that every request is self contained. What remains is the free plan’s one hard ceiling — 10 ms of CPU per request — and whether a real server fits under it is not a question you argue about. It is a question you measure. So we built one, on this site, and measured it.

What the revision actually removed

The old lifecycle opened every connection with an initialize round trip, minted a session id, and required the client to carry it on every call. All of that is deleted. Version, client identity, and capabilities now travel inside each request’s _meta fields, and the one thing a server must implement is server/discover, which reports its supported versions and capabilities to anyone who asks.

The transport got stricter in exchange. Every POST must carry an MCP-Protocol-Version header that matches the version inside the body, plus an Mcp-Method header mirroring the JSON-RPC method — and Mcp-Name on tool calls — so load balancers and gateways can route without parsing bodies. A mismatch is a hard 400. Batching stays gone, ping is removed entirely, an unknown method is now an HTTP 404, and the 2024 era HTTP+SSE transport is formally classified as deprecated. A server that never pushes messages may answer every request with plain JSON and refuse the streaming path with a 405.

Until 2025: stateful

initialize

session id minted

every call carries the session

same instance must answer

2026-07-28: stateless

one POST
version + identity in _meta

any instance answers

What a client had to do before, and what a request is now

For a server that only reads, the protocol surface left over is small: server/discover, tools/list, tools/call, and the header discipline. That is the whole list.

The server this site now runs

This blog already publishes an agent surface — Markdown mirrors of every article and a posts.json feed. The MCP server is those same files behind two tools: list_articles returns the feed, get_article returns an article’s full Markdown. It lives at https://301.sh/mcp on the same Worker that does the site’s content negotiation, with zero dependencies — after the revision, the remaining protocol surface is short enough to write out by hand. Cloudflare ships an SDK route for the same job (createMcpHandler plus the protocol SDK), which is the right answer the moment you need resources, prompts, or elicitation; a two tool read only server does not.

The dispatch, condensed to its shape:

switch (message.method) {
  case 'server/discover':
    return reply(id, { resultType: 'complete', supportedVersions: ['2026-07-28'],
      capabilities: { tools: {} }, ttlMs: 3_600_000, cacheScope: 'public' });
  case 'tools/list':
    return reply(id, { resultType: 'complete', tools: TOOLS, ttlMs: 3_600_000,
      cacheScope: 'public' });
  case 'tools/call': {
    // Mcp-Name header must equal params.name, or 400 + HeaderMismatch.
    const mirror = await env.ASSETS.fetch(new URL(`/${slug}.md`, origin));
    return reply(id, { resultType: 'complete',
      content: [{ type: 'text', text: await mirror.text() }], isError: false });
  }
  default:
    return fail(id, 404, -32601, `Method not found: ${message.method}`);
}

Nothing in it computes. Every answer is an already deployed static asset fetched through the assets binding, and that choice is exactly what the measurement rewards.

The measurement

Cloudflare’s definition of the limit is the part most people skip past:

CPU time measures how long the CPU spends executing your Worker code. Waiting on network requests (such as fetch() calls, KV reads, or database queries) does not count toward CPU time.

So the 10 ms budget is spent on parsing, validating, and assembling JSON — not on fetching the article body. We sent 40 requests at the production endpoint and read the per invocation cpuTime that Workers Logs record:

Method CPU, ms (min / median / max) Wall, ms (min / median / max)
server/discover 0 / 0 / 1 0 / 1 / 2
tools/list 0 / 0 / 0 0 / 1 / 2
list_articles 0 / 1 / 1 10 / 13 / 23
get_article, largest article 0 / 1 / 2 6 / 10 / 88

The worst case spends 2 ms of the 10 ms budget, and the slowest response — 88 ms of wall time serving the longest article on the site — is almost entirely waiting on the asset fetch, which the meter ignores. A serving tool runs at a fifth of the free ceiling with room to spare, and the free plan’s other number, 100,000 requests a day, is a different class of problem entirely for an endpoint agents call a few times per conversation.

Where 10 ms actually bites

The ceiling is real; it just lives somewhere else than the transport. When we audited this site’s agent surface, we measured a template engine we considered exposing as a service, and it blew through the same 10 ms by a factor of eight. That is the honest division: a tool that serves bytes costs one or two milliseconds; a tool that computes — parses documents, renders templates, hashes at scale — eats the budget almost immediately. Cloudflare does allow occasional overruns (“Each isolate has some built-in flexibility”), but a Worker that exceeds the limit consistently is terminated with error 1102, and burst tolerance is not a plan.

The lift has a price tag, and per this site’s house rule it belongs next to the limit: the Workers Paid plan at $5 a month raises the per request cap to a default of 30 seconds (configurable up to 5 minutes) and includes 30 million CPU milliseconds and 10 million requests a month. If your tools compute, that is the number to compare against, not the free tier’s 10 ms.

Try it, and what this does not need

The endpoint is live, and one request shows the whole new shape of the protocol:

curl https://301.sh/mcp -X POST \
  -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' \
  -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover",
       "params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'

No session to open, no state to hold, no Durable Object on the bill. If your site already publishes Markdown mirrors or a feed — and after the agent readiness audit ours did — the distance from “static files” to “MCP server” is one route on a Worker you may already be running. The spec finally matches what a small read only server always wanted to be: a plain HTTP endpoint that answers questions about content you have already built. The free plan carries that easily. It is the computing tools that were never going to be free, and now you know the number that decides.