# How to Build an MCP Server for Your Website on Cloudflare Workers

Build a secure MCP server for your website on Cloudflare Workers with stateless handlers, OAuth 2.1, direct data tools, image uploads, and deployment checks.

Canonical URL: https://aiidelist.com/blog/cloudflare-workers-website-mcp-server-guide

Language: en

Published: 2026-09-06

Updated: 2026-09-06

A website usually already has the data and actions an AI assistant needs: a database, an authentication system, an admin area, object storage, and business rules. A remote Model Context Protocol (MCP) server turns those existing capabilities into a small, typed tool surface that ChatGPT, Claude, coding agents, and other MCP clients can use with the owner's permission.

This guide shows a production-minded way to add that surface with Cloudflare Workers. The implementation is deliberately generic: every hostname, resource identifier, table name, and permission is an example. The goal is to expose useful website operations without exposing a private API key, copying an internal schema into a prompt, or asking an AI model to crawl the public site.

The stack and package combination shown here was tested on September 2, 2026. Cloudflare's MCP APIs are evolving quickly, so check the linked official documentation before upgrading packages.

## The architecture to aim for

For a new remote MCP service, use the stateless Streamable HTTP path. Cloudflare's current [MCP handler API](https://developers.cloudflare.com/agents/model-context-protocol/apis/handler-api/) recommends `createMcpHandler` from `agents/mcp/server` with `@modelcontextprotocol/server`. The older `McpAgent` path is deprecated and feature-frozen for new servers.

```text
MCP client
    |
    | 1. POST /mcp
    v
Cloudflare Worker ---- 401 + WWW-Authenticate ----> OAuth discovery
    |                                                   |
    |                                                   v
    |<--------- user login + explicit consent ----------+
    |
    | 2. Bearer token with a narrow scope
    v
Stateless MCP handler
    |
    +---- read tools ----> D1 / existing application services
    |
    +---- draft tools ---> validation ---> D1
    |
    +---- image import --> URL checks ---> R2 / CDN
```

There are three important boundaries in this design:

1. OAuth proves which user approved the connection.
2. Server-side authorization decides which tools that user may call.
3. Business validation decides what each tool is allowed to change.

Do not collapse these boundaries into one prompt instruction. A model can ignore a prompt; it cannot bypass a server that refuses to publish, overwrite, or access a resource outside the approved scope.

## Step 1: Define the smallest useful tool surface

Do not wrap every internal API route as an MCP tool. Large tool catalogs consume context, confuse tool selection, and reproduce implementation details that should remain private. Start with a few outcome-oriented operations.

For a content website, a safe first version might contain:

| Tool | Purpose | Permission |
|---|---|---|
| `list_entries` | Return a paginated catalog directly from the database | Read |
| `list_blog_tags` | Return allowed tag identifiers | Read |
| `import_image` | Validate and copy an image into controlled storage | Write |
| `create_entry_draft` | Validate and create an unpublished entry | Write |
| `create_blog_draft` | Validate and create an unpublished article | Write |

This is better than providing a sitemap and telling the model to crawl it. The database tool is complete, structured, paginated, and permission-aware. It can include unpublished records for duplicate checking without making those records public.

Write tools should be narrow by construction. A `create_blog_draft` tool can always force `published = false`, reject duplicate slugs, validate required SEO fields, and return an editor URL. There is no need for an intermediate “AI report” table when the desired human workflow is simply “AI creates a draft; editor reviews it.”

## Step 2: Install a compatible package set

This tested combination uses the stateless v2 server package:

```bash
bun add agents@0.22.0   @modelcontextprotocol/server@2.0.0   @cloudflare/workers-oauth-provider@0.10.3   zod@4.2.1

bun add -D wrangler@4.111.0
```

Why pin versions? MCP packages, schema libraries, and the Workers bundler move independently. In one real deployment, leaving Zod on a newer release allowed local TypeScript and tests to pass but made Cloudflare's deployment validation fail with `ZodLazy is not a constructor`. Pinning Zod 4.2.1 alongside the packages above removed that failure.

Treat this as a known-good set, not a permanent recommendation. Upgrade the group together, run a dry deployment, and only then change the production lockfile. Cloudflare explicitly advises using the MCP package version required by the installed Agents release.

## Step 3: Configure Worker-first routes and bindings

An MCP endpoint can live in the same Worker as an existing full-stack site. If that Worker also serves static assets or an SPA, make the MCP and OAuth paths Worker-first. Otherwise the asset layer may return an HTML fallback before your OAuth or MCP code runs.

