home / skills / jezweb / claude-skills / cloudflare-mcp-server

cloudflare-mcp-server skill

/skills/cloudflare-mcp-server

This skill helps you deploy and troubleshoot remote MCP servers on Cloudflare Workers, preventing URL path and OAuth errors.

npx playbooks add skill jezweb/claude-skills --skill cloudflare-mcp-server

Review the files below or copy the command above to add this skill to your agents.

Files (24)
SKILL.md
33.3 KB
---
name: cloudflare-mcp-server
description: |
  Build MCP servers on Cloudflare Workers - the only platform with official remote MCP support. TypeScript-based with OAuth, Durable Objects, and WebSocket hibernation. Prevents 24 documented errors.

  Use when: deploying remote MCP servers, implementing OAuth, or troubleshooting URL path mismatches, McpAgent exports, CORS issues, IoContext timeouts.
user-invocable: true
allowed-tools: ["Read", "Write", "Edit", "Bash", "Glob", "Grep"]
---

# Cloudflare MCP Server Skill

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

**Status**: Production Ready βœ…
**Last Updated**: 2026-01-21
**Latest Versions**: @modelcontextprotocol/[email protected], @cloudflare/[email protected], [email protected]

**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 24+ common MCP + Cloudflare errors (especially URL path mismatches - the #1 failure cause)

---

## πŸš€ Quick Start (5 Minutes)

**Start with Cloudflare's official template:**

```bash
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

```bash
# 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`**
```typescript
// 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`**:
```json
{
  "mcpServers": {
    "my-mcp": {
      "url": "https://my-mcp.workers.dev/sse"  // βœ… Correct
    }
  }
}
```

**❌ WRONG client configurations**:
```json
"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)**
```typescript
export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return MyMCP.serveSSE("/").fetch(request, env, ctx);  // ← Base path is "/"
  }
};
```

**Client configuration**:
```json
{
  "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**
```bash
npx wrangler deploy
# Output: Deployed to https://my-mcp.YOUR_ACCOUNT.workers.dev
```

**Step 2: Test the base path with curl**
```bash
# 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**
```json
{
  "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
   ```typescript
   MyMCP.serveSSE("/sse").fetch(request, env, ctx)
   ```

2. **Streamable HTTP** - 2025 standard (recommended), single endpoint
   ```typescript
   MyMCP.serve("/mcp").fetch(request, env, ctx)
   ```

**Support both for maximum compatibility:**

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

```typescript
// 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:

```typescript
// 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:

```typescript
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)

```typescript
// 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:

```typescript
// 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

---

## Security Considerations

### PKCE Bypass Vulnerability (CRITICAL)

**CVE**: [GHSA-qgp8-v765-qxx9](https://github.com/cloudflare/workers-oauth-provider/security/advisories/GHSA-qgp8-v765-qxx9)
**Severity**: Critical
**Fixed in**: @cloudflare/[email protected]

**Problem**: Earlier versions of the OAuth provider library had a critical vulnerability that completely bypassed PKCE protection, potentially allowing attackers to intercept authorization codes.

**Action Required**:
```bash
# Check current version
npm list @cloudflare/workers-oauth-provider

# Update if < 0.0.5
npm install @cloudflare/workers-oauth-provider@latest
```

**Minimum Safe Version**: `@cloudflare/[email protected]` or later

### Token Storage Best Practices

**Always use encrypted storage for OAuth tokens:**

```typescript
// βœ… GOOD: workers-oauth-provider handles encryption automatically
export default new OAuthProvider({
  kv: (env) => env.OAUTH_KV,  // Tokens stored encrypted
  // ...
});

// ❌ BAD: Storing tokens in plain text
await env.KV.put("access_token", token);  // Security risk!
```

**User-scoped KV keys** prevent data leakage between users:

```typescript
// βœ… GOOD: Namespace by user ID
await env.KV.put(`user:${userId}:todos`, data);

// ❌ BAD: Global namespace
await env.KV.put(`todos`, data);  // Data visible to all users!
```

---

## 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`)
   ```typescript
   // 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`)
   ```typescript
   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:

```typescript
// 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)

