Guide
How to Build an MCP Server in TypeScript on Cloudflare Workers
Table of Contents
If you want to build an MCP server that any AI client can reach over the internet, Cloudflare Workers is one of the quickest places to put it. There is no server to patch, deploys take seconds, and a Worker sits close to whatever API or data you want to expose to your AI tools.
This guide takes you from an empty folder to a deployed, password-protected remote MCP server written in TypeScript. We will build two tools, test them locally in MCP Inspector, deploy, and then connect the server to Claude Desktop and Cursor.
One thing before we start, because it will save you an evening. A lot of tutorials, including older Cloudflare examples, build on the McpAgent class and Durable Objects. According to Cloudflare’s handler API docs (last updated 28 July 2026), McpAgent is now deprecated and feature-frozen, and new servers should use createMcpHandler with MCP SDK v2. Even the one-click deploy templates still sit on the old path. So this guide uses the newer, stateless route.
What you will build
By the end you will have a Worker called seo-mcp that:
- Exposes an
addtool (the “hello world” of tools, so you can prove the plumbing works) - Exposes a
check_page_seotool that fetches any public URL and returns its status code, title, meta description, canonical URL, and H1 count - Rejects any request that does not carry your secret bearer token
- Runs locally with
wrangler devand in production on aworkers.devURL - Connects to real MCP clients
If you are still fuzzy on what MCP actually is, read MCP Servers Explained first and come back. The short version: an MCP server is a small service that tells an AI client “here are the tools I offer and the inputs they take”, and the client calls them when the model decides they are useful.
Prerequisites
- Node.js (a current LTS release is fine) and npm
- A free Cloudflare account
- Basic TypeScript. Nothing fancy, if you can read an async function you are fine
Stateless or stateful: pick the right approach
Cloudflare’s remote MCP server guide lists four ways to build a server. The useful part is this comparison:
| Approach | Stateful? | Best for |
|---|---|---|
createMcpHandler() | No | New stateless tools |
createLegacyMcpHandler() | Optional | Temporary bridge for existing session-based routes |
McpAgent | Yes | Deprecated Durable Object and RPC servers |
| Raw SDK transport | Depends | Custom transport ownership |
For almost every tool server, stateless is exactly what you want. Each request arrives with everything the server needs, the Worker answers, and nothing is held in memory between calls. If you do need to remember things across requests, store them in a Durable Object, D1, KV or R2 behind an authenticated handle, not in an MCP session. Cloudflare’s docs describe this pattern in the handler API page.
Step 1: Create the project
I am setting this up by hand rather than using a template, because the templates still use the deprecated path and the manual setup is only a few commands.
mkdir seo-mcp && cd seo-mcp
npm init -y
npm i agents @modelcontextprotocol/server@2.0.0 zod
npm i -D wrangler typescriptA quick note on versions. Cloudflare says to use the exact MCP package versions required by your installed agents release, and their docs currently pair agents with @modelcontextprotocol/server@2.0.0. Check the handler API page when you install, because this is the kind of thing that changes.
Open package.json, set the module type, and add scripts:
{
"name": "seo-mcp",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types"
}
}(Keep the dependencies that npm already added. I am only showing the parts you change.)
Step 2: Configure Wrangler and TypeScript
Create wrangler.jsonc in the project root:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "seo-mcp",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"]
}Set compatibility_date to roughly today’s date when you follow along. I enable nodejs_compat as a safe default for packages that expect Node APIs. If you want to find out whether you need it, remove it and see whether the build complains.
Then a minimal tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "worker-configuration.d.ts"]
}Finally, generate the Worker types so ExportedHandler and Env exist:
npm run cf-typegenLearn more about the config file in the Wrangler docs.
Step 3: Write your first MCP server
Create src/index.ts. This is the smallest working server:
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";
function createServer() {
const server = new McpServer({
name: "seo-mcp",
version: "1.0.0",
});
server.registerTool(
"add",
{
description: "Add two numbers together",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
}),
);
return server;
}
export default {
fetch(request, env, ctx) {
return createMcpHandler(createServer)(request, env, ctx);
},
} satisfies ExportedHandler<Env>;Three details here trip people up, and all three come straight from Cloudflare’s docs:
- Pass the factory, not a server.
createMcpHandler(createServer)receives the function. The handler creates a fresh MCP server for every request. Do not build one global server instance and pass that in. - Do not export the handler directly. Writing
export default createMcpHandler(createServer)looks tidy, but Wrangler treats a function default export as aWorkerEntrypointclass. Wrap it in an object with afetchmethod, as above. - Zod describes your inputs. The
inputSchemais a plain object of Zod validators. The model sees the shape (and yourdescriptiontext) and uses it to decide how to call the tool, so good descriptions matter more than clever code. See the Zod docs for everything you can validate.
By default the handler serves the MCP endpoint at /mcp. You can change that with the route option if you need to.
Step 4: Add a tool that does something useful
An add tool proves the pipeline works, but nobody needs a calculator in their AI client. Let us add a tool that is genuinely handy for anyone who does SEO or content work: fetch a page and report the on-page basics.
Workers ship with HTMLRewriter, a streaming HTML parser, so we do not need to install a parsing library:
server.registerTool(
"check_page_seo",
{
description:
"Fetch a public web page and report its HTTP status, title, meta description, canonical URL and number of H1 tags.",
inputSchema: { url: z.string().url() },
},
async ({ url }) => {
const target = new URL(url);
if (!["http:", "https:"].includes(target.protocol)) {
return {
isError: true,
content: [{ type: "text", text: "Only http and https URLs are allowed." }],
};
}
const res = await fetch(target, {
redirect: "follow",
headers: { "User-Agent": "seo-mcp/1.0" },
});
const seo = {
status: res.status,
finalUrl: res.url,
title: "",
description: "",
canonical: "",
h1Count: 0,
};
const rewritten = new HTMLRewriter()
.on("title", {
text(chunk) {
seo.title += chunk.text;
},
})
.on('meta[name="description"]', {
element(el) {
seo.description = el.getAttribute("content") ?? "";
},
})
.on('link[rel="canonical"]', {
element(el) {
seo.canonical = el.getAttribute("href") ?? "";
},
})
.on("h1", {
element() {
seo.h1Count += 1;
},
})
.transform(res);
// The handlers only run while the body is being read
await rewritten.arrayBuffer();
return {
content: [
{
type: "text",
text: JSON.stringify({ ...seo, title: seo.title.trim() }, null, 2),
},
],
};
},
);Two things worth understanding. First, HTMLRewriter handlers fire as the body streams through, so you must actually consume the response (the arrayBuffer() call) before reading your results. Second, the text handler can fire in several chunks for one element, which is why the title is appended with += rather than assigned.
A security note, because this tool takes a URL from a model and fetches it: always treat that input as untrusted. I restrict the protocol to http and https. If you later let other people use your server, add a hostname allowlist or blocklist as well.
Step 5: Protect it with a bearer token
Right now anyone who finds your URL can call your tools. Cloudflare’s docs are blunt about this: CORS headers are not authentication, so you need a real auth layer in front of the endpoint.
For a personal or team server, a long random bearer token is a perfectly reasonable start. Generate one:
openssl rand -hex 32Create a .dev.vars file for local development (and add it to .gitignore straight away):
echo 'MCP_API_KEY="paste-your-generated-key-here"' > .dev.vars
echo '.dev.vars' >> .gitignoreRun npm run cf-typegen again so Wrangler picks up the new variable in your types. Now here is the complete src/index.ts with the auth check wired in:
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";
function createServer() {
const server = new McpServer({
name: "seo-mcp",
version: "1.0.0",
});
server.registerTool(
"add",
{
description: "Add two numbers together",
inputSchema: { a: z.number(), b: z.number() },
},
async ({ a, b }) => ({
content: [{ type: "text", text: String(a + b) }],
}),
);
server.registerTool(
"check_page_seo",
{
description:
"Fetch a public web page and report its HTTP status, title, meta description, canonical URL and number of H1 tags.",
inputSchema: { url: z.string().url() },
},
async ({ url }) => {
const target = new URL(url);
if (!["http:", "https:"].includes(target.protocol)) {
return {
isError: true,
content: [{ type: "text", text: "Only http and https URLs are allowed." }],
};
}
const res = await fetch(target, {
redirect: "follow",
headers: { "User-Agent": "seo-mcp/1.0" },
});
const seo = {
status: res.status,
finalUrl: res.url,
title: "",
description: "",
canonical: "",
h1Count: 0,
};
const rewritten = new HTMLRewriter()
.on("title", {
text(chunk) {
seo.title += chunk.text;
},
})
.on('meta[name="description"]', {
element(el) {
seo.description = el.getAttribute("content") ?? "";
},
})
.on('link[rel="canonical"]', {
element(el) {
seo.canonical = el.getAttribute("href") ?? "";
},
})
.on("h1", {
element() {
seo.h1Count += 1;
},
})
.transform(res);
await rewritten.arrayBuffer();
return {
content: [
{
type: "text",
text: JSON.stringify({ ...seo, title: seo.title.trim() }, null, 2),
},
],
};
},
);
return server;
}
function isAuthorised(request: Request, secret?: string): boolean {
if (!secret) return false;
const encoder = new TextEncoder();
const given = encoder.encode(request.headers.get("Authorization") ?? "");
const expected = encoder.encode(`Bearer ${secret}`);
// Constant-time comparison, and only when the lengths match
return (
given.byteLength === expected.byteLength &&
crypto.subtle.timingSafeEqual(given, expected)
);
}
export default {
fetch(request, env, ctx) {
const { pathname } = new URL(request.url);
if (pathname === "/") {
return new Response("seo-mcp is running. The MCP endpoint is /mcp.");
}
// Let CORS preflight requests through, they carry no credentials
if (request.method !== "OPTIONS" && !isAuthorised(request, env.MCP_API_KEY)) {
return new Response("Unauthorized", { status: 401 });
}
return createMcpHandler(createServer)(request, env, ctx);
},
} satisfies ExportedHandler<Env>;A few choices to point out. The check fails closed: if MCP_API_KEY is missing, nothing gets through. The comparison uses crypto.subtle.timingSafeEqual, which is available in the Workers runtime and avoids leaking information through response timing. And the root path returns a friendly message, so if you open the Worker URL in a browser you can see it is alive.
If TypeScript complains that MCP_API_KEY does not exist on Env, run npm run cf-typegen again. Failing that, declare it yourself with an interface Env { MCP_API_KEY: string } in a .d.ts file.
Step 6: Run it locally and test with MCP Inspector
Start the dev server:
npm run devWrangler prints the local address (it is usually http://localhost:8787), so your MCP endpoint is http://localhost:8787/mcp.
Now, a warning that catches everyone once: opening /mcp in a browser will not show you anything useful. Cloudflare’s docs say the same. The endpoint expects MCP protocol messages from a client, and a browser does not send those. You need an MCP client, and the quickest one is the official MCP Inspector.
In a second terminal:
npx @modelcontextprotocol/inspector@latestThen, in the Inspector UI:
- Choose the Streamable HTTP transport.
- Enter
http://localhost:8787/mcpas the URL. - Add a custom header named
Authorizationwith the valueBearer paste-your-generated-key-here. (The Inspector’s layout changes between versions, so look for an Authentication or headers section.) - Click Connect, then List Tools.
You should see add and check_page_seo. Run add with a as 23 and b as 19 and you should get 42. Then run check_page_seo against https://example.com. You should get back a 200 status, the title “Example Domain”, one H1, and empty description and canonical fields, because that page has neither.
To prove the auth gate works, call the endpoint without a token:
curl -i -X POST http://localhost:8787/mcpYou should get a 401 Unauthorized. If you get anything else, stop and fix it before deploying.
Step 7: Deploy to Cloudflare Workers
Store the secret in Cloudflare (not in your repo), then deploy:
npx wrangler secret put MCP_API_KEY
npx wrangler deployPaste the same key you generated earlier when Wrangler prompts you. The first time, Wrangler may ask you to log in and may offer to create the Worker so it can store the secret. Say yes.
When the deploy finishes, your server lives at:
https://seo-mcp.your-subdomain.workers.dev/mcpRe-run the curl test against that URL to confirm the 401, then point the Inspector at the production URL (with your header) and list the tools again.
Step 8: Connect real MCP clients
The Inspector proves the server works. The real payoff is using it from the tools you work in every day.
Claude Desktop
Claude Desktop launches local processes, so the usual trick for remote servers is the mcp-remote proxy. Open Settings, then Developer, then Edit Config and add:
{
"mcpServers": {
"seo-mcp": {
"command": "npx",
"args": [
"mcp-remote",
"https://seo-mcp.your-subdomain.workers.dev/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer paste-your-generated-key-here"
}
}
}
}The odd-looking Authorization:${AUTH_HEADER} with no space after the colon is deliberate. The mcp-remote docs recommend it, because spaces inside arguments cause trouble on some systems, while spaces inside environment variable values are fine.
Restart Claude Desktop, then try a prompt like: “Use the check_page_seo tool on https://xahidex.com and tell me if anything looks off.”
Cursor
Cursor supports remote servers directly. Add this to .cursor/mcp.json in your project (or ~/.cursor/mcp.json globally):
{
"mcpServers": {
"seo-mcp": {
"url": "https://seo-mcp.your-subdomain.workers.dev/mcp",
"headers": {
"Authorization": "Bearer paste-your-generated-key-here"
}
}
}
}See Cursor’s MCP docs for the current options. Cloudflare also documents setups for Windsurf and its own AI Playground on the test a remote MCP server page.
When you need OAuth instead of a bearer token
A static bearer token is fine for you and a few teammates. It stops being fine when other people need to connect their own accounts, or when you want a hosted AI client to connect through its own “add a server” flow, because those clients generally expect either an open server or an OAuth login.
The MCP specification uses OAuth 2.1 for this. On Cloudflare the usual building block is workers-oauth-provider, which handles token issuing, client registration and access token validation. Cloudflare also documents integrations with providers such as GitHub, Google, Auth0, Stytch and WorkOS, and has a full securing MCP servers guide covering CSRF protection, consent dialogs, state handling and __Host- cookies.
Two honest cautions. OAuth is a bigger job than everything else in this guide, so do not reach for it until you need it. And Cloudflare’s own OAuth templates may still sit on the deprecated McpAgent path, so read the migration guide to MCP SDK v2 before copying one.
Once OAuth is in place, tools can find out who is calling with getMcpAuthContext(), imported from agents/mcp/server. Cloudflare’s docs warn never to log or return authInfo.token or its extra props.
Production checklist
Before you point real work at this server, run through these:
- Custom domain? The handler only trusts localhost and
workers.devhostnames by default. For your own domain, setallowedHostnames(and an explicit CORS origin if browsers will call it), for examplecreateMcpHandler(createServer, { allowedHostnames: ["mcp.example.com"] }). - Never treat CORS as security. It controls what browsers may read, nothing more. Authentication is your job.
- Keep secrets out of logs and responses. That includes tokens and anything derived from them.
- Validate everything. Zod handles the shape of the input. You still need to think about what the input means, as with the URL in
check_page_seo. - Limit what each tool can touch. Give a tool the narrowest permission that still does the job. A model that gets confused or manipulated can only misuse what you exposed.
- Rate limit. If the server can reach a paid API or a slow upstream, cap how often it can be called.
- Watch it live.
npx wrangler tailstreams logs from your deployed Worker while you test. - Only use a module-level handler when you need notifications. If you use
notifyorsubscriptions/listen, create the handler once at module scope. For ordinary tools, creating it insidefetch()as we did is fine. Note that notifications are local to a single Worker isolate.
Troubleshooting
The browser shows nothing at /mcp. Expected. Use the Inspector or another MCP client.
You get a 401 in the Inspector. The header is missing, misspelled, or the key does not match. Check for a stray space or an extra quote in .dev.vars.
Wrangler complains about the default export. You exported the handler function directly. Wrap it in an object with a fetch method.
Tools appear to share state between calls, or behave oddly. Check that you passed createServer (the function) and did not create a global server instance.
Type errors or runtime errors right after installing. Check that your @modelcontextprotocol/server version matches what your installed agents release requires.
A custom domain returns 403. Add it to allowedHostnames, as in the checklist above.
What I’d do next
If you want to take this further, these are the upgrades in rough order of value:
- Swap
check_page_seofor a tool that wraps something only you have access to: your own API, a database, your Semrush or Search Console data. That is where MCP stops being a demo and starts saving you time. - Add persistence with D1, KV or R2, keyed by an authenticated user rather than an MCP session.
- Move from a bearer token to OAuth once other people need access.
- Add tests around your tool handlers. They are plain async functions, so they are easy to test without a running server.
For the bigger picture, MCP Servers Explained covers what the protocol is and why it matters, Building AI-Powered Chatbots with Next.js and Vercel AI SDK shows the client side of tool calling, and my guide to AI agents in the UAE looks at where agentic AI is heading commercially.
Frequently asked questions
Do I need Durable Objects to build an MCP server on Cloudflare?
No. For new servers Cloudflare recommends createMcpHandler, which is stateless and does not need a Durable Object. You only need stateful storage if your tools genuinely have to remember things between calls, and even then Cloudflare suggests storing that data in a Durable Object, D1, KV or R2 behind an authenticated handle.
Is McpAgent still supported?
It still works, but Cloudflare’s docs describe it as deprecated and feature-frozen. If you have an existing McpAgent server, Cloudflare provides a staged migration guide to MCP SDK v2.
Can I use the same server from Claude Desktop, Cursor and the Inspector?
Yes. They all speak MCP, so one deployed server works with any of them. The only difference is how each client sends credentials, which is why the setup steps above differ slightly.
Why not use a local stdio server instead?
A local stdio server runs on one machine and is started by one client. A remote server on Workers is reachable from anywhere, can be shared with a team, and does not depend on your laptop being awake.
Can I connect this to a hosted AI app without writing OAuth?
Hosted clients usually expect an open server or an OAuth flow, and a static bearer token only works where the client lets you set custom headers. If you need hosted clients, plan for OAuth, or run a deliberately public, read-only server with no sensitive tools.
How do I add prompts and resources, not just tools?
The MCP SDK supports tools, prompts and resources. This guide stuck to tools because they are the most common starting point. The MCP documentation covers the other two, and the same factory pattern applies.
Sources used for this guide: Cloudflare’s documentation on the MCP handler APIs, remote MCP servers, testing remote servers and securing MCP servers, plus the mcp-remote README. APIs in this area are changing quickly, so check the linked docs if something here no longer matches.