```jsonc
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "website-worker",
  "main": "worker.ts",
  "compatibility_date": "2026-09-02",
  "compatibility_flags": [
    "nodejs_compat",
    "global_fetch_strictly_public"
  ],
  "assets": {
    "directory": "./dist/client",
    "binding": "ASSETS",
    "run_worker_first": [
      "/mcp",
      "/oauth/*",
      "/.well-known/*"
    ]
  },
  "kv_namespaces": [
    { "binding": "OAUTH_KV", "id": "<OAUTH_KV_NAMESPACE_ID>" }
  ],
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "<DATABASE_NAME>",
      "database_id": "<DATABASE_ID>"
    }
  ],
  "r2_buckets": [
    { "binding": "MEDIA", "bucket_name": "<BUCKET_NAME>" }
  ],
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  }
}
```

The selective `run_worker_first` array is supported by [Workers Static Assets routing](https://developers.cloudflare.com/workers/static-assets/). It avoids charging every asset request for Worker execution while protecting the protocol routes from an SPA fallback.

Use a dedicated KV namespace for OAuth state and tokens. D1 should hold application data, while R2 should hold binary media. Inside the Worker, access these services through bindings rather than calling Cloudflare's REST API with an account token.

After every binding change, regenerate the environment types:

```bash
bunx wrangler types
```

## Step 4: Build a stateless MCP server factory

The stateless handler creates a fresh server for every request. Pass a factory to `createMcpHandler`; do not construct one global `McpServer` instance and reuse it across requests.

```ts
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";

type Grant = {
  userId: string;
  role: "admin" | "editor";
  canWriteDrafts: true;
};

function createWebsiteServer(env: Env, grant: Grant) {
  const server = new McpServer(
    { name: "Website Draft Tools", version: "1.0.0" },
    {
      instructions:
        "Read the catalog with tools, never by crawling the site. " +
        "Create unpublished drafts only and never overwrite existing content.",
    },
  );

  server.registerTool(
    "list_entries",
    {
      description: "List catalog records for duplicate checks.",
      inputSchema: {
        query: z.string().max(200).optional(),
        cursor: z.string().optional(),
        limit: z.number().int().min(1).max(200).default(100),
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async ({ query, cursor, limit }) => {
      const page = await catalogService(env.DB).list({ query, cursor, limit });
      return {
        content: [{ type: "text", text: JSON.stringify(page) }],
      };
    },
  );

  server.registerTool(
    "create_blog_draft",
    {
      description: "Validate and create one unpublished article draft.",
      inputSchema: {
        title: z.string().min(1).max(240),
        slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
        contentMd: z.string().min(1000),
        seoTitle: z.string().max(70),
        seoDescription: z.string().min(80).max(160),
        featuredImage: z.string().url(),
      },
      annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    async (candidate) => {
      if (!grant.canWriteDrafts) throw new Error("Write access denied");

      const result = await draftService(env.DB).createBlog({
        ...candidate,
        source: "ai",
        published: false,
        contentStatus: "draft",
        createdBy: grant.userId,
        rejectDuplicates: true,
      });

      return {
        content: [{ type: "text", text: JSON.stringify(result) }],
      };
    },
  );

  return server;
}
```

The service calls above are placeholders for your existing application layer. Keeping database queries and business rules in reusable services prevents the MCP transport from becoming a second, inconsistent backend.

One subtle v2 detail: server instructions belong in the second constructor argument, as shown above. Putting `instructions` inside the identity object may compile through loose types in some setups but will not configure the server as intended.

## Step 5: Put OAuth in front of `/mcp`

Remote MCP clients discover and use OAuth. The current [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) requires protected-resource metadata and an authorization-server discovery mechanism. A protected `/mcp` response should challenge the client with `401 Unauthorized` and a `WWW-Authenticate` header pointing to resource metadata.

Cloudflare's Workers OAuth Provider handles token issuance, refresh, client registration, metadata, and access-token validation. Your application still owns the login and consent experience.

```ts
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";
import { WorkerEntrypoint } from "cloudflare:workers";
import { createMcpHandler } from "agents/mcp/server";

class WebsiteMcpHandler extends WorkerEntrypoint<Env, Grant> {
  async fetch(request: Request) {
    const grant = this.ctx.props;
    const handler = createMcpHandler(
      () => createWebsiteServer(this.env, grant),
      {
        route: "/mcp",
        authContext: { props: grant },
        allowedHostnames: ["example.com", "www.example.com"],
        allowedOriginHostnames: [
          "example.com",
          "chatgpt.com",
          "claude.ai",
        ],
        legacy: "stateless",
        onerror: (error) => {
          console.error(JSON.stringify({
            event: "mcp_request_failed",
            message: error.message,
          }));
        },
      },
    );

    return handler(request, this.env, this.ctx);
  }
}

export default new OAuthProvider<Env>({
  apiRoute: "/mcp",
  apiHandler: WebsiteMcpHandler,
  defaultHandler: siteAndConsentHandler,
  authorizeEndpoint: "/oauth/authorize",
  tokenEndpoint: "/oauth/token",
  clientRegistrationEndpoint: "/oauth/register",
  clientIdMetadataDocumentEnabled: true,
  scopesSupported: ["drafts:write"],
  accessTokenTTL: 60 * 60,
  refreshTokenTTL: 30 * 24 * 60 * 60,
  resourceMetadata: {
    resource: "https://example.com/mcp",
    authorization_servers: ["https://example.com"],
    scopes_supported: ["drafts:write"],
    bearer_methods_supported: ["header"],
    resource_name: "Website Draft Tools",
  },
});
```

`siteAndConsentHandler` should do four things:

1. Pass normal website requests to the existing application.
2. Parse OAuth authorization requests through `env.OAUTH_PROVIDER.parseAuthRequest(request)`.
3. Require an active site session with an allowed role, then show the client name, requested scopes, and an explicit Allow/Deny form.
4. On approval, call `completeAuthorization` with narrow scopes and serializable user props for the MCP handler.

Protect the form with a one-time CSRF token in an `HttpOnly; Secure; SameSite=Lax` cookie. Validate the request origin, escape all client-controlled HTML, apply a restrictive Content Security Policy, and return `Cache-Control: no-store`.

Do not treat a valid login as sufficient authorization. A normal member should not gain admin tools simply because a session cookie exists. Check the role both during consent and again in every write tool.

## Step 6: Design data and image tools for agents

An MCP tool should give the model enough context to finish a task without leaking unrelated data.

For catalog reads:

- Return stable identifiers, canonical URLs, status, aliases, and pagination metadata.
- Accept a query for duplicate checks, but also support listing every record page by page.
- Read D1 through the Worker binding and prepared statements.
- Never require the model to scrape your public pages to reconstruct the database.

For draft creation:

- Validate the full candidate with Zod and again in the service layer.
- Reject a matching slug, canonical URL, or normalized name.
- Force the source to `ai` and the workflow state to `draft`.
- Never expose a generic update or publish switch in the first version.
- Return the draft ID and an admin edit URL so the human can review immediately.

For image import:

- Accept only public HTTPS URLs without embedded credentials.
- Reject localhost, private, link-local, and reserved IP ranges after every redirect.
- Allowlist image MIME types and verify the file signature, not just the header.
- Enforce separate size limits for logos and article covers.
- Generate the R2 key server-side and set immutable cache metadata.
- Save the resulting CDN URL in the draft; never hotlink the source.

The `global_fetch_strictly_public` compatibility flag is useful hardening for outbound requests because it sends global `fetch()` through the public Internet path. It does not replace URL, redirect, MIME, signature, and size validation.

## The problems most likely to waste your time

| Symptom | Likely cause | Fix |
|---|---|---|
| `/mcp` returns the SPA or a 404 page | Static assets ran before the Worker | Add `/mcp`, `/oauth/*`, and `/.well-known/*` to `run_worker_first` |
| A new tutorial starts with `McpAgent` | It targets the legacy server path | Use `createMcpHandler` and `@modelcontextprotocol/server` for a new stateless service |
| Local tests pass, deploy fails with `ZodLazy is not a constructor` | Schema-library/package skew in the Worker bundle | Pin a tested Zod version and upgrade the MCP package set together |
| Server instructions are ignored | They were placed in the identity object | Pass `{ instructions }` as the second `McpServer` constructor argument |
| Setting a cookie on a redirect throws | `Response.redirect()` headers are immutable | Build a new `Response(null, { status: 302, headers })` |
| The client never reaches the consent page | OAuth metadata or the 401 challenge is missing | Test protected-resource and authorization-server discovery independently |
| Any signed-in user can create content | Authentication was mistaken for authorization | Enforce role and scope in consent and in each write handler |
| A temporary Markdown link keeps expiring | A short-lived bearer key was embedded in a document | Use OAuth access and refresh tokens; never place credentials in a URL |
| Image import can reach internal services | Only the initial URL was checked | Revalidate every redirect and block non-public destinations |
| A write disappears after the response | A Promise was left floating | Await it or pass genuine post-response work to `ctx.waitUntil()` |

There is another easy-to-miss redirect issue. This will fail in Workers:

```ts
const response = Response.redirect(location, 302);
response.headers.set("Set-Cookie", cookie); // immutable headers
```

Construct the redirect with the final headers instead:

```ts
return new Response(null, {
  status: 302,
  headers: {
    Location: String(location),
    "Set-Cookie": cookie,
  },
});
```

## Deploy in an order that can be rolled back

Keep schema changes additive. Apply a migration that the old Worker can tolerate, then deploy the new code. For destructive changes, use an expand-migrate-contract sequence across multiple releases.

```bash
bunx wrangler types --check
bun run typecheck
bun test
bun run build

bunx wrangler deploy --dry-run

bunx wrangler d1 migrations apply <DATABASE_NAME> --remote

bunx wrangler deploy
```

Enable structured logs before launch. Log event names, status, duration, tool name, and a request identifier, but never log bearer tokens, authorization codes, cookies, full prompts, or sensitive tool arguments. Cloudflare's current [Workers best practices](https://developers.cloudflare.com/workers/best-practices/workers-best-practices/) recommend searchable structured logs and explicit observability configuration.

## Verify the protocol, not just the homepage

A successful deploy is not proof that an MCP client can connect. Test the discovery chain and the protected endpoint separately.

```bash
curl -i https://example.com/mcp

curl -i https://example.com/.well-known/oauth-protected-resource/mcp

curl -i https://example.com/.well-known/oauth-authorization-server
```

Then connect a real MCP client and complete the full flow:

1. The client discovers OAuth from the 401 response.
2. The browser opens the consent page.
3. An authorized editor approves a narrow scope.
4. The client lists tools with the access token.
5. A read tool returns paginated data.
6. A write tool creates an unpublished draft.
7. Repeating the same request does not create an accidental duplicate.

Also test denial, expired access tokens, refresh, a normal non-editor account, an invalid origin, a duplicate draft, an oversized image, and a redirect to a private address. These negative cases are where most authorization and SSRF bugs hide.

## How to extend the service later

Keep one transport and add capabilities as small domain modules. For example:

```text
MCP transport + OAuth
    |
    +-- catalog tools       (read)
    +-- content drafts      (write:drafts)
    +-- media import        (write:media)
    +-- analytics summaries (read:analytics)
    +-- maintenance jobs    (run:jobs)
```

Each module should define its schemas, required scopes, service calls, rate limits, and tests. Add a new scope when the risk changes, not merely when a new tool is added. High-impact actions such as publishing, deleting, paying, or contacting users should remain separate and require explicit human approval.

If a job becomes long-running or retriable, let the tool enqueue a Cloudflare Queue message or start a Workflow and return a job ID. Keep ordinary draft writes synchronous so the model receives a definite result and the editor can open the draft immediately.

## Frequently asked questions

### Does an MCP server need a Durable Object?

Not for ordinary stateless tools. `createMcpHandler` creates a server per request, while durable application data can stay in D1, KV, R2, or your existing backend. Use a Durable Object only when the application genuinely needs coordinated state, not simply because older MCP examples used `McpAgent`.

### Can I protect MCP with one permanent API key?

Technically, some clients can send a bearer header, but it is a poor default for an interactive remote service. OAuth gives each client an explicit grant, narrow scopes, short-lived access tokens, refresh, revocation, and a consent trail. It also avoids expiring Markdown files that contain credentials.

### Should the AI receive every database field?

No. Return only the fields needed for the task and duplicate detection. Keep private notes, user data, secrets, internal moderation fields, and unrelated unpublished content out of tool responses unless a specific authorized workflow requires them.

### Can the AI publish directly after creating a draft?

It can be built, but it should not be the first version. Draft-only tools dramatically reduce the cost of a mistaken tool call. Add publishing later as a separate scope and tool with an explicit human confirmation step and a complete audit record.

## Final checklist

- Use stateless Streamable HTTP with `createMcpHandler` for a new server.
- Put `/mcp`, OAuth, and well-known metadata ahead of static assets.
- Use OAuth discovery, explicit consent, narrow scopes, and refresh tokens.
- Enforce authorization and draft-only behavior on the server.
- Give the model structured database tools instead of asking it to crawl the site.
- Import images through a validated server-side pipeline and controlled CDN.
- Pin a tested MCP, Agents, OAuth, and Zod package set.
- Run types, tests, build, dry deployment, migrations, and live protocol checks.
- Log operational metadata, never credentials or private content.
- Extend by adding small scoped tool modules, not by exposing the entire internal API.

A useful website MCP server is not a public mirror of your backend. It is a deliberately small operating surface: enough structured context for an AI assistant to work, enough server-side policy to keep mistakes reversible, and enough OAuth plumbing for a human to remain in control.
