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 |
|
|
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 OAuthremote-mcp-google-oauth- Google OAuthremote-mcp-auth0/remote-mcp-authkit- Enterprise SSOmcp-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/ssereturns MCP server info (not 404)- Client config URL matches deployed URL exactly
- No typos in URL (common:
workes.devinstead ofworkers.dev) - Using
https://(nothttp://) for deployed Workers - If using OAuth, redirect URI also updated
Transport Selection
Two transports available:
-
SSE (Server-Sent Events) - Legacy, wide compatibility
MyMCP.serveSSE("/sse").fetch(request, env, ctx) -
Streamable HTTP - 2025 standard (recommended), single endpoint
MyMCP.serve("/mcp").fetch(request, env, ctx)
Support both for maximum compatibility:
export default {
fetch(request: Request, env: Env, ctx: ExecutionContext) {
const { pathname } = new URL(request.url);
if (pathname.startsWith("/sse")) {
return MyMCP.serveSSE("/sse").fetch(request, env, ctx);
}
if (pathname.startsWith("/mcp")) {
return MyMCP.serve("/mcp").fetch(request, env, ctx);
}
return new Response("Not Found", { status: 404 });
}
};
CRITICAL: Use pathname.startsWith() to match paths correctly!
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:
-
No Auth - Internal tools, dev (Template:
remote-mcp-authless) -
Bearer Token - Custom auth (Template:
mcp-server-bearer-auth)// Validate Authorization: Bearer <token> const token = request.headers.get("Authorization")?.replace("Bearer ", ""); if (!await validateToken(token, env)) { return new Response("Unauthorized", { status: 401 }); } -
OAuth Proxy - GitHub/Google (Template:
remote-mcp-github-oauth)import { OAuthProvider, GitHubHandler } from "@cloudflare/workers-oauth-provider"; export default new OAuthProvider({ authorizeEndpoint: "/authorize", tokenEndpoint: "/token", defaultHandler: new GitHubHandler({ clientId: (env) => env.GITHUB_CLIENT_ID, clientSecret: (env) => env.GITHUB_CLIENT_SECRET, scopes: ["repo", "user:email"] }), kv: (env) => env.OAUTH_KV, apiHandlers: { "/sse": MyMCP.serveSSE("/sse") } });⚠️ CRITICAL: All OAuth URLs (url, authorizationUrl, tokenUrl) must use same domain
-
Remote OAuth with DCR - Full OAuth provider (Template:
remote-mcp-authkit)
Security levels: No Auth (⚠️) < Bearer (✅) < OAuth Proxy (✅✅) < Remote OAuth (✅✅✅)
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:
- Check what path your server uses:
serveSSE("/sse")vsserveSSE("/") - Test with curl:
curl https://worker.dev/sse - 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 deployand 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.jsoncvars(public) or usewrangler 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
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
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
- 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.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)