Back to Blog
On This Page8 sections

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

ToolPurposePermission
list_entriesReturn a paginated catalog directly from the databaseRead
list_blog_tagsReturn allowed tag identifiersRead
import_imageValidate and copy an image into controlled storageWrite
create_entry_draftValidate and create an unpublished entryWrite
create_blog_draftValidate and create an unpublished articleWrite

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. 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 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

SymptomLikely causeFix
/mcp returns the SPA or a 404 pageStatic assets ran before the WorkerAdd /mcp, /oauth/*, and /.well-known/* to run_worker_first
A new tutorial starts with McpAgentIt targets the legacy server pathUse createMcpHandler and @modelcontextprotocol/server for a new stateless service
Local tests pass, deploy fails with ZodLazy is not a constructorSchema-library/package skew in the Worker bundlePin a tested Zod version and upgrade the MCP package set together
Server instructions are ignoredThey were placed in the identity objectPass { instructions } as the second McpServer constructor argument
Setting a cookie on a redirect throwsResponse.redirect() headers are immutableBuild a new Response(null, { status: 302, headers })
The client never reaches the consent pageOAuth metadata or the 401 challenge is missingTest protected-resource and authorization-server discovery independently
Any signed-in user can create contentAuthentication was mistaken for authorizationEnforce role and scope in consent and in each write handler
A temporary Markdown link keeps expiringA short-lived bearer key was embedded in a documentUse OAuth access and refresh tokens; never place credentials in a URL
Image import can reach internal servicesOnly the initial URL was checkedRevalidate every redirect and block non-public destinations
A write disappears after the responseA Promise was left floatingAwait 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 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.

Share this article

Referenced Tools

Browse entries that are adjacent to the topics covered in this article.

Explore directory