Files
2025-11-30 08:24:23 +08:00

25 KiB

name, description, license, allowed-tools, metadata
name description license allowed-tools metadata
cloudflare-mcp-server Build Model Context Protocol (MCP) servers on Cloudflare Workers - the only platform with official remote MCP support. TypeScript-based with OAuth, Durable Objects, and WebSocket hibernation. Use when: deploying remote MCP servers, implementing OAuth (GitHub/Google), using dual transports (SSE/HTTP), or troubleshooting URL path mismatches, McpAgent exports, OAuth redirects, CORS issues. MIT
Read
Write
Edit
Bash
Glob
Grep
version last_verified sdk_versions official_examples cloudflare_docs
2.2.0 2025-11-24
mcp_sdk oauth_provider agents_sdk
1.22.0 0.1.0 0.2.23
https://github.com/cloudflare/ai/tree/main/demos https://developers.cloudflare.com/agents/model-context-protocol/

Cloudflare MCP Server Skill

Build and deploy Model Context Protocol (MCP) servers on Cloudflare Workers with TypeScript.

Status: Production Ready Last Updated: 2025-11-24 Latest Versions: @modelcontextprotocol/sdk@1.22.0, @cloudflare/workers-oauth-provider@0.1.0, agents@0.2.23

Recent Updates (2025):

  • September 2025: Code Mode (agents write code vs calling tools, auto-generated TypeScript API from schema)
  • August 2025: MCP Elicitation (interactive workflows, user input during execution), Task Queues, Email Integration
  • July 2025: MCPClientManager (connection management, OAuth flow, hibernation)
  • April 2025: HTTP Streamable Transport (single endpoint, recommended over SSE), Python MCP support
  • May 2025: Claude.ai remote MCP support, use-mcp React library, major partnerships

What is This Skill?

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)


🚀 Quick Start (5 Minutes)

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 OAuth
  • remote-mcp-google-oauth - Google OAuth
  • remote-mcp-auth0 / remote-mcp-authkit - Enterprise SSO
  • mcp-server-bearer-auth - Custom auth

All templates: https://github.com/cloudflare/ai/tree/main/demos

Production examples: https://github.com/cloudflare/mcp-server-cloudflare (15 servers with real integrations)


Deployment Workflow

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

  • curl returns server info (not 404)
  • Client URL matches curl URL exactly
  • Claude Desktop restarted
  • Tools visible in Claude Desktop
  • Test tool call succeeds

⚠️ CRITICAL: HTTP Transport Fundamentals

The #1 reason MCP servers fail to connect is URL path configuration mistakes.

URL Path Configuration Deep-Dive

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)
    }
  }
}

How Base Path Affects Tool URLs

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.


Request/Response Lifecycle

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

Testing Your URL Configuration

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


Post-Deployment Checklist

After deploying, verify:

  • curl https://worker.dev/sse returns MCP server info (not 404)
  • Client config URL matches deployed URL exactly
  • No typos in URL (common: workes.dev instead of workers.dev)
  • Using https:// (not http://) for deployed Workers
  • If using OAuth, redirect URI also updated

Transport Selection

Two transports available:

  1. SSE (Server-Sent Events) - Legacy, wide compatibility

    MyMCP.serveSSE("/sse").fetch(request, env, ctx)
    
  2. 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!


2025 Knowledge Gaps

MCP Elicitation (August 2025)

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

Code Mode (September 2025)

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

MCPClientManager (July 2025)

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 & Email (August 2025)

// 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);
}

HTTP Streamable Transport Details (April 2025)

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


Authentication Patterns

Choose auth based on use case:

  1. No Auth - Internal tools, dev (Template: remote-mcp-authless)

  2. 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 });
    }
    
  3. 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

  4. Remote OAuth with DCR - Full OAuth provider (Template: remote-mcp-authkit)

Security levels: No Auth (⚠️) < Bearer () < OAuth Proxy () < Remote OAuth ()


Stateful MCP Servers (Durable Objects)

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)


Common Patterns

Caching with DO Storage

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;
}

Rate Limiting

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;
}

22 Known Errors (With Solutions)

1. McpAgent Class Not Exported

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() { ... } }

2. Base Path Configuration Mismatch (Most Common!)

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:

  1. Check what path your server uses: serveSSE("/sse") vs serveSSE("/")
  2. Test with curl: curl https://worker.dev/sse
  3. Update client config to match curl URL

3. Transport Type Confusion

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


4. pathname.startsWith() Logic Error

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(...);
}

5. Local vs Deployed URL Mismatch

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:

  • Run npx wrangler deploy and note output URL
  • Update client config with deployed URL
  • Test with curl
  • Restart Claude Desktop

6. OAuth Redirect URI Mismatch

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


7. Missing OPTIONS Handler (CORS Preflight)

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)


8. Request Body Validation Missing

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" }
        }
      );
    }
  }
};

9. Environment Variable Validation Missing

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:

  • Development: Add to .dev.vars (local only, gitignored)
  • Production: Add to 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

10. McpAgent vs McpServer Confusion

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.


11. WebSocket Hibernation State Loss

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");

12. Durable Objects Binding Missing

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"
      }
    ]
  }
}

13. Migration Not Defined

Error: Error: Durable Object class MyMCP has no migration defined

Cause: First DO deployment requires migration

Solution:

{
  "migrations": [
    { "tag": "v1", "new_classes": ["MyMCP"] }
  ]
}

14. serializeAttachment() Not Used

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
  // ...
});

16. JWT Signing Key Missing

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

17. Tool Schema Validation Error

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
)

18. Multiple Transport Endpoints Conflicting

Error: /sse returns 404 after adding /mcp

Cause: Incorrect path matching (missing startsWith())

Solution: Use startsWith() or exact matches correctly (see Error #4)


19. Local Testing with Miniflare Limitations

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

20. Client Configuration Format Error

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"
    }
  }
}

21. Health Check Endpoint Missing

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":{...}}

22. CORS Headers Missing

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!


Testing & Deployment

# 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

Official Documentation


Package Versions: @modelcontextprotocol/sdk@1.22.0, @cloudflare/workers-oauth-provider@0.1.0, agents@0.2.23 Last Verified: 2025-11-24 Errors Prevented: 22 documented issues (100% prevention rate)