---

## Architecture: Internal vs External Transports

**Important**: McpAgent uses different transports for client-facing vs internal communication.

**Source**: [GitHub Issue #172](https://github.com/cloudflare/agents/issues/172)

### Transport Architecture

```
Client --- (SSE or HTTP) --> Worker --- (WebSocket) --> Durable Object
```

**Client β†’ Worker** (External):
- SSE transport: `/sse` endpoint
- HTTP Streamable: `/mcp` endpoint
- Client chooses transport

**Worker β†’ Durable Object** (Internal):
- Always WebSocket
- Required by PartyServer (McpAgent's internal dependency)
- Automatic upgrade, invisible to client

### What This Means

1. **SSE clients are fully supported** - External interface can be SSE
2. **WebSocket is mandatory for DO** - Internal Worker-DO communication always uses WebSocket
3. **This is not a limitation** - It's an implementation detail of McpAgent's architecture

### Example

```typescript
export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const { pathname } = new URL(request.url);

    // Client uses SSE
    if (pathname.startsWith("/sse")) {
      // βœ… Client β†’ Worker: SSE
      // βœ… Worker β†’ DO: WebSocket (automatic)
      return MyMCP.serveSSE("/sse").fetch(request, env, ctx);
    }

    return new Response("Not Found", { status: 404 });
  }
};
```

**Key Takeaway**: You can serve SSE to clients without worrying about the internal WebSocket requirement.

---

## Common Patterns

### Tool Return Format (CRITICAL)

**Source**: [Stytch Blog - Building MCP Server with OAuth](https://stytch.com/blog/building-an-mcp-server-oauth-cloudflare-workers/)

**All MCP tools must return this exact format:**

```typescript
this.server.tool(
  "my_tool",
  { /* schema */ },
  async (params) => {
    // βœ… CORRECT: Return object with content array
    return {
      content: [
        { type: "text", text: "Your result here" }
      ]
    };

    // ❌ WRONG: Raw string
    return "Your result here";

    // ❌ WRONG: Plain object
    return { result: "Your result here" };
  }
);
```

**Common mistake**: Returning raw strings or plain objects instead of proper MCP content format. This causes client parsing errors.

### Conditional Tool Registration

**Source**: [Cloudflare Blog - Building AI Agents](https://blog.cloudflare.com/building-ai-agents-with-mcp-authn-authz-and-durable-objects/)

**Dynamically add tools based on authenticated user:**

```typescript
export class MyMCP extends McpAgent<Env> {
  async init() {
    this.server = new McpServer({ name: "My MCP" });

    // Base tools for all users
    this.server.tool("public_tool", { /* schema */ }, async (params) => {
      // Available to everyone
    });

    // Conditional tools based on user
    const userId = this.props?.userId;
    if (await this.isAdmin(userId)) {
      this.server.tool("admin_tool", { /* schema */ }, async (params) => {
        // Only available to admins
      });
    }

    // Premium features
    if (await this.isPremiumUser(userId)) {
      this.server.tool("premium_feature", { /* schema */ }, async (params) => {
        // Only for premium users
      });
    }
  }

  private async isAdmin(userId?: string): Promise<boolean> {
    if (!userId) return false;
    const userRole = await this.state.storage.get<string>(`user:${userId}:role`);
    return userRole === "admin";
  }
}
```

**Use cases**:
- Feature flags per user
- Premium vs free tier tools
- Role-based access control (RBAC)
- A/B testing new tools

### Caching with DO Storage

```typescript
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

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

---

## 24 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**:
```typescript
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
```typescript
// 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
```typescript
// 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
```typescript
// βœ… 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
```json
// 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
```json
{
  "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
```typescript
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
```typescript
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
```typescript
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**:
```bash
# .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
```typescript
// ❌ 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
```typescript
// ❌ 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)
```jsonc
{
  "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**:
```jsonc
{
  "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

---

### 15. OAuth Consent Screen Disabled

**Security risk**: Users don't see what permissions they're granting

**Cause**: `allowConsentScreen: false` in production

**Solution**: Always enable in production
```typescript
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**:
```bash
# 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
```typescript
// 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
```bash
# 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**:
```json
// ❌ 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**:
```bash
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
```typescript
// 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!

---

### 23. IoContext Timeout During MCP Initialization

**Error**: `IoContext timed out due to inactivity, waitUntil tasks were cancelled`

**Source**: [GitHub Issue #640](https://github.com/cloudflare/agents/issues/640)

**Cause**: When implementing MCP servers using `McpAgent` with custom Bearer authentication, the IoContext times out during the MCP protocol initialization handshake (before any tools are called).

**Symptoms**:
- Timeout occurs before any tools are called
- ~2 minute gap between initial request and agent initialization
- Internal methods work (setInitializeRequest, getInitializeRequest, updateProps)
- Both GET and POST to `/mcp` are canceled
- Error: "IoContext timed out due to inactivity, waitUntil tasks were cancelled"

**Affected Code Pattern**:
```typescript
// Custom Bearer auth without OAuthProvider wrapper
export default {
  fetch: async (req, env, ctx) => {
    const authHeader = req.headers.get("Authorization");
    if (!authHeader?.startsWith("Bearer ")) {
      return new Response("Unauthorized", { status: 401 });
    }

    if (url.pathname === "/sse") {
      return MyMCP.serveSSE("/sse")(req, env, ctx);  // ← Timeout here
    }
    return new Response("Not found", { status: 404 });
  }
};
```

**Root Cause Hypothesis**:
- May require `OAuthProvider` wrapper even for custom Bearer auth
- Possible missing timeout configuration for Durable Object IoContext
- May need `CloudflareMCPServer` instead of standard `McpServer`

**Workaround**: Use official templates with OAuthProvider pattern instead of custom Bearer auth:

```typescript
// Use OAuthProvider wrapper (recommended)
import { OAuthProvider } from "@cloudflare/workers-oauth-provider";

export default new OAuthProvider({
  authorizeEndpoint: "/authorize",
  tokenEndpoint: "/token",
  // ... OAuth config
  apiHandlers: { "/sse": MyMCP.serveSSE("/sse") }
});
```

**Status**: Investigation ongoing (issue open as of 2026-01-21)

---

### 24. OAuth Remote Connection Failures

**Error**: Connection to remote MCP server fails when using OAuth (works locally but fails when deployed)

**Source**: [GitHub Issue #444](https://github.com/cloudflare/agents/issues/444)

**Cause**: When deploying MCP client from Cloudflare Agents repository to Workers, client fails to connect to MCP servers secured with OAuth.

**Symptoms**:
- Works perfectly in local development
- Fails after deployment to Workers
- OAuth handshake never completes
- Client can't establish connection

**Troubleshooting Steps**:

1. **Verify OAuth tokens are handled correctly** during remote connection attempts
   ```typescript
   // Check token is being passed to remote server
   console.log("Connecting with token:", token ? "present" : "missing");
   ```

2. **Check network permissions** to access OAuth provider
   ```typescript
   // Ensure Worker can reach OAuth endpoints
   const response = await fetch("https://oauth-provider.com/token");
   ```

3. **Verify CORS configuration** on OAuth provider
   ```typescript
   // OAuth provider must allow Worker origin
   headers: {
     "Access-Control-Allow-Origin": "https://your-worker.workers.dev",
     "Access-Control-Allow-Methods": "POST, OPTIONS",
     "Access-Control-Allow-Headers": "Content-Type, Authorization"
   }
   ```

4. **Check redirect URIs match deployed URLs**
   ```json
   {
     "url": "https://mcp.workers.dev/sse",
     "auth": {
       "authorizationUrl": "https://mcp.workers.dev/authorize",  // Must match deployed domain
       "tokenUrl": "https://mcp.workers.dev/token"
     }
   }
   ```

**Deployment Checklist**:
- [ ] All OAuth URLs use deployed domain (not localhost)
- [ ] CORS headers configured on OAuth provider
- [ ] Network requests to OAuth provider allowed in Worker
- [ ] Redirect URIs registered with OAuth provider
- [ ] Environment variables set in production (`wrangler secret`)

**Related**: Issue #640 (both involve OAuth/auth in remote deployments)

---

## Testing & Deployment

```bash
# 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

- **Cloudflare Agents**: https://developers.cloudflare.com/agents/
- **MCP Specification**: https://modelcontextprotocol.io/
- **Official Templates**: https://github.com/cloudflare/ai/tree/main/demos
- **Production Servers**: https://github.com/cloudflare/mcp-server-cloudflare
- **workers-oauth-provider**: https://github.com/cloudflare/workers-oauth-provider

---

**Package Versions**: @modelcontextprotocol/[email protected], @cloudflare/[email protected], [email protected]
**Last Verified**: 2026-01-21
**Errors Prevented**: 24 documented issues (100% prevention rate)
**Skill Version**: 3.1.0 | **Changes**: Added IoContext timeout (#23), OAuth remote failures (#24), Security section (PKCE vulnerability), Architecture clarification (internal WebSocket), Tool return format pattern, Conditional tool registration

Overview

This skill teaches how to build and deploy Model Context Protocol (MCP) servers on Cloudflare Workers using TypeScript. It focuses on the official remote MCP support on Cloudflare, with templates, OAuth integration, Durable Objects, and the newer HTTP streamable transport. The skill also documents fixes and patterns to prevent the 24+ common MCP + Cloudflare errors, especially URL path mismatches.

How this skill works

The skill provides step‑by‑step patterns and templates to serve MCP over SSE or the recommended HTTP streamable endpoint, and shows how to wire Cloudflare Workers to Durable Objects for stateful agents. It covers OAuth options (no auth, bearer, OAuth proxy, remote OAuth), token storage best practices, and critical security updates. Practical checks and curl tests are included to validate exact base path configuration and ensure client tools route correctly.

When to use it

  • Deploying remote MCP servers on Cloudflare Workers in production or staging.
  • Implementing OAuth flows and secure token storage for MCP agents.
  • Troubleshooting URL path mismatches that cause 404s or tool routing failures.
  • Configuring Durable Objects for per-session state and hibernation.
  • Migrating from SSE to the newer HTTP streamable transport for better performance.

Best practices

  • Always test the exact base path with curl after deploy and use that exact URL in client config.
  • Use pathname.startsWith() in your Worker to match base paths and avoid off‑by‑one errors.
  • Keep @cloudflare/workers-oauth-provider updated to a secure minimum version to avoid PKCE bypass vulnerabilities.
  • Store OAuth tokens in encrypted KV namespaces scoped by user to prevent data leakage.
  • Support both transports (SSE and /mcp) during rollout for compatibility, then prefer HTTP streamable.

Example use cases

  • A dev team deploying an internal MCP server with no auth for fast prototyping.
  • A product that needs OAuth proxy (GitHub/Google) to access user repos via MCP tools.
  • Stateful agents using Durable Objects to preserve session data across hibernation.
  • Replacing separate connect/message endpoints with a single /mcp streamable endpoint for performance.
  • Troubleshooting Claude Desktop failing to load tools because the client URL omitted the base path.

FAQ

What causes the 404 most often when a client can't connect?

The client URL does not exactly match the base path the Worker serves (e.g., missing /sse). Test with curl and use that exact URL in the client config.

Which transport should I choose?

Use the streamable HTTP transport (/mcp) for new deployments; keep SSE for legacy compatibility during migration.

How do I secure OAuth tokens?

Use the workers-oauth-provider with encrypted KV and namespace keys per user; update to a fixed provider version to avoid PKCE bypass vulnerabilities.