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 Mac | Cloudflare Workers | |
|---|---|---|
| Runtime | Python + uv, your machine | TypeScript, V8 isolates on the edge |
| Reach | one host you keep awake | global, anycast HTTPS |
| Transport | HTTP you expose yourself | Streamable HTTP, TLS included |
| State | files / SQLite on the box | Durable Object (SQLite), per session |
| Deploy | render a plist, launchctl | one 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
McpAgentserver (add, a statefulincrement, and a counter resource) served over Streamable HTTP at/mcp, plus a plain/healthroute. - A local dev loop with
wrangler devand a Node smoke test that speaks MCP. - A one-command deploy to a public
*.workers.devURL.
Prerequisites
- Node.js 18+ (
brew install node) andnpm. Verifynode --version. - A Cloudflare account (the free plan is enough) for the deploy step —
dash.cloudflare.com.
wranglerruns throughnpx; 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 runwrangler dev); it is disposable..dev.varsis where local secrets go — never commit it.worker-configuration.d.tsis generated bywrangler 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
agentsis Cloudflare’s Agents SDK;McpAgent(fromagents/mcp) is the base class that turns a Durable Object into an MCP server.@modelcontextprotocol/sdkis pinned to an exact version.agentsdepends on a specific SDK version (here1.23.0). If your dependency resolves to a different version, npm installs two copies and TypeScript rejects yourMcpServeras “not assignable” to the oneMcpAgentexpects. Pin it to the versionagentsuses so there is a single copy. Check it withnpm ls @modelcontextprotocol/sdk.ai(the Vercel AI SDK) is here becauseagentsdynamically 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 withCould not resolve "ai".wrangleris the Workers CLI (build, local dev, deploy).@types/nodeis needed because thenodejs_compatflag (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
mainpoints at the entry module.compatibility_datepins the Workers runtime behavior; keep it recent.compatibility_flags: ["nodejs_compat"]is required by the Agents SDK.durable_objects.bindingsexposes theEdgeMCPclass to the Worker asenv.EdgeMCP.migrationswithnew_sqlite_classesregisters that class as a SQLite-backed Durable Object — mandatory forMcpAgent, whose state uses the DO’s SQLite. Never edit an existing migration; add a newtagfor new classes.observabilityturns on Workers logs sowrangler 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
includelistsworker-configuration.d.tsso the generatedEnvinterface and runtime globals (Request,Response,ExecutionContext,DurableObjectNamespace) are in scope. Modernwrangler typessupersedes the old@cloudflare/workers-typespackage.- Run
wrangler typesafter a fresh clone and after everywrangler.jsoncchange, since the file is gitignored and theEnvtype is derived from your bindings. noEmit: truebecausewrangler(esbuild) does the bundling;tscis 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 beexported and match theclass_nameinwrangler.jsonc.server = new McpServer(...)is the MCP server the agent wraps; tools and resources register on it insideinit(), which runs once per session.setState/this.stateread and write the Durable Object’s SQLite-backed state.incrementaccumulates across calls within a session; a fresh session starts frominitialState. This is the edge equivalent of a per-user server instance, with no database to provision.- The
fetchdefault export is the Worker entry point./healthreturns plain JSON (handy for a load balancer, no MCP handshake). Anything under/mcpis handed toEdgeMCP.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
StreamableHTTPClientTransportis the client half of the transportEdgeMCP.serveexposes.client.connectruns the MCP initialize handshake and opens a session.incrementis called with1then4, so the counter reads5, 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
5again, not10: a new client connection is a new session with its own Durable Object, starting frominitialState. 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
makeprints the help screen listing every target. make devthenmake smoke(in a second terminal) is the local loop;make deployships it;make tailstreams 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 theaipackage; 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/sdkare installed. Pin your dependency to the versionagentsuses (npm ls @modelcontextprotocol/sdkshows both), then reinstall.Envis untyped / red squiggles on bindings. Runnpx wrangler types. It is gitignored and must be regenerated after a clone or awrangler.jsoncchange.- 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 deploysays you are not authenticated. Runnpx wrangler loginfirst, or set aCLOUDFLARE_API_TOKENfor CI.- Durable Object errors on first deploy. Confirm the class is listed in both
durable_objects.bindingsand anew_sqlite_classesmigration, and that theclass_namematches the exported class.
Recap
- An MCP server on Workers is an
McpAgent(a Durable Object) that registers tools ininit()and is served over Streamable HTTP withEdgeMCP.serve("/mcp", …). wrangler.jsoncbinds the class and registers it as a SQLite Durable Object via a migration;wrangler typesgenerates theEnvtype.- State is per session, persisted in the Durable Object with no database to
run — the edge counterpart to a stateful
launchdserver. wrangler devruns it locally andwrangler deployships it to a global HTTPS URL that any Streamable HTTP client can reach.
Next improvements
- Add OAuth with
@cloudflare/workers-oauth-providerto 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 deployon push with aCLOUDFLARE_API_TOKENsecret, so shipping is automatic.