Skip to content
Back to guides

Guide

How to Build an MCP Server in TypeScript on Cloudflare Workers

13 min
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 add tool (the “hello world” of tools, so you can prove the plumbing works)
  • Exposes a check_page_seo tool 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 dev and in production on a workers.dev URL
  • 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:

ApproachStateful?Best for
createMcpHandler()NoNew stateless tools
createLegacyMcpHandler()OptionalTemporary bridge for existing session-based routes
McpAgentYesDeprecated Durable Object and RPC servers
Raw SDK transportDependsCustom 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 typescript

A 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-typegen

Learn 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:

  1. 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.
  2. Do not export the handler directly. Writing export default createMcpHandler(createServer) looks tidy, but Wrangler treats a function default export as a WorkerEntrypoint class. Wrap it in an object with a fetch method, as above.
  3. Zod describes your inputs. The inputSchema is a plain object of Zod validators. The model sees the shape (and your description text) 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 32

Create 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' >> .gitignore

Run 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 dev

Wrangler 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@latest

Then, in the Inspector UI:

  1. Choose the Streamable HTTP transport.
  2. Enter http://localhost:8787/mcp as the URL.
  3. Add a custom header named Authorization with the value Bearer paste-your-generated-key-here. (The Inspector’s layout changes between versions, so look for an Authentication or headers section.)
  4. 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/mcp

You 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 deploy

Paste 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/mcp

Re-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.dev hostnames by default. For your own domain, set allowedHostnames (and an explicit CORS origin if browsers will call it), for example createMcpHandler(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 tail streams logs from your deployed Worker while you test.
  • Only use a module-level handler when you need notifications. If you use notify or subscriptions/listen, create the handler once at module scope. For ordinary tools, creating it inside fetch() 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:

  1. Swap check_page_seo for 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.
  2. Add persistence with D1, KV or R2, keyed by an authenticated user rather than an MCP session.
  3. Move from a bearer token to OAuth once other people need access.
  4. 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.