The FastMCP tutorials in this series deploy to a Mac with launchd: one machine, your machine, awake and on the network. This one takes the opposite approach and deploys an MCP server to Cloudflare Workers — TypeScript with the Agents SDK’s McpAgent, pushed to a public HTTPS URL that runs in data centers worldwide. There is no server of your own to keep running, and per-session state lives in a Durable Object (SQLite) instead of a file on disk.

launchd on a MacCloudflare Workers
RuntimePython + uv, your machineTypeScript, V8 isolates on the edge
Reachone host you keep awakeglobal, anycast HTTPS
TransportHTTP you expose yourselfStreamable HTTP, TLS included
Statefiles / SQLite on the boxDurable Object (SQLite), per session
Deployrender a plist, launchctlone wrangler deploy

This is a TypeScript project (Node and npm), not the Python/uv stack of the other articles. An McpAgent is a Durable Object under the hood, so each MCP session gets its own instance and its own persistent state.

What you will build

  • An McpAgent server (add, a stateful increment, and a counter resource) served over Streamable HTTP at /mcp, plus a plain /health route.
  • A local dev loop with wrangler dev and a Node smoke test that speaks MCP.
  • A one-command deploy to a public *.workers.dev URL.

Prerequisites

  • Node.js 18+ (brew install node) and npm. Verify node --version.
  • A Cloudflare account (the free plan is enough) for the deploy step — dash.cloudflare.com. wrangler runs through npx; no global install needed.
  • Familiarity with MCP tools and transports (see Build an MCP Server with FastMCP). No prior Workers experience assumed.

Step 1: Scaffold and add project hygiene

Create the files

mkdir -p deploy-mcp-cloudflare-workers/src
cd deploy-mcp-cloudflare-workers
touch .gitignore

Add the code: .gitignore

# Node / Wrangler
node_modules/
dist/
.wrangler/
.dev.vars
*.log
# Cloudflare local state
.mf/
# Generated types
worker-configuration.d.ts
# OS / editor noise
.DS_Store

Detailed breakdown

  • .wrangler/ holds local dev state (the Durable Object’s SQLite while you run wrangler dev); it is disposable.
  • .dev.vars is where local secrets go — never commit it.
  • worker-configuration.d.ts is generated by wrangler types (Step 4). It is a build artifact, so it is ignored and regenerated rather than committed.

Step 2: Dependencies

Four runtime packages and three dev tools. Two of the choices are not obvious and are explained below.

Create the file

touch package.json

Add the code: package.json

{
  "name": "mcp-edge",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "types": "wrangler types",
    "smoke": "node scripts/smoke.mjs"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "1.23.0",
    "agents": "^0.2.0",
    "ai": "^7.0.31",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "typescript": "^5.6.0",
    "wrangler": "^4.0.0"
  }
}

Install:

npm install

Detailed breakdown

  • agents is Cloudflare’s Agents SDK; McpAgent (from agents/mcp) is the base class that turns a Durable Object into an MCP server.
  • @modelcontextprotocol/sdk is pinned to an exact version. agents depends on a specific SDK version (here 1.23.0). If your dependency resolves to a different version, npm installs two copies and TypeScript rejects your McpServer as “not assignable” to the one McpAgent expects. Pin it to the version agents uses so there is a single copy. Check it with npm ls @modelcontextprotocol/sdk.
  • ai (the Vercel AI SDK) is here because agents dynamically imports it on its client code path. An MCP server never calls that path, but the bundler still has to resolve the import, so the package must be installed or the build fails with Could not resolve "ai".
  • wrangler is the Workers CLI (build, local dev, deploy). @types/node is needed because the nodejs_compat flag (Step 3) exposes Node globals.

Step 3: Configure the Worker

wrangler.jsonc tells Cloudflare how to build and bind the Worker. The MCP server is a Durable Object, which needs both a binding and a migration.

Create the file

touch wrangler.jsonc

