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


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.
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.
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 / CDNThere are three important boundaries in this design:
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.
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.”
This tested combination uses the stateless v2 server package:
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.0Why 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.
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.
{
"$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:
bunx wrangler typesThe 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.
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.
/mcpRemote 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.
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:
env.OAUTH_PROVIDER.parseAuthRequest(request).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.
An MCP tool should give the model enough context to finish a task without leaking unrelated data.
For catalog reads:
For draft creation:
ai and the workflow state to draft.For image import:
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.
| 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:
const response = Response.redirect(location, 302);
response.headers.set("Set-Cookie", cookie); // immutable headersConstruct the redirect with the final headers instead:
return new Response(null, {
status: 302,
headers: {
Location: String(location),
"Set-Cookie": cookie,
},
});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.
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 deployEnable 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.
A successful deploy is not proof that an MCP client can connect. Test the discovery chain and the protected endpoint separately.
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-serverThen connect a real MCP client and complete the full flow:
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.
Keep one transport and add capabilities as small domain modules. For example:
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.
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.
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.
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.
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.
createMcpHandler for a new server./mcp, OAuth, and well-known metadata ahead of static assets.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.
More articles connected to the same themes, protocols, and tools.



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