The answer. A read-only MCP server needs no Agents SDK, Durable Object or SSE stream. Streamable HTTP permits a stateless server: each client message is a POST with a JSON-RPC 2.0 body, answered in the response. Handle initialize, ping, tools/list and tools/call; return 202 with an empty body for notifications; never issue a session id. Serve tools from a JSON file built with the site. Add a server card at /.well-known/mcp/server-card.json and a descriptive GET /mcp, so the server is discoverable without speaking the protocol. On Pages it's one file in functions/; on a zone, the same handler in a Worker's router.
The pattern.
// functions/mcp.js — stateless Streamable HTTP MCP server
const PROTOCOL = "2025-06-18", INFO = { name: "my-site", version: "0.1.0" };
const TOOLS = [{ name: "list_things", description: "List things.", inputSchema: { type: "object" } }];
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
const err = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
async function handle(msg, env, origin) {
if (msg?.jsonrpc !== "2.0") return err(msg?.id ?? null, -32600, "Invalid Request");
const { id, method, params } = msg;
switch (method) {
case "initialize": return ok(id, { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: INFO });
case "tools/list": return ok(id, { tools: TOOLS });
case "tools/call": return ok(id, await runTool(env, origin, params));
default: return id == null ? null : err(id, -32601, `Method not found: ${method}`);
}
}
export async function onRequest({ request, env }) {
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
if (request.method !== "POST") return Response.json({ server: INFO, transport: "streamable-http" }, { headers: CORS });
const body = await request.json().catch(() => null), origin = new URL(request.url).origin;
const out = (await Promise.all([].concat(body).map((m) => handle(m, env, origin)))).filter(Boolean);
if (!out.length) return new Response(null, { status: 202, headers: CORS });
return Response.json(Array.isArray(body) ? out : out[0], { headers: CORS });
}
The receipt. Our lab's site shipped a 119-line functions/mcp.js on 2026-06-11: six read-only tools over the site's /agent/data.json, protocol version 2025-06-18, no auth, open CORS. Our studio site is on Webflow (no Functions), so the identical handler lives in a zone Worker beside the discovery files (/llms.txt, /.well-known/api-catalog, an oauth-protected-resource document declaring no auth). Call volume is unmeasured on both.
Watch out.
- Notifications get a
202with an empty body; a JSON error there breaks some clients. - Allow
mcp-session-idin the CORS headers even though you never set it; clients that send it must not be rejected. - Stateless means no server-initiated messages; progress or subscriptions is when the Agents SDK earns its cost.
Related: pages-middleware-accept-text-markdown-negotiation · credential-free-edge-companion-hmac-site-tokens