Build MCP servers on Cloudflare Workers - the only platform with official remote MCP support. TypeScript-based with OAuth, Durable Objects, and WebSocket hibernation. Prevents 22 documented errors. Use when: deploying remote MCP servers, implementing OAuth, or troubleshooting URL path mismatches, McpAgent exports, CORS issues.
/plugin marketplace add jezweb/claude-skills/plugin install jezweb-tooling-skills@jezweb/claude-skillsThis skill is limited to using the following tools:
README.mdcommands/init.mdreferences/authentication.mdreferences/common-issues.mdreferences/debugging-guide.mdreferences/http-transport-fundamentals.mdreferences/oauth-providers.mdreferences/official-examples.mdreferences/transport-comparison.mdreferences/transport.mdrules/cloudflare-mcp-server.mdtemplates/basic-mcp-server.tstemplates/claude_desktop_config.jsontemplates/mcp-bearer-auth.tstemplates/mcp-http-fundamentals.tstemplates/mcp-oauth-proxy.tstemplates/mcp-stateful-do.tstemplates/mcp-with-d1.tstemplates/mcp-with-workers-ai.tstemplates/package.jsonBuild and deploy Model Context Protocol (MCP) servers on Cloudflare Workers with TypeScript.
Status: Production Ready ✅ Last Updated: 2026-01-09 Latest Versions: @modelcontextprotocol/sdk@1.25.2, @cloudflare/workers-oauth-provider@0.2.2, agents@0.3.3
Recent Updates (2025):
This skill teaches you to build remote MCP servers on Cloudflare - the ONLY platform with official remote MCP support.
Use when: Avoiding 22+ common MCP + Cloudflare errors (especially URL path mismatches - the #1 failure cause)
Start with Cloudflare's official template:
npm create cloudflare@latest -- my-mcp-server \
--template=cloudflare/ai/demos/remote-mcp-authless
cd my-mcp-server && npm install && npm run dev
Choose template based on auth needs:
remote-mcp-authless - No auth (recommended for most)remote-mcp-github-oauth - GitHub OAuthremote-mcp-google-oauth - Google OAuthremote-mcp-auth0 / remote-mcp-authkit - Enterprise SSOmcp-server-bearer-auth - Custom authAll templates: https://github.com/cloudflare/ai/tree/main/demos
Production examples: https://github.com/cloudflare/mcp-server-cloudflare (15 servers with real integrations)
# 1. Create from template
npm create cloudflare@latest -- my-mcp --template=cloudflare/ai/demos/remote-mcp-authless
cd my-mcp && npm install && npm run dev
# 2. Deploy
npx wrangler deploy
# Note the output URL: https://my-mcp.YOUR_ACCOUNT.workers.dev
# 3. Test (PREVENTS 80% OF ERRORS!)
curl https://my-mcp.YOUR_ACCOUNT.workers.dev/sse
# Expected: {"name":"My MCP Server","version":"1.0.0","transports":["/sse","/mcp"]}
# Got 404? See "HTTP Transport Fundamentals" below
# 4. Configure client (~/.config/claude/claude_desktop_config.json)
{
"mcpServers": {
"my-mcp": {
"url": "https://my-mcp.YOUR_ACCOUNT.workers.dev/sse" // Must match curl URL!
}
}
}
# 5. Restart Claude Desktop (config only loads at startup)
Post-Deployment Checklist:
The #1 reason MCP servers fail to connect is URL path configuration mistakes.
When you serve an MCP server at a specific path, the client URL must match exactly.
Example 1: Serving at /sse
// src/index.ts
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const { pathname } = new URL(request.url);
if (pathname.startsWith("/sse")) {
return MyMCP.serveSSE("/sse").fetch(request, env, ctx); // ← Base path is "/sse"
}
return new Response("Not Found", { status: 404 });
}
};
Client configuration MUST include /sse:
{
"mcpServers": {
"my-mcp": {
"url": "https://my-mcp.workers.dev/sse" // ✅ Correct
}
}
}
❌ WRONG client configurations:
"url": "https://my-mcp.workers.dev" // Missing /sse → 404
"url": "https://my-mcp.workers.dev/" // Missing /sse → 404
"url": "http://localhost:8788" // Wrong after deploy
Example 2: Serving at / (root)
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
return MyMCP.serveSSE("/").fetch(request, env, ctx); // ← Base path is "/"
}
};
Client configuration:
{
"mcpServers": {
"my-mcp": {
"url": "https://my-mcp.workers.dev" // ✅ Correct (no /sse)
}
}
}
When you call serveSSE("/sse"), MCP tools are served at:
https://my-mcp.workers.dev/sse/tools/list
https://my-mcp.workers.dev/sse/tools/call
https://my-mcp.workers.dev/sse/resources/list
When you call serveSSE("/"), MCP tools are served at:
https://my-mcp.workers.dev/tools/list
https://my-mcp.workers.dev/tools/call
https://my-mcp.workers.dev/resources/list
The base path is prepended to all MCP endpoints automatically.
1. Client connects to: https://my-mcp.workers.dev/sse
↓
2. Worker receives request: { url: "https://my-mcp.workers.dev/sse", ... }
↓
3. Your fetch handler: const { pathname } = new URL(request.url)
↓
4. pathname === "/sse" → Check passes
↓
5. MyMCP.serveSSE("/sse").fetch() → MCP server handles request
↓
6. Tool calls routed to: /sse/tools/call
If client connects to https://my-mcp.workers.dev (missing /sse):
pathname === "/" → Check fails → 404 Not Found
Step 1: Deploy your MCP server
npx wrangler deploy
# Output: Deployed to https://my-mcp.YOUR_ACCOUNT.workers.dev
Step 2: Test the base path with curl
# If serving at /sse, test this URL:
curl https://my-mcp.YOUR_ACCOUNT.workers.dev/sse
# Should return MCP server info (not 404)
Step 3: Update client config with the EXACT URL you tested
{
"mcpServers": {
"my-mcp": {
"url": "https://my-mcp.YOUR_ACCOUNT.workers.dev/sse" // Match curl URL
}
}
}
Step 4: Restart Claude Desktop
After deploying, verify:
curl https://worker.dev/sse returns MCP server info (not 404)workes.dev instead of workers.dev)https:// (not http://) for deployed WorkersTwo transports available:
SSE (Server-Sent Events) - Legacy, wide compatibility
MyMCP.serveSSE("/sse").fetch(request, env, ctx)
Streamable HTTP - 2025 standard (recommended), single endpoint
MyMCP.serve("/mcp").fetch(request, env, ctx)
Support both for maximum compatibility:
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const { pathname } = new URL(request.url);
if (pathname.startsWith("/sse")) {
return MyMCP.serveSSE("/sse").fetch(request, env, ctx);
}
if (pathname.startsWith("/mcp")) {
return MyMCP.serve("/mcp").fetch(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
}
};
CRITICAL: Use pathname.startsWith() to match paths correctly!
MCP servers can now request user input during tool execution:
// Request user input during tool execution
const result = await this.elicit({
prompt: "Enter your API key:",
type: "password"
});
// Interactive workflows with Durable Objects state
await this.state.storage.put("api_key", result);
Use cases: Confirmations, forms, multi-step workflows State: Preserved during agent hibernation
Agents SDK converts MCP schema → TypeScript API:
// Old: Direct tool calls
await server.callTool("get_user", { id: "123" });
// New: Type-safe generated API
const user = await api.getUser("123");
Benefits: Auto-generated doc comments, type safety, code completion
New class for MCP client capabilities:
import { MCPClientManager } from "agents/mcp";
const manager = new MCPClientManager(env);
await manager.connect("https://external-mcp.com/sse");
// Auto-discovers tools, resources, prompts
// Handles reconnection, OAuth flow, hibernation
// Task queues for background jobs
await this.queue.send({ task: "process_data", data });
// Email integration
async onEmail(message: Email) {
// Process incoming email
const response = await this.generateReply(message);
await this.sendEmail(response);
}
Single endpoint replaces separate connection/messaging endpoints:
// Old: Separate endpoints
/connect // Initialize connection
/message // Send/receive messages
// New: Single streamable endpoint
/mcp // All communication via HTTP streaming
Benefit: Simplified architecture, better performance
Choose auth based on use case:
No Auth - Internal tools, dev (Template: remote-mcp-authless)
Bearer Token - Custom auth (Template: mcp-server-bearer-auth)
// Validate Authorization: Bearer <token>
const token = request.headers.get("Authorization")?.replace("Bearer ", "");
if (!await validateToken(token, env)) {
return new Response("Unauthorized", { status: 401 });
}
OAuth Proxy - GitHub/Google (Template: remote-mcp-github-oauth)
import { OAuthProvider, GitHubHandler } from "@cloudflare/workers-oauth-provider";
export default new OAuthProvider({
authorizeEndpoint: "/authorize",
tokenEndpoint: "/token",
defaultHandler: new GitHubHandler({
clientId: (env) => env.GITHUB_CLIENT_ID,
clientSecret: (env) => env.GITHUB_CLIENT_SECRET,
scopes: ["repo", "user:email"]
}),
kv: (env) => env.OAUTH_KV,
apiHandlers: { "/sse": MyMCP.serveSSE("/sse") }
});
⚠️ CRITICAL: All OAuth URLs (url, authorizationUrl, tokenUrl) must use same domain
Remote OAuth with DCR - Full OAuth provider (Template: remote-mcp-authkit)
Security levels: No Auth (⚠️) < Bearer (✅) < OAuth Proxy (✅✅) < Remote OAuth (✅✅✅)
McpAgent extends Durable Objects for per-session state:
// Storage API
await this.state.storage.put("key", "value");
const value = await this.state.storage.get<string>("key");
// Required wrangler.jsonc
{
"durable_objects": {
"bindings": [{ "name": "MY_MCP", "class_name": "MyMCP" }]
},
"migrations": [{ "tag": "v1", "new_classes": ["MyMCP"] }] // Required on first deploy!
}
Critical: Migrations required on first deployment
Cost: Durable Objects now included in free tier (2025)
async getCached<T>(key: string, ttlMs: number, fetchFn: () => Promise<T>): Promise<T> {
const cached = await this.state.storage.get<{ data: T, timestamp: number }>(key);
if (cached && Date.now() - cached.timestamp < ttlMs) {
return cached.data;
}
const data = await fetchFn();
await this.state.storage.put(key, { data, timestamp: Date.now() });
return data;
}
async rateLimit(key: string, maxRequests: number, windowMs: number): Promise<boolean> {
const requests = await this.state.storage.get<number[]>(`ratelimit:${key}`) || [];
const recentRequests = requests.filter(ts => Date.now() - ts < windowMs);
if (recentRequests.length >= maxRequests) return false;
recentRequests.push(Date.now());
await this.state.storage.put(`ratelimit:${key}`, recentRequests);
return true;
}
Error: TypeError: Cannot read properties of undefined (reading 'serve')
Cause: Forgot to export McpAgent class
Solution:
export class MyMCP extends McpAgent { ... } // ✅ Must export
export default { fetch() { ... } }
Error: 404 Not Found or Connection failed
Cause: serveSSE("/sse") but client configured with https://worker.dev (missing /sse)
Solution: Match base paths exactly
// Server serves at /sse
MyMCP.serveSSE("/sse").fetch(...)
// Client MUST include /sse
{ "url": "https://worker.dev/sse" } // ✅ Correct
{ "url": "https://worker.dev" } // ❌ Wrong - 404
Debug steps:
serveSSE("/sse") vs serveSSE("/")curl https://worker.dev/sseError: Connection failed: Unexpected response format
Cause: Client expects SSE but connects to HTTP endpoint (or vice versa)
Solution: Match transport types
// SSE transport
MyMCP.serveSSE("/sse") // Client URL: https://worker.dev/sse
// HTTP transport
MyMCP.serve("/mcp") // Client URL: https://worker.dev/mcp
Best practice: Support both transports (see Transport Selection Guide)
Error: Both /sse and /mcp routes fail or conflict
Cause: Incorrect path matching logic
Solution: Use startsWith() correctly
// ✅ CORRECT
if (pathname.startsWith("/sse")) {
return MyMCP.serveSSE("/sse").fetch(...);
}
if (pathname.startsWith("/mcp")) {
return MyMCP.serve("/mcp").fetch(...);
}
// ❌ WRONG: Exact match breaks sub-paths
if (pathname === "/sse") { // Breaks /sse/tools/list
return MyMCP.serveSSE("/sse").fetch(...);
}
Error: Works in dev, fails after deployment
Cause: Client still configured with localhost URL
Solution: Update client config after deployment
// Development
{ "url": "http://localhost:8788/sse" }
// ⚠️ MUST UPDATE after npx wrangler deploy
{ "url": "https://my-mcp.YOUR_ACCOUNT.workers.dev/sse" }
Post-deployment checklist:
npx wrangler deploy and note output URLError: OAuth error: redirect_uri does not match
Cause: OAuth redirect URI doesn't match deployed URL
Solution: Update ALL OAuth URLs after deployment
{
"url": "https://my-mcp.YOUR_ACCOUNT.workers.dev/sse",
"auth": {
"type": "oauth",
"authorizationUrl": "https://my-mcp.YOUR_ACCOUNT.workers.dev/authorize", // Must match deployed domain
"tokenUrl": "https://my-mcp.YOUR_ACCOUNT.workers.dev/token"
}
}
CRITICAL: All URLs must use the same protocol and domain!
Error: Access to fetch at '...' blocked by CORS policy or Method Not Allowed
Cause: Browser clients send OPTIONS requests for CORS preflight, but server doesn't handle them
Solution: Add OPTIONS handler
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400"
}
});
}
// ... rest of your fetch handler
}
};
When needed: Browser-based MCP clients (like MCP Inspector in browser)
Error: TypeError: Cannot read properties of undefined or Unexpected token in JSON parsing
Cause: Client sends malformed JSON, server doesn't validate before parsing
Solution: Wrap request handling in try/catch
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
try {
// Your MCP server logic
return await MyMCP.serveSSE("/sse").fetch(request, env, ctx);
} catch (error) {
console.error("Request handling error:", error);
return new Response(
JSON.stringify({
error: "Invalid request",
details: error.message
}),
{
status: 400,
headers: { "Content-Type": "application/json" }
}
);
}
}
};
Error: TypeError: env.API_KEY is undefined or silent failures (tools return empty data)
Cause: Required environment variables not configured or missing at runtime
Solution: Add startup validation
export class MyMCP extends McpAgent<Env> {
async init() {
// Validate required environment variables
if (!this.env.API_KEY) {
throw new Error("API_KEY environment variable not configured");
}
if (!this.env.DATABASE_URL) {
throw new Error("DATABASE_URL environment variable not configured");
}
// Continue with tool registration
this.server.tool(...);
}
}
Configuration checklist:
.dev.vars (local only, gitignored)wrangler.jsonc vars (public) or use wrangler secret (sensitive)Best practices:
# .dev.vars (local development, gitignored)
API_KEY=dev-key-123
DATABASE_URL=http://localhost:3000
# wrangler.jsonc (public config)
{
"vars": {
"ENVIRONMENT": "production",
"LOG_LEVEL": "info"
}
}
# wrangler secret (production secrets)
npx wrangler secret put API_KEY
npx wrangler secret put DATABASE_URL
Error: TypeError: server.registerTool is not a function or this.server is undefined
Cause: Trying to use standalone SDK patterns with McpAgent class
Solution: Use McpAgent's this.server.tool() pattern
// ❌ WRONG: Mixing standalone SDK with McpAgent
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
const server = new McpServer({ name: "My Server" });
server.registerTool(...); // Not compatible with McpAgent!
export class MyMCP extends McpAgent { /* no server property */ }
// ✅ CORRECT: McpAgent pattern
export class MyMCP extends McpAgent<Env> {
server = new McpServer({
name: "My MCP Server",
version: "1.0.0"
});
async init() {
this.server.tool("tool_name", ...); // Use this.server
}
}
Key difference: McpAgent provides this.server property, standalone SDK doesn't.
Error: Tool calls fail after reconnect with "state not found"
Cause: In-memory state cleared on hibernation
Solution: Use this.state.storage instead of instance properties
// ❌ DON'T: Lost on hibernation
this.userId = "123";
// ✅ DO: Persists through hibernation
await this.state.storage.put("userId", "123");
Error: TypeError: Cannot read properties of undefined (reading 'idFromName')
Cause: Forgot DO binding in wrangler.jsonc
Solution: Add binding (see Stateful MCP Servers section)
{
"durable_objects": {
"bindings": [
{
"name": "MY_MCP",
"class_name": "MyMCP",
"script_name": "my-mcp-server"
}
]
}
}
Error: Error: Durable Object class MyMCP has no migration defined
Cause: First DO deployment requires migration
Solution:
{
"migrations": [
{ "tag": "v1", "new_classes": ["MyMCP"] }
]
}
Error: WebSocket metadata lost on hibernation wake
Cause: Not using serializeAttachment() to preserve connection metadata
Solution: See WebSocket Hibernation section
Security risk: Users don't see what permissions they're granting
Cause: allowConsentScreen: false in production
Solution: Always enable in production
export default new OAuthProvider({
allowConsentScreen: true, // ✅ Always true in production
// ...
});
Error: Error: JWT_SIGNING_KEY environment variable not set
Cause: OAuth Provider requires signing key for tokens
Solution:
# Generate secure key
openssl rand -base64 32
# Add to wrangler secret
npx wrangler secret put JWT_SIGNING_KEY
Error: ZodError: Invalid input type
Cause: Client sends string, schema expects number (or vice versa)
Solution: Use Zod transforms
// Accept string, convert to number
param: z.string().transform(val => parseInt(val, 10))
// Or: Accept both types
param: z.union([z.string(), z.number()]).transform(val =>
typeof val === "string" ? parseInt(val, 10) : val
)
Error: /sse returns 404 after adding /mcp
Cause: Incorrect path matching (missing startsWith())
Solution: Use startsWith() or exact matches correctly (see Error #4)
Error: OAuth flow fails in local dev, or Durable Objects behave differently
Cause: Miniflare doesn't support all DO features
Solution: Use npx wrangler dev --remote for full DO support
# Local simulation (faster but limited)
npm run dev
# Remote DOs (slower but accurate)
npx wrangler dev --remote
Error: Claude Desktop doesn't recognize server
Cause: Wrong JSON format in claude_desktop_config.json
Solution: See "Connect Claude Desktop" section for correct format
Common mistakes:
// ❌ WRONG: Missing "mcpServers" wrapper
{
"my-mcp": {
"url": "https://worker.dev/sse"
}
}
// ❌ WRONG: Trailing comma
{
"mcpServers": {
"my-mcp": {
"url": "https://worker.dev/sse", // ← Remove comma
}
}
}
// ✅ CORRECT
{
"mcpServers": {
"my-mcp": {
"url": "https://worker.dev/sse"
}
}
}
Issue: Can't tell if Worker is running or if URL is correct
Impact: Debugging connection issues takes longer
Solution: Add health check endpoint (see Transport Selection Guide)
Test:
curl https://my-mcp.workers.dev/health
# Should return: {"status":"ok","transports":{...}}
Error: Access to fetch at '...' blocked by CORS policy
Cause: MCP server doesn't return CORS headers for cross-origin requests
Solution: Add CORS headers to all responses
// Manual CORS (if not using OAuthProvider)
const corsHeaders = {
"Access-Control-Allow-Origin": "*", // Or specific origin
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization"
};
// Add to responses
return new Response(body, {
headers: {
...corsHeaders,
"Content-Type": "application/json"
}
});
Note: OAuthProvider handles CORS automatically!
# Local dev
npm run dev # Miniflare (fast)
npx wrangler dev --remote # Remote DOs (accurate)
# Test with MCP Inspector
npx @modelcontextprotocol/inspector@latest
# Open http://localhost:5173, enter http://localhost:8788/sse
# Deploy
npx wrangler login # First time only
npx wrangler deploy
# ⚠️ CRITICAL: Update client config with deployed URL!
# Monitor logs
npx wrangler tail
Package Versions: @modelcontextprotocol/sdk@1.25.2, @cloudflare/workers-oauth-provider@0.2.2, agents@0.3.3 Last Verified: 2026-01-09 Errors Prevented: 22 documented issues (100% prevention rate)
This skill should be used when the user asks to "create a slash command", "add a command", "write a custom command", "define command arguments", "use command frontmatter", "organize commands", "create command with file references", "interactive command", "use AskUserQuestion in command", or needs guidance on slash command structure, YAML frontmatter fields, dynamic arguments, bash execution in commands, user interaction patterns, or command development best practices for Claude Code.
This skill should be used when the user asks to "create an agent", "add an agent", "write a subagent", "agent frontmatter", "when to use description", "agent examples", "agent tools", "agent colors", "autonomous agent", or needs guidance on agent structure, system prompts, triggering conditions, or agent development best practices for Claude Code plugins.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.