npx claudepluginhub adamlevoy/claude-plugins --plugin cf-mcpThis skill uses the workspace's default tool permissions.
---
Designs and optimizes AI agent action spaces, tool definitions, observation formats, error recovery, and context for higher task completion rates.
Compares coding agents like Claude Code and Aider on custom YAML-defined codebase tasks using git worktrees, measuring pass rate, cost, time, and consistency.
Designs, implements, and audits WCAG 2.2 AA accessible UIs for Web (ARIA/HTML5), iOS (SwiftUI traits), and Android (Compose semantics). Audits code for compliance gaps.
Scaffold a complete MCP server on Cloudflare Workers with Durable Objects + Google OAuth. This generates the exact architecture used by kwatch-mcp, reddit-mcp, klaviyo-mcp, and dataforseo-mcp.
OAuthProvider (entry point)
├── /authorize, /callback → GoogleHandler (Hono) → Google OAuth
├── /register, /token → OAuthProvider built-in
└── /mcp → McpAgent (Durable Object) → API Client → External API
Ask the user for these values (provide defaults where noted):
| Placeholder | Example | Notes |
|---|---|---|
{{SERVICE_NAME}} | stripe | Lowercase, kebab-safe. Used in package name, wrangler name, URLs |
{{SERVICE_NAME_UPPER}} | STRIPE | Uppercase. Used for env var prefix (STRIPE_API_KEY) |
{{SERVICE_NAME_CLASS}} | Stripe | PascalCase. Used for class names (StripeMCP, StripeClient) |
{{DESCRIPTION}} | MCP server for Stripe payment API | One-line description |
{{DOMAIN}} | taboogrow.com | Domain for custom route. Default: taboogrow.com |
{{GOOGLE_WORKSPACE_DOMAIN}} | taboogrow.com | Google Workspace domain for hd param. Set to NONE if not using Workspace |
{{ACCOUNT_ID}} | 27e3ec1d452356993cc7acfc1c99bcd6 | Cloudflare account ID. Default: 27e3ec1d452356993cc7acfc1c99bcd6 |
mkdir -p ~/code/{{SERVICE_NAME}}-mcp/src
cd ~/code/{{SERVICE_NAME}}-mcp
Copy all files from the plugin's templates/ directory into the new project:
| Template Source | Destination | Processing |
|---|---|---|
templates/src/index.ts.tmpl | src/index.ts | Replace all {{PLACEHOLDERS}} |
templates/src/google-handler.ts | src/google-handler.ts | Replace {{SERVICE_NAME}}, {{SERVICE_NAME_CLASS}}, {{DESCRIPTION}} |
templates/src/workers-oauth-utils.ts | src/workers-oauth-utils.ts | Copy as-is (no placeholders) |
templates/src/utils.ts.tmpl | src/utils.ts | Replace {{GOOGLE_WORKSPACE_DOMAIN}}. If NONE, remove the entire hd line |
templates/wrangler.toml.tmpl | wrangler.toml | Replace all {{PLACEHOLDERS}} |
templates/package.json.tmpl | package.json | Replace {{SERVICE_NAME}}, {{DESCRIPTION}} |
templates/tsconfig.json | tsconfig.json | Copy as-is |
templates/CLAUDE.md.tmpl | CLAUDE.md | Replace all {{PLACEHOLDERS}}. If GOOGLE_WORKSPACE_DOMAIN is NONE, remove the Workspace line |
If {{GOOGLE_WORKSPACE_DOMAIN}} is NONE: In src/utils.ts, delete the line:
url.searchParams.set('hd', '{{GOOGLE_WORKSPACE_DOMAIN}}');
And in CLAUDE.md, change the auth description to say "Authenticated via Google SSO OAuth 2.1" without the Workspace restriction.
bun install
git init && git add -A && git commit -m "Initial scaffold from cf-mcp template"
Use WebSearch and WebFetch to study the target service's API documentation. Identify:
Add any service-specific secrets to the Env interface in src/index.ts:
interface Env {
{{SERVICE_NAME_UPPER}}_API_KEY: string;
// Add more service-specific secrets here
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
COOKIE_ENCRYPTION_KEY: string;
MCP_OBJECT: DurableObjectNamespace;
OAUTH_KV: KVNamespace;
}
Modify the {{SERVICE_NAME_CLASS}}Client class in src/index.ts:
API_BASE_URLconst API_BASE_URL = 'https://api.service.com/v1';
class {{SERVICE_NAME_CLASS}}Client {
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const body = await response.text();
switch (response.status) {
case 401:
throw new Error('Error: Authentication failed. Check your API key.');
case 403:
throw new Error('Error: Permission denied.');
case 404:
throw new Error('Error: Resource not found.');
case 429:
throw new Error('Error: Rate limit exceeded.');
default:
throw new Error(`Error: API returned ${response.status} - ${body}`);
}
}
return response.json() as Promise<T>;
}
}
Replace the example tool in init() with real tools. Follow this pattern:
Tool naming: {service}_{action}_{resource} — e.g., stripe_list_charges, stripe_get_customer
Tool registration pattern:
this.server.registerTool('{{SERVICE_NAME}}_list_items', {
title: 'List Items',
description: 'List all items with optional filtering. Returns item name, status, and metadata.',
inputSchema: {
status: z.string().optional().describe('Filter by status: active, archived, or all (default: active)'),
limit: z.string().optional().describe('Max results to return, 1-100 (default: 20)'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
}, async ({ status, limit }) => {
try {
console.log(JSON.stringify({
tool: '{{SERVICE_NAME}}_list_items',
status,
limit,
timestamp: new Date().toISOString(),
}));
const data = await client.request<unknown>(`/items?status=${status || 'active'}&limit=${limit || '20'}`);
return {
content: [{ type: 'text' as const, text: truncateResponse(data) }],
};
} catch (error) {
return {
isError: true,
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
};
}
});
Write tools (mutations):
this.server.registerTool('{{SERVICE_NAME}}_create_item', {
title: 'Create Item',
description: 'Create a new item with the given name and optional configuration as JSON.',
inputSchema: {
name: z.string().min(1).describe('Item name'),
config: z.string().optional().describe('Optional configuration as JSON string (e.g. {"key": "value"})'),
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: true,
},
}, async ({ name, config }) => {
try {
console.log(JSON.stringify({
tool: '{{SERVICE_NAME}}_create_item',
name,
timestamp: new Date().toISOString(),
}));
const parsedConfig = config ? JSON.parse(config) : undefined;
const data = await client.request<unknown>('/items', {
method: 'POST',
body: JSON.stringify({ name, config: parsedConfig }),
});
return {
content: [{ type: 'text' as const, text: truncateResponse(data) }],
};
} catch (error) {
return {
isError: true,
content: [{ type: 'text' as const, text: error instanceof Error ? error.message : String(error) }],
};
}
});
.describe() on every field. Put validation constraints and defaults in the description text, not in Zod methods.readOnlyHint, destructiveHint, idempotentHint, openWorldHint accurately.isError: true with the error message.{ tool, ...keyParams, timestamp } via console.log(JSON.stringify(...)).truncateResponse() — the 25k character limit prevents oversized responses.CRITICAL: With zod v4 (^4.3.5) and the agents/mcp McpAgent pattern, only these Zod types work reliably in inputSchema:
z.string() — worksz.string().optional() — worksz.string().min(1) — worksz.string().describe('...') — worksThese types silently break tool registration (tools won't appear in the connector):
z.number(), z.number().min().max() — use z.string() and parse in the handlerz.enum([...]) — use z.string() and document valid values in .describe()z.array(z.string()) — use z.string() with comma-separated valuesz.record(z.unknown()) — use z.string() and accept JSON stringsz.object({...}) — use z.string() and accept JSON stringsz.boolean() — use z.string() and parse 'true'/'false'The failure is silent — init() completes, but the MCP connector shows "This connector has no tools available." with no errors in logs.
cd ~/code/{{SERVICE_NAME}}-mcp
wrangler kv namespace create "OAUTH_KV"
Copy the output ID and update wrangler.toml:
[[kv_namespaces]]
binding = "OAUTH_KV"
id = "PASTE_THE_ID_HERE"
wrangler secret put {{SERVICE_NAME_UPPER}}_API_KEY
wrangler secret put GOOGLE_CLIENT_ID
wrangler secret put GOOGLE_CLIENT_SECRET
wrangler secret put COOKIE_ENCRYPTION_KEY
# Generate cookie key: openssl rand -hex 32
console.cloud.google.comhttps://{{SERVICE_NAME}}-mcp.{{DOMAIN}}/callbackbun run deploy
bun run logs
Visit https://{{SERVICE_NAME}}-mcp.{{DOMAIN}}/ in a browser — should see the approval dialog page.
In Claude Desktop or Claude Code Settings > Connectors > Add custom connector:
https://{{SERVICE_NAME}}-mcp.{{DOMAIN}}/mcpTest each registered tool to verify:
Update the generated CLAUDE.md with:
gh repo create taboogrow/{{SERVICE_NAME}}-mcp --private --source=. --push
bun install completes without errorswrangler.tomlwrangler secret puthttps://{{SERVICE_NAME}}-mcp.{{DOMAIN}}/callbackbun run deploy succeedsCLAUDE.md updated with actual tools and configAll CF MCP servers use the same dependency set, pinned to compatible versions:
{
"dependencies": {
"@cloudflare/workers-oauth-provider": "^0.2.0",
"@modelcontextprotocol/sdk": "^1.27.0",
"agents": "^0.5.0",
"hono": "^4.11.10",
"zod": "^4.3.5"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241230.0",
"typescript": "^5.7.2",
"wrangler": "^4.59.3"
}
}