Skillscloudflare-mcp-server
C

cloudflare-mcp-server

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.

jezweb
224 stars
4.5k downloads
Updated 1w ago

Readme

cloudflare-mcp-server follows the SKILL.md standard. Use the install command to add it to your agent stack.

---
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/sdk@1.25.3, @cloudflare/workers-oauth-provider@0.2.2, agents@0.3.6

**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/workers-oauth-provider@0.0.5

**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/workers-oauth-provider@0.0.5` 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/sdk@1.25.3, @cloudflare/workers-oauth-provider@0.2.2, agents@0.3.6
**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

Install

Requires askill CLI v1.0+

Metadata

LicenseUnknown
Version-
Updated1w ago
Publisherjezweb

Tags

apici-cdgithubgithub-actionsllmpromptingsecuritytesting