Skip to content
Wiki

A stateless remote MCP server runs as one Cloudflare Pages Function at functions/mcp.ts

DrFritzi · Reviewed · Updated 28 Sept 2026 · Markdown

Answer

Create functions/mcp.ts in a Cloudflare Pages project and export onRequest. Cloudflare maps the file to the route /mcp. Answer every POST with exactly one JSON-RPC message in and one application/json response out, return 202 for notifications, and return 405 for other methods. Read static files with env.ASSETS.fetch(). Run npx wrangler pages dev out to test. This page describes what the wiki at wiki.drfritzi.at runs as of 2026-09-28.

Details

File layout

Cloudflare's docs say the /functions directory structure determines the routes, so functions/mcp.ts serves /mcp. If no Function matches, the request falls back to a static asset. onRequest runs unless a more specific onRequestPost-style export exists. Here one onRequest handles every method, which keeps the 405 logic in one place.

The handler

export async function onRequest({ request, env }) {
  if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS });
  if (request.method !== 'POST') {
    return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'POST, OPTIONS', ...CORS } });
  }
  const msg = await request.json();               // one JSON-RPC message per POST
  if (msg.id === undefined) return new Response(null, { status: 202, headers: CORS }); // notification
  return json({ jsonrpc: '2.0', id: msg.id, result: await handle(env, msg) });
}

The full file also checks jsonrpc, answers parse errors with -32700 and unknown methods with -32601. No session ID is issued, so a request can land on any isolate.

Reading the static site

env.ASSETS is the default binding to Pages' asset server. This wiki's search_answers and get_answer tools fetch /search-index.json and /a/<slug>.md from the build:

const res = await env.ASSETS.fetch(new Request(new URL('/a/what-is-mcp.md', origin)));

Cloudflare's reference recommends pretty paths such as /users/. A direct .md path works here, and the smoke test checks it. Validate slugs against ^[a-z0-9]+(?:-[a-z0-9]+)*$ before building a path.

Two protocol eras in one endpoint

  • Legacy (2025-11-25 and earlier): initialize, ping, tools/list, tools/call, with no session.
  • 2026-07-28: a request is modern when params._meta carries io.modelcontextprotocol/protocolVersion or the MCP-Protocol-Version header names a modern version. The handler then requires MCP-Protocol-Version and Mcp-Method (plus Mcp-Name for tools/call) to match the body, else 400 with -32020. It answers server/discover, puts resultType: "complete" on each result, and returns 404 with -32601 for unknown methods.

Test locally

npm run build
npx wrangler pages dev out --port 8788
curl -sS -X POST http://127.0.0.1:8788/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}'

Cloudflare documents npx wrangler pages dev <DIRECTORY> and port 8788 as the default. The repo's scripts/check-mcp.sh starts wrangler, calls both eras and the error cases, and runs in CI.

Limits to know

Pages Functions requests count toward the Workers quota. On the Free plan that is 100,000 requests per day, shared with Workers, and static asset requests are free and unlimited. In the dashboard, Settings > Runtime > Fail open / closed decides what happens when the free allowance runs out. Fail open keeps serving static assets. Fail closed returns an error page. In both cases the Function no longer runs, so /mcp stops answering until the daily limit resets at midnight UTC.

When to use something else

Stateless is the shape 2026-07-28 allows. If you need per-session state, Cloudflare's Agents docs describe McpAgent, a stateful server backed by a Durable Object. As of 2026-09-28 those docs mark it deprecated and feature-frozen and recommend createMcpHandler for new stateless servers. Check the current docs before choosing.

See also

Sources