Add the code: wrangler.jsonc

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "mcp-edge",
  "main": "src/index.ts",
  "compatibility_date": "2026-06-01",
  "compatibility_flags": ["nodejs_compat"],
  "durable_objects": {
    "bindings": [{ "name": "EdgeMCP", "class_name": "EdgeMCP" }]
  },
  "migrations": [{ "tag": "v1", "new_sqlite_classes": ["EdgeMCP"] }],
  "observability": { "enabled": true }
}

Detailed breakdown

  • main points at the entry module. compatibility_date pins the Workers runtime behavior; keep it recent.
  • compatibility_flags: ["nodejs_compat"] is required by the Agents SDK.
  • durable_objects.bindings exposes the EdgeMCP class to the Worker as env.EdgeMCP. migrations with new_sqlite_classes registers that class as a SQLite-backed Durable Object — mandatory for McpAgent, whose state uses the DO’s SQLite. Never edit an existing migration; add a new tag for new classes.
  • observability turns on Workers logs so wrangler tail (Step 8) has something to stream.

Step 4: TypeScript config and generated types

wrangler types reads wrangler.jsonc and writes an Env type (with your bindings) plus the Workers runtime types into worker-configuration.d.ts.

Create the file

touch tsconfig.json

Add the code: tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "Bundler",
    "lib": ["ES2022"],
    "types": ["node"],
    "strict": true,
    "skipLibCheck": true,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"]
}

Generate the types:

npx wrangler types

Detailed breakdown

  • include lists worker-configuration.d.ts so the generated Env interface and runtime globals (Request, Response, ExecutionContext, DurableObjectNamespace) are in scope. Modern wrangler types supersedes the old @cloudflare/workers-types package.
  • Run wrangler types after a fresh clone and after every wrangler.jsonc change, since the file is gitignored and the Env type is derived from your bindings.
  • noEmit: true because wrangler (esbuild) does the bundling; tsc is only a type checker here.

Step 5: Write the server

The server extends McpAgent, registers tools and a resource in init(), and routes requests in the default fetch handler.

Create the file

touch src/index.ts

Add the code: src/index.ts

/**
 * An MCP server that runs on Cloudflare Workers.
 *
 * Unlike a launchd service pinned to one Mac, this deploys to Cloudflare's edge:
 * one `wrangler deploy` puts it on a public HTTPS URL, served from data centers
 * worldwide. Each MCP session is backed by its own Durable Object (SQLite), so
 * the counter below persists across the requests in a session — and across
 * hibernation — without a database of your own. A different session starts from
 * its own initial state.
 */
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { McpAgent } from "agents/mcp";
import { z } from "zod";

type State = { counter: number };

export class EdgeMCP extends McpAgent<Env, State, {}> {
  server = new McpServer({ name: "mcp-edge", version: "0.1.0" });

  initialState: State = { counter: 0 };

  async init() {
    // A pure tool: no state, just computation.
    this.server.registerTool(
      "add",
      {
        description: "Add two integers.",
        inputSchema: { a: z.number(), b: z.number() },
      },
      async ({ a, b }) => ({
        content: [{ type: "text", text: String(a + b) }],
      }),
    );

    // A stateful tool: the counter persists in this session's Durable Object.
    this.server.registerTool(
      "increment",
      {
        description: "Increment the persistent counter and return its value.",
        inputSchema: { amount: z.number().default(1) },
      },
      async ({ amount }) => {
        this.setState({ counter: this.state.counter + amount });
        return {
          content: [{ type: "text", text: `counter=${this.state.counter}` }],
        };
      },
    );

    // Expose the same state as a resource.
    this.server.resource("counter", "mcp://resource/counter", (uri) => ({
      contents: [{ uri: uri.href, text: String(this.state.counter) }],
    }));
  }
}

export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext): Response | Promise<Response> {
    const url = new URL(request.url);

    // A plain health check for uptime monitors (no MCP handshake required).
    if (url.pathname === "/health") {
      return Response.json({ status: "ok", server: "mcp-edge" });
    }

    // Streamable HTTP transport (recommended for external clients).
    if (url.pathname.startsWith("/mcp")) {
      return EdgeMCP.serve("/mcp", { binding: "EdgeMCP" }).fetch(request, env, ctx);
    }

    return new Response("Not found", { status: 404 });
  },
};

Detailed breakdown

  • class EdgeMCP extends McpAgent<Env, State, {}> is the server. The three type parameters are the environment bindings, the shape of the persisted state, and the per-session props (empty here). It must be exported and match the class_name in wrangler.jsonc.
  • server = new McpServer(...) is the MCP server the agent wraps; tools and resources register on it inside init(), which runs once per session.
  • setState / this.state read and write the Durable Object’s SQLite-backed state. increment accumulates across calls within a session; a fresh session starts from initialState. This is the edge equivalent of a per-user server instance, with no database to provision.
  • The fetch default export is the Worker entry point. /health returns plain JSON (handy for a load balancer, no MCP handshake). Anything under /mcp is handed to EdgeMCP.serve("/mcp", { binding: "EdgeMCP" }), which implements the Streamable HTTP transport and routes each session to its Durable Object by the binding name.

Step 6: Run it locally

wrangler dev builds the Worker and runs it in a local Workers runtime — Durable Objects and SQLite included, no account required.

npx wrangler dev
# ⎔ Starting local server...
# [wrangler:info] Ready on http://localhost:8787

In another terminal, check the health route:

curl -s http://localhost:8787/health
# {"status":"ok","server":"mcp-edge"}

Then drive the MCP endpoint with a small client. It speaks the same Streamable HTTP transport a real client uses.

Create the file

mkdir -p scripts
touch scripts/smoke.mjs

Add the code: scripts/smoke.mjs

/**
 * Smoke-test the Worker over the Streamable HTTP transport.
 *
 * Point it at a running server (local `wrangler dev` or a deployed URL) and it
 * runs the MCP handshake, lists tools, calls them, and reads the counter
 * resource — the same protocol a real client speaks. Usage:
 *
 *   MCP_URL=http://localhost:8787/mcp node scripts/smoke.mjs
 */
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const url = process.env.MCP_URL ?? "http://localhost:8787/mcp";

const client = new Client({ name: "smoke", version: "0.1.0" });
await client.connect(new StreamableHTTPClientTransport(new URL(url)));

const tools = await client.listTools();
console.log("tools:", tools.tools.map((t) => t.name).sort());

const sum = await client.callTool({ name: "add", arguments: { a: 2, b: 3 } });
console.log("add ->", sum.content[0].text);

await client.callTool({ name: "increment", arguments: { amount: 1 } });
const inc = await client.callTool({ name: "increment", arguments: { amount: 4 } });
console.log("increment ->", inc.content[0].text);

const res = await client.readResource({ uri: "mcp://resource/counter" });
console.log("counter resource ->", res.contents[0].text);

await client.close();

Run it:

MCP_URL=http://localhost:8787/mcp node scripts/smoke.mjs
tools: [ 'add', 'increment' ]
add -> 5
increment -> counter=5
counter resource -> 5

Detailed breakdown

  • StreamableHTTPClientTransport is the client half of the transport EdgeMCP.serve exposes. client.connect runs the MCP initialize handshake and opens a session.
  • increment is called with 1 then 4, so the counter reads 5, and the resource reports the same value — the requests share one session’s Durable Object.
  • State is per session. Run the script again and it prints 5 again, not 10: a new client connection is a new session with its own Durable Object, starting from initialState. Requests within a session share state; separate sessions do not.

Step 7: The Makefile

Wrap the npm/wrangler commands so plain make prints help.

Create the file

touch Makefile

Add the code: Makefile

.DEFAULT_GOAL := help

PORT ?= 8787
MCP_URL ?= http://localhost:$(PORT)/mcp

.PHONY: help install types dev smoke deploy tail clean

help:  ## Show this help screen
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
		| awk 'BEGIN {FS = ":.*?## "}; {printf "  \033[36m%-10s\033[0m %s\n", $$1, $$2}'

install:  ## Install dependencies
	npm install

types:  ## Regenerate Worker types from wrangler.jsonc
	npx wrangler types

dev:  ## Run the Worker locally (http://localhost:$(PORT))
	npx wrangler dev --port $(PORT)

smoke:  ## Run the MCP client smoke test against MCP_URL (server must be running)
	MCP_URL=$(MCP_URL) node scripts/smoke.mjs

deploy:  ## Deploy to Cloudflare (requires `npx wrangler login` first)
	npx wrangler deploy

tail:  ## Stream live logs from the deployed Worker
	npx wrangler tail

clean:  ## Remove build artifacts and local state
	rm -rf .wrangler dist node_modules

Detailed breakdown

  • Plain make prints the help screen listing every target.
  • make dev then make smoke (in a second terminal) is the local loop; make deploy ships it; make tail streams production logs.

Step 8: Deploy to Cloudflare

Deploying needs a Cloudflare account. Log in once — this opens a browser to authorize wrangler:

npx wrangler login

Then deploy:

npx wrangler deploy

Wrangler uploads the Worker, creates the Durable Object, and prints the public URL, for example https://mcp-edge.<your-subdomain>.workers.dev. The MCP endpoint is that URL plus /mcp. Confirm it the same way you tested locally:

MCP_URL=https://mcp-edge.<your-subdomain>.workers.dev/mcp node scripts/smoke.mjs

Stream live logs while you exercise it:

npx wrangler tail

Step 9: Point a client at it

A deployed Worker is a remote HTTP MCP server, so any client that speaks Streamable HTTP connects with just the URL. With Claude Code (see Register a FastMCP Server with Claude Desktop and Claude Code):

claude mcp add --transport http mcp-edge https://mcp-edge.<your-subdomain>.workers.dev/mcp
claude mcp get mcp-edge     # Status: ✔ Connected

For a stdio-only client like Claude Desktop, bridge it with mcp-remote (npx -y mcp-remote https://…/mcp).

Troubleshooting

  • Build fails with Could not resolve "ai". Install the ai package; the Agents SDK dynamically imports it even though an MCP server does not use that path.
  • McpServer “not assignable” type error. Two copies of @modelcontextprotocol/sdk are installed. Pin your dependency to the version agents uses (npm ls @modelcontextprotocol/sdk shows both), then reinstall.
  • Env is untyped / red squiggles on bindings. Run npx wrangler types. It is gitignored and must be regenerated after a clone or a wrangler.jsonc change.
  • The counter does not accumulate between runs. That is expected: each client session is its own Durable Object. Reuse one client/session to share state.
  • wrangler deploy says you are not authenticated. Run npx wrangler login first, or set a CLOUDFLARE_API_TOKEN for CI.
  • Durable Object errors on first deploy. Confirm the class is listed in both durable_objects.bindings and a new_sqlite_classes migration, and that the class_name matches the exported class.

Recap

  • An MCP server on Workers is an McpAgent (a Durable Object) that registers tools in init() and is served over Streamable HTTP with EdgeMCP.serve("/mcp", …).
  • wrangler.jsonc binds the class and registers it as a SQLite Durable Object via a migration; wrangler types generates the Env type.
  • State is per session, persisted in the Durable Object with no database to run — the edge counterpart to a stateful launchd server.
  • wrangler dev runs it locally and wrangler deploy ships it to a global HTTPS URL that any Streamable HTTP client can reach.

Next improvements

  • Add OAuth with @cloudflare/workers-oauth-provider to protect the server, the Workers equivalent of the Clerk/GitHub auth articles.
  • Bind storage — KV for config, D1 for shared relational data, R2 for blobs — when per-session Durable Object state is not enough.
  • Attach a custom domain in the Workers dashboard so the MCP URL lives on your own hostname instead of workers.dev.
  • Add CI that runs wrangler deploy on push with a CLOUDFLARE_API_TOKEN secret, so shipping is automatic.