mcp-authoring
When to author an MCP server, architecture overview (tools/resources/prompts), stdio vs HTTP+SSE transport tradeoffs, registration in .claude.json. Sub-modules: stdio-server-template.md (full TS code), http-server-on-workers.md (Hono + SSE on CF Workers), forge-mcp-from-openapi.m
Install
npx skills add https://github.com/heymegabyte/claude-skills/tree/master/19-mcp-authoring
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install heymegabyte-claude-skills@llmmart
git clone https://github.com/heymegabyte/claude-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole heymegabyte/claude-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
19 — MCP Authoring
Three primitive types:
- Tools — callable functions (JSON schema input → structured output). Model decides when to call. "Do something."
- Resources — addressable content (files, DB rows, feeds) returned as text/binary. "Read something."
- Prompts — reusable templates with typed args. "Fill and inject."
Source authority: modelcontextprotocol.io/introduction, @modelcontextprotocol/sdk NPM.
When to author an MCP server
Build when:
- A REST API/Worker would provide genuine agent value and schema-wrapping cost < benefit
- Tool set needs sharing across multiple Claude sessions without copy-pasting prompts
- CF Worker owns business logic and you want Claude persistent auth-aware access (HTTP transport = zero extra infra)
- Extending the forge pipeline (
--target=mcp-server— seeforge-mcp-from-openapi.md)
Do NOT build when a simple [[hono-api]] route + direct fetch suffices — MCP adds SDK overhead not justified for one-off integrations.
Architecture
Claude Code / Claude Desktop
│ JSON-RPC 2.0
▼
┌──────────────┐
│ MCP Server │
│ ┌────────┐ │
│ │ tools │ │ ← JSON-schema validated inputs + Zod-validated outputs
│ ├────────┤ │
│ │resourc.│ │ ← URI-addressed, MIME-typed
│ ├────────┤ │
│ │prompts │ │ ← Named templates with typed args
│ └────────┘ │
└──────────────┘
│
▼
External system (D1 / R2 / Vectorize / external API)
Every tool input: z.parse() before hitting the system. Every result: Zod-validated before returning. Per [[contract-first-ai]] and [[zod-everywhere]].
Transport decision
| Criterion | stdio | HTTP + SSE |
|---|---|---|
| Where it runs | Local process, same machine as Claude | Any origin — CF Workers, remote server |
| Auth | None (process-level trust) | HTTP headers, Bearer tokens, CF Zero Trust |
| Session state | Process lifetime | DO / KV per session ID |
| Streaming | Native (stdout) | SSE (text/event-stream) |
| Setup | ~/.claude.json mcpServers entry |
CF Worker deploy + .claude.json remote entry |
| Best for | Dev tools, local scripts, secret-laden CLIs | Shared team tools, SaaS integrations, per-user auth |
Per [[cloudflare-lock-in-is-leverage]]: prefer HTTP on CF Workers over any third-party MCP host.
.claude.json registration
stdio server
{
"mcpServers": {
"my-local-tool": {
"command": "node",
"args": ["/absolute/path/to/mcp-server/dist/index.js"],
"env": { "DB_PATH": "/Users/Apple/data/mydb.sqlite" }
}
}
}
HTTP server (CF Workers)
{
"mcpServers": {
"my-worker-tool": {
"url": "https://my-mcp.workers.dev/mcp",
"headers": { "Authorization": "Bearer ${MY_MCP_TOKEN}" }
}
}
}
Place at ~/.claude.json (global) or .claude.json at repo root (project-scoped).
Sub-modules
stdio-server-template.md— complete TypeScript stdio server with sample tool + resource + prompthttp-server-on-workers.md— Hono + MCP SDK + SSE on CF Workers, wrangler.toml, authforge-mcp-from-openapi.md— plan for extendingbin/forge-skill-from-openapi.mjsto emit MCP servers
Quality gates (every MCP server)
- All tool inputs have a Zod schema — never accept raw
unknown - All tool results conform to a Zod output schema before returning
- Errors return MCP
isError: truewith structured{ code, message }— never throw raw JS errors - Every tool
description≤2 sentences, specific enough for an LLM to decide when to call it - No secret values in tool schemas or resource URIs — pass via
envblock in.claude.json - Smoke-test with
npx @modelcontextprotocol/inspectorbefore registering
Cross-links
[[cloudflare-lock-in-is-leverage]]— Workers HTTP transport over any third-party MCP host[[ai-agent-supervisor]]— MCP tools are the supervised boundary for agent actions[[contract-first-ai]]— Zod at every tool boundary[[hono-api]]— HTTP transport built on Hono05-architecture-and-stack/cf-agents-do-pattern.md— stateful MCP sessions via Durable Objectsrules/ai-agent-security.md— tool scope minimization, input sanitization, rate limiting
Files (claude-skills)
-
forge-mcp-from-openapi.md 9.5 KB
--- name: "Forge MCP from OpenAPI" description: "Plan + pseudocode for extending bin/forge-skill-from-openapi.mjs with a --target=mcp-server flag that emits a fully-wired MCP server instead of a Claude Code skill markdown file. Does NOT modify the forge script — describes the extension seam, the code-generation template, and the output file set." updated: "2026-06-18" --- # Forge MCP from OpenAPI — Extension Plan `bin/forge-skill-from-openapi.mjs` already ingests an OpenAPI 3.0/3.1 spec and emits Claude Code skill markdown. This document describes how to add `--target=mcp-server` so the same spec also emits a ready-to-deploy MCP server (stdio OR Workers HTTP transport). This is a **plan + pseudocode**, not an implementation PR. Extend the forge script when you need to auto-generate MCP servers from existing OpenAPI contracts — e.g. when onboarding a new vendor API or scaffolding a new Workers microservice. --- ## Why this is the right abstraction OpenAPI specs are already a typed, machine-readable contract for every endpoint. MCP tool schemas are JSON Schema subsets — structurally identical to OpenAPI `requestBody` + `parameters`. The forge pipeline already: 1. Fetches + parses the OpenAPI spec. 2. Groups operations by tag. 3. Emits templated markdown per operation. Adding `--target=mcp-server` requires replacing step 3's template and adding a TS code emitter. The parsing + grouping logic is unchanged. Per `[[cloudflare-lock-in-is-leverage]]`: the emitted server targets CF Workers HTTP transport by default; `--transport=stdio` for local dev tools. --- ## CLI interface (new flag) ```bash # Emit Claude Code skill (existing behaviour — unchanged) node bin/forge-skill-from-openapi.mjs <spec-url> <output-dir> --name my-api # NEW: emit MCP server (TypeScript, CF Workers HTTP transport) node bin/forge-skill-from-openapi.mjs <spec-url> <output-dir> \ --name my-api \ --target mcp-server \ --transport http # or --transport stdio --base-url https://api.example.com # Output files: # <output-dir>/ # src/index.ts (Hono + MCP server, all tools wired) # src/schemas.ts (Zod schemas for every operation input + output) # wrangler.toml (CF Workers config) # package.json # tsconfig.json # .claude.json (registration snippet) ``` --- ## Extension seam in forge-skill-from-openapi.mjs The existing script ends with a `generateSkillMarkdown(spec, args)` call. The extension adds a branch before that call: ```javascript // Pseudocode — shows WHERE to branch, not final code const target = args.flags['target'] ?? 'skill'; const transport = args.flags['transport'] ?? 'http'; if (target === 'mcp-server') { await generateMcpServer(spec, args, transport); } else { await generateSkillMarkdown(spec, args); // existing path — untouched } ``` --- ## generateMcpServer — pseudocode ```javascript async function generateMcpServer(spec, args, transport) { const operations = extractOperations(spec); // existing helper const name = slugify(args.flags['name'] ?? spec.info.title); const baseUrl = args.flags['base-url'] ?? spec.servers?.[0]?.url ?? 'https://api.example.com'; const outDir = args.positional[1]; // 1. Emit src/schemas.ts const schemas = operations.map(op => buildZodSchema(op)); await writeFile(join(outDir, 'src/schemas.ts'), renderSchemasTemplate(schemas)); // 2. Emit src/index.ts const toolDefs = operations.map(op => buildToolDef(op)); const handlerCode = operations.map(op => buildHandler(op, baseUrl)); const indexSrc = transport === 'http' ? renderHttpTemplate({ name, toolDefs, handlerCode }) : renderStdioTemplate({ name, toolDefs, handlerCode }); await writeFile(join(outDir, 'src/index.ts'), indexSrc); // 3. Emit wrangler.toml (HTTP only) if (transport === 'http') { await writeFile(join(outDir, 'wrangler.toml'), renderWranglerTemplate({ name })); } // 4. Emit package.json + tsconfig.json await writeFile(join(outDir, 'package.json'), renderPackageJson({ name })); await writeFile(join(outDir, 'tsconfig.json'), renderTsconfig()); // 5. Emit .claude.json registration snippet const reg = transport === 'http' ? renderHttpRegistration({ name }) : renderStdioRegistration({ name, outDir }); await writeFile(join(outDir, '.claude.json'), reg); console.log(`MCP server scaffolded at ${outDir} (${transport} transport)`); } ``` --- ## buildZodSchema — pseudocode Converts an OpenAPI operation's parameters + requestBody into a Zod schema string. ```javascript function buildZodSchema(op) { // op = { operationId, method, path, parameters, requestBody, responses } const fields = []; // Path + query params → z.object fields for (const param of op.parameters ?? []) { const zodType = oasTypeToZod(param.schema); // 'string' → 'z.string()', 'integer' → 'z.number().int()' const required = param.required ?? false; fields.push(` ${param.name}: ${zodType}${required ? '' : '.optional()'}` + `.describe(${JSON.stringify(param.description ?? param.name)})`); } // requestBody (application/json) → spread into z.object const body = op.requestBody?.content?.['application/json']?.schema; if (body?.properties) { for (const [key, sch] of Object.entries(body.properties)) { const zodType = oasTypeToZod(sch); const isRequired = (body.required ?? []).includes(key); fields.push(` ${key}: ${zodType}${isRequired ? '' : '.optional()'}`); } } const schemaName = `${titlify(op.operationId)}InputSchema`; return `export const ${schemaName} = z.object({\n${fields.join(',\n')}\n});`; } function oasTypeToZod(schema) { if (!schema) return 'z.unknown()'; switch (schema.type) { case 'string': return schema.enum ? `z.enum(${JSON.stringify(schema.enum)})` : 'z.string()'; case 'integer': return 'z.number().int()'; case 'number': return 'z.number()'; case 'boolean': return 'z.boolean()'; case 'array': return `z.array(${oasTypeToZod(schema.items)})`; case 'object': return 'z.record(z.unknown())'; default: return 'z.unknown()'; } } ``` --- ## buildHandler — pseudocode Each operation becomes a `CallToolRequestSchema` branch that: 1. Parses input with the Zod schema. 2. Constructs the HTTP request to `baseUrl`. 3. Returns `{ content: [{ type: 'text', text: JSON.stringify(result) }] }`. ```javascript function buildHandler(op, baseUrl) { const schemaName = `${titlify(op.operationId)}InputSchema`; const pathParams = (op.parameters ?? []).filter(p => p.in === 'path').map(p => p.name); const queryParams = (op.parameters ?? []).filter(p => p.in === 'query').map(p => p.name); const hasBody = !!op.requestBody; return ` if (request.params.name === '${op.operationId}') { const input = ${schemaName}.parse(request.params.arguments); let url = '${baseUrl}${op.path}'; ${pathParams.map(p => `url = url.replace('{${p}}', String(input.${p}));`).join('\n ')} ${queryParams.length ? `const qs = new URLSearchParams(${queryParams.map(p => `['${p}', input.${p}]`).join(', ')});\n url += '?' + qs.toString();` : ''} const res = await fetch(url, { method: '${op.method.toUpperCase()}', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (env.API_KEY ?? '') }, ${hasBody ? `body: JSON.stringify(input),` : ''} }); const data = await res.json(); return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; } `.trim(); } ``` --- ## Output file set ``` <output-dir>/ src/ index.ts Hono + MCP SDK server, all tools wired schemas.ts Zod schemas for every operation wrangler.toml CF Workers config (HTTP transport only) package.json @modelcontextprotocol/sdk + hono + zod deps tsconfig.json strict, NodeNext, ES2022 .claude.json Registration snippet (copy into ~/.claude.json or repo .claude.json) ``` --- ## Implementation checklist (when you decide to ship this) + [ ] Add `--target` and `--transport` to `parseArgs()` in `bin/forge-skill-from-openapi.mjs` + [ ] Implement `oasTypeToZod()` helper (pseudocode above) + [ ] Implement `buildZodSchema()`, `buildHandler()`, `buildToolDef()` helpers + [ ] Add `renderHttpTemplate()` / `renderStdioTemplate()` string templates (inline template strings using `tpl()` helper already in the script) + [ ] Add `renderSchemasTemplate()`, `renderWranglerTemplate()`, `renderPackageJson()` etc. + [ ] Add `--target=mcp-server` to the CLI usage comment at top of file + [ ] Add integration test: feed a known OpenAPI spec, assert the output files exist and TypeScript compiles (`tsc --noEmit`) + [ ] Add a note in `19-mcp-authoring/SKILL.md` submodules list --- ## Relationship to existing forge-from-openapi skill The existing `commands/forge-from-openapi.md` (and its Claude Code skill equivalent) emits markdown Claude Code skills. This extension is **additive** — it does not change that output path. The `--target=skill` flag (default) preserves existing behaviour exactly. All new code lives in new helper functions; the existing `generateSkillMarkdown()` function is not touched. --- ## See + `bin/forge-skill-from-openapi.mjs` — the script to extend (read before implementing) + `19-mcp-authoring/http-server-on-workers.md` — the HTTP server template the emitter generates + `19-mcp-authoring/stdio-server-template.md` — the stdio template + `[[cloudflare-lock-in-is-leverage]]` — default to HTTP+Workers transport in the emitter + `[[contract-first-ai]]` — every emitted tool must have Zod at its boundary + modelcontextprotocol.io/docs/concepts/tools — tool schema spec -
http-server-on-workers.md 12.2 KB
--- name: "MCP HTTP Server on CF Workers" description: "MCP server hosted on Cloudflare Workers using HTTP+SSE transport. Hono router + @modelcontextprotocol/sdk StreamableHTTPServerTransport. Auth via CF Zero Trust Access or Bearer token. wrangler.toml binding config. Registration in .claude.json as a remote server." updated: "2026-06-18" --- # MCP HTTP Server on Cloudflare Workers Workers + HTTP transport = zero infra, global presence, per-request billing, CF Zero Trust auth for free. This is the `[[cloudflare-lock-in-is-leverage]]` play for MCP. Ref: modelcontextprotocol.io/docs/concepts/transports, `@modelcontextprotocol/sdk` StreamableHTTPServerTransport. --- ## Why HTTP+SSE on Workers (not stdio) - **Shared access** — multiple Claude Code users / agents hit the same endpoint, authenticated individually. - **CF primitives** — D1, R2, KV, Vectorize, AI binding wired directly in `env`. No REST API calls. - **Zero Trust** — put the MCP endpoint behind CF Access for BYOD auth without writing auth code. - **Streaming** — SSE allows progress events for long-running tool calls (e.g. bulk D1 migrations). - **Cost** — Workers free tier covers ~10M requests/mo. MCP tool calls are lightweight JSON-RPC. --- ## Package setup ```json // package.json (in your Workers monorepo or standalone worker) { "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "hono": "^4.7.0", "zod": "^3.23.0" }, "devDependencies": { "wrangler": "^4.0.0", "typescript": "^5.9.0" } } ``` --- ## wrangler.toml ```toml name = "my-mcp-worker" main = "src/index.ts" compatibility_date = "2025-09-01" compatibility_flags = ["nodejs_compat"] [ai] binding = "AI" [[d1_databases]] binding = "DB" database_name = "production" database_id = "your-d1-id" [[kv_namespaces]] binding = "KV" id = "your-kv-id" [vars] MCP_SECRET = "set-via-wrangler-secret" # wrangler secret put MCP_SECRET # CF Access service token for machine-to-machine auth (optional) # CF_ACCESS_CLIENT_ID = "set-via-wrangler-secret" # CF_ACCESS_CLIENT_SECRET = "set-via-wrangler-secret" ``` --- ## Full Worker: `src/index.ts` ```typescript import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; // ── CF Worker env type ──────────────────────────────────────────────────────── interface Env { AI: Ai; DB: D1Database; KV: KVNamespace; MCP_SECRET: string; } // ── Zod schemas (all tool boundaries) ──────────────────────────────────────── const SearchKnowledgeBaseInputSchema = z.object({ query: z.string().min(1).max(500).describe('Semantic search query'), top_k: z.number().int().min(1).max(20).default(5).describe('Number of results to return'), }); const SearchKnowledgeBaseOutputSchema = z.object({ results: z.array( z.object({ id: z.string(), score: z.number(), text: z.string(), source: z.string(), }), ), }); const RunSqlInputSchema = z.object({ sql: z.string().min(1).describe('READ-ONLY SQL query (SELECT only)'), params: z.array(z.union([z.string(), z.number(), z.null()])).optional(), }); // ── MCP server factory (one per request — stateless HTTP transport) ─────────── function createMcpServer(env: Env): Server { const server = new Server( { name: 'cf-worker-mcp', version: '1.0.0' }, { capabilities: { tools: {}, resources: {} } }, ); // Tool: search-knowledge-base (Workers AI + Vectorize) server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'search_knowledge_base', description: 'Semantic search over the project knowledge base using Workers AI embeddings and Vectorize. Returns ranked text chunks with source attribution.', inputSchema: { type: 'object' as const, properties: { query: { type: 'string', description: 'Search query' }, top_k: { type: 'number', description: 'Results to return (1-20)', default: 5 }, }, required: ['query'], }, }, { name: 'run_read_query', description: 'Run a read-only SQL SELECT against the project D1 database. Rejects any mutating SQL.', inputSchema: { type: 'object' as const, properties: { sql: { type: 'string', description: 'SELECT query' }, params: { type: 'array', items: {}, description: 'Positional params' }, }, required: ['sql'], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { // ── search_knowledge_base ───────────────────────────────────────────────── if (request.params.name === 'search_knowledge_base') { const input = SearchKnowledgeBaseInputSchema.parse(request.params.arguments); try { const embedResult = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: [input.query], }); // @ts-expect-error — Workers AI types vary by model const vector: number[] = embedResult.data[0].values; // NOTE: add Vectorize binding to wrangler.toml if using vector search // const matches = await env.VECTORIZE.query(vector, { topK: input.top_k }); // Stub result (wire Vectorize for production) const output = SearchKnowledgeBaseOutputSchema.parse({ results: [{ id: 'stub-1', score: 0.99, text: 'Stub — wire Vectorize binding.', source: 'wrangler.toml' }], }); return { content: [{ type: 'text' as const, text: JSON.stringify(output, null, 2) }] }; } catch (err) { return { isError: true, content: [ { type: 'text' as const, text: JSON.stringify({ code: 'AI_ERROR', message: String(err) }), }, ], }; } } // ── run_read_query ──────────────────────────────────────────────────────── if (request.params.name === 'run_read_query') { const input = RunSqlInputSchema.parse(request.params.arguments); // Hard guard: only SELECT statements if (!/^\s*SELECT\b/i.test(input.sql)) { return { isError: true, content: [ { type: 'text' as const, text: JSON.stringify({ code: 'FORBIDDEN', message: 'Only SELECT queries are permitted.' }), }, ], }; } try { const result = await env.DB.prepare(input.sql) .bind(...(input.params ?? [])) .all(); return { content: [{ type: 'text' as const, text: JSON.stringify({ rows: result.results, count: result.results.length }, null, 2) }], }; } catch (err) { return { isError: true, content: [{ type: 'text' as const, text: JSON.stringify({ code: 'DB_ERROR', message: String(err) }) }], }; } } return { isError: true, content: [{ type: 'text' as const, text: `Unknown tool: ${request.params.name}` }], }; }); // Resource: kv/{key} server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: 'kv://{key}', name: 'KV Store Value', description: 'Read a value from the project KV namespace by key.', mimeType: 'text/plain', }, ], })); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; const match = uri.match(/^kv:\/\/(.+)$/); if (!match?.[1]) { return { contents: [{ uri, mimeType: 'text/plain', text: 'Invalid KV URI' }] }; } const value = await env.KV.get(match[1]); return { contents: [ { uri, mimeType: 'text/plain', text: value ?? `Key "${match[1]}" not found in KV namespace`, }, ], }; }); return server; } // ── Hono app ───────────────────────────────────────────────────────────────── const app = new Hono<{ Bindings: Env }>(); app.use('/mcp', cors({ origin: '*', allowHeaders: ['Content-Type', 'Authorization', 'mcp-session-id'] })); // Bearer auth middleware app.use('/mcp', async (c, next) => { const auth = c.req.header('Authorization'); if (!auth || auth !== `Bearer ${c.env.MCP_SECRET}`) { return c.json({ error: 'Unauthorized' }, 401); } return next(); }); // MCP endpoint — stateless HTTP transport (one server instance per request) app.all('/mcp', async (c) => { const server = createMcpServer(c.env); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), }); await server.connect(transport); const req = c.req.raw; const response = await transport.handleRequest(req); return response; }); // Health check (unauthenticated — used by deploy-verifier) app.get('/health', (c) => c.json({ status: 'ok', server: 'cf-worker-mcp', ts: Date.now() })); export default app; ``` --- ## Authentication options ### Option A — Bearer token (simple, secret-based) Set via `wrangler secret put MCP_SECRET`. The middleware above enforces it. Register in `.claude.json` as: ```json { "mcpServers": { "cf-worker-mcp": { "url": "https://my-mcp-worker.workers.dev/mcp", "headers": { "Authorization": "Bearer <MCP_SECRET>" } } } } ``` ### Option B — CF Zero Trust Access (team/enterprise) 1. Create a CF Access application protecting `https://my-mcp-worker.workers.dev/mcp`. 2. Create a Service Token (client ID + secret) for each Claude Code user / agent. 3. Pass `CF-Access-Client-Id` + `CF-Access-Client-Secret` as headers in `.claude.json`. 4. Remove the Bearer middleware — CF Access handles it at the edge before the Worker runs. Per `05-architecture-and-stack/cf-zero-trust-access.md`. --- ## Deploy + smoke-test ```bash # Deploy wrangler deploy # Set secrets wrangler secret put MCP_SECRET # Smoke-test via inspector (points at live Worker) npx @modelcontextprotocol/inspector https://my-mcp-worker.workers.dev/mcp # Health check curl https://my-mcp-worker.workers.dev/health ``` Post-deploy E2E: add a Playwright spec that calls `/health` and verifies `200 { status: 'ok' }`. Per `08-deploy-and-runtime-verification/` deploy mandate. --- ## Stateful sessions via Durable Objects (optional) For tools that need conversation-scoped state (e.g. multi-turn SQL query builder): ```typescript // In wrangler.toml [[durable_objects.bindings]] name = "MCP_SESSION" class_name = "McpSessionDO" // In src/mcp-session.do.ts — store session state in DO storage export class McpSessionDO implements DurableObject { // Per modelcontextprotocol.io: session ID from mcp-session-id header // maps 1:1 to DO instance } ``` Per `05-architecture-and-stack/cf-agents-do-pattern.md`. --- ## Rules - One `createMcpServer()` call per request — `StreamableHTTPServerTransport` is stateless. - SELECT-only guard on `run_read_query` — never expose a write-capable SQL tool without explicit `approval-required` tier per `rules/autonomous-engineering.md`. - All secrets via `wrangler secret put` — never in `wrangler.toml` vars. - `/health` always unauthenticated — used by deploy-verifier. ## See - `19-mcp-authoring/stdio-server-template.md` — local stdio variant - `[[cloudflare-lock-in-is-leverage]]` — Workers over third-party MCP hosts - `[[ai-agent-supervisor]]` — tool scope and approval tiers - `05-architecture-and-stack/cf-agents-do-pattern.md` — stateful DO sessions - modelcontextprotocol.io/docs/concepts/transports -
SKILL.md 5.4 KB
--- name: "mcp-authoring" description: "When to author an MCP server, architecture overview (tools/resources/prompts), stdio vs HTTP+SSE transport tradeoffs, registration in .claude.json. Sub-modules: stdio-server-template.md (full TS code), http-server-on-workers.md (Hono + SSE on CF Workers), forge-mcp-from-openapi.md (extend forge script with --target=mcp-server). Fires when user asks to 'build an MCP server', 'expose X as an MCP tool', or 'add MCP to my Worker'." when_to_use: "Any request to author, scaffold, or extend an MCP server — whether standalone stdio for local dev tools or HTTP transport on CF Workers for shareable AI integrations." effort: "high" model: "sonnet" priority: 5 pack: "ai" stage: stable triggers: - "MCP server" - "model context protocol" - "mcp tool" - "mcp resource" - "mcp prompt" - "stdio transport" - "claude.json" - "expose API as MCP" paths: - "src/worker/**" - "workers/**" - "mcp-server/**" - ".claude.json" submodules: - stdio-server-template.md - http-server-on-workers.md - forge-mcp-from-openapi.md --- # 19 — MCP Authoring Three primitive types: - **Tools** — callable functions (JSON schema input → structured output). Model decides when to call. "Do something." - **Resources** — addressable content (files, DB rows, feeds) returned as text/binary. "Read something." - **Prompts** — reusable templates with typed args. "Fill and inject." Source authority: modelcontextprotocol.io/introduction, `@modelcontextprotocol/sdk` NPM. ## When to author an MCP server Build when: - A REST API/Worker would provide genuine agent value and schema-wrapping cost < benefit - Tool set needs sharing across multiple Claude sessions without copy-pasting prompts - CF Worker owns business logic and you want Claude persistent auth-aware access (HTTP transport = zero extra infra) - Extending the forge pipeline (`--target=mcp-server` — see `forge-mcp-from-openapi.md`) Do NOT build when a simple `[[hono-api]]` route + direct `fetch` suffices — MCP adds SDK overhead not justified for one-off integrations. ## Architecture ``` Claude Code / Claude Desktop │ JSON-RPC 2.0 ▼ ┌──────────────┐ │ MCP Server │ │ ┌────────┐ │ │ │ tools │ │ ← JSON-schema validated inputs + Zod-validated outputs │ ├────────┤ │ │ │resourc.│ │ ← URI-addressed, MIME-typed │ ├────────┤ │ │ │prompts │ │ ← Named templates with typed args │ └────────┘ │ └──────────────┘ │ ▼ External system (D1 / R2 / Vectorize / external API) ``` Every tool input: `z.parse()` before hitting the system. Every result: Zod-validated before returning. Per `[[contract-first-ai]]` and `[[zod-everywhere]]`. ## Transport decision | Criterion | stdio | HTTP + SSE | |---|---|---| | Where it runs | Local process, same machine as Claude | Any origin — CF Workers, remote server | | Auth | None (process-level trust) | HTTP headers, Bearer tokens, CF Zero Trust | | Session state | Process lifetime | DO / KV per session ID | | Streaming | Native (stdout) | SSE (`text/event-stream`) | | Setup | `~/.claude.json` mcpServers entry | CF Worker deploy + `.claude.json` remote entry | | Best for | Dev tools, local scripts, secret-laden CLIs | Shared team tools, SaaS integrations, per-user auth | Per `[[cloudflare-lock-in-is-leverage]]`: prefer HTTP on CF Workers over any third-party MCP host. ## .claude.json registration ### stdio server ```json { "mcpServers": { "my-local-tool": { "command": "node", "args": ["/absolute/path/to/mcp-server/dist/index.js"], "env": { "DB_PATH": "/Users/Apple/data/mydb.sqlite" } } } } ``` ### HTTP server (CF Workers) ```json { "mcpServers": { "my-worker-tool": { "url": "https://my-mcp.workers.dev/mcp", "headers": { "Authorization": "Bearer ${MY_MCP_TOKEN}" } } } } ``` Place at `~/.claude.json` (global) or `.claude.json` at repo root (project-scoped). ## Sub-modules - `stdio-server-template.md` — complete TypeScript stdio server with sample tool + resource + prompt - `http-server-on-workers.md` — Hono + MCP SDK + SSE on CF Workers, wrangler.toml, auth - `forge-mcp-from-openapi.md` — plan for extending `bin/forge-skill-from-openapi.mjs` to emit MCP servers ## Quality gates (every MCP server) 1. All tool inputs have a Zod schema — never accept raw `unknown` 2. All tool results conform to a Zod output schema before returning 3. Errors return MCP `isError: true` with structured `{ code, message }` — never throw raw JS errors 4. Every tool `description` ≤2 sentences, specific enough for an LLM to decide when to call it 5. No secret values in tool schemas or resource URIs — pass via `env` block in `.claude.json` 6. Smoke-test with `npx @modelcontextprotocol/inspector` before registering ## Cross-links - `[[cloudflare-lock-in-is-leverage]]` — Workers HTTP transport over any third-party MCP host - `[[ai-agent-supervisor]]` — MCP tools are the supervised boundary for agent actions - `[[contract-first-ai]]` — Zod at every tool boundary - `[[hono-api]]` — HTTP transport built on Hono - `05-architecture-and-stack/cf-agents-do-pattern.md` — stateful MCP sessions via Durable Objects - `rules/ai-agent-security.md` — tool scope minimization, input sanitization, rate limiting -
stdio-server-template.md 10.2 KB
--- name: "MCP stdio Server Template" description: "Complete TypeScript MCP server with stdio transport. Includes sample tool (D1 query), sample resource (R2 file), sample prompt (brief-generator). Uses @modelcontextprotocol/sdk/server. Copy-paste starting point for local dev tools and CLI-resident integrations." updated: "2026-06-18" --- # MCP stdio Server — TypeScript Template Full working server. Copy `mcp-server/` into any repo, wire `tsconfig.json`, run `npx tsc && node dist/index.js` — then register in `.claude.json`. Ref: modelcontextprotocol.io/docs/concepts/architecture, `@modelcontextprotocol/sdk` v1.x. --- ## Package setup ```json // mcp-server/package.json { "name": "my-mcp-server", "version": "1.0.0", "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "dev": "tsx src/index.ts", "start": "node dist/index.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.23.0" }, "devDependencies": { "typescript": "^5.9.0", "tsx": "^4.19.0", "@types/node": "^22.0.0" } } ``` ```json // mcp-server/tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "rootDir": "src", "strict": true, "esModuleInterop": true, "skipLibCheck": true } } ``` --- ## Full server: `mcp-server/src/index.ts` ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import Database from 'better-sqlite3'; // swap for D1 REST API in CI import { readFileSync } from 'node:fs'; // ── Zod schemas ────────────────────────────────────────────────────────────── const QueryDonorsInputSchema = z.object({ min_amount: z.number().min(0).describe('Minimum donation amount in USD'), limit: z.number().int().min(1).max(100).default(20).describe('Max rows to return'), }); const QueryDonorsOutputSchema = z.object({ donors: z.array( z.object({ id: z.string(), name: z.string(), total_donated: z.number(), last_gift_date: z.string(), }), ), count: z.number(), }); const GenerateBriefInputSchema = z.object({ organization: z.string().describe('Nonprofit name'), quarter: z.string().describe('Quarter, e.g. Q1 2026'), total_raised: z.number().describe('Total dollars raised this quarter'), top_program: z.string().describe('Name of the top-funded program'), }); // ── Server init ────────────────────────────────────────────────────────────── const server = new Server( { name: 'nonprofit-mcp', version: '1.0.0', }, { capabilities: { tools: {}, resources: {}, prompts: {}, }, }, ); // ── Tool: query-donors ─────────────────────────────────────────────────────── server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'query_donors', description: 'Query donor records from the local D1 database by minimum donation amount. Returns donor name, total donated, and last gift date.', inputSchema: { type: 'object' as const, properties: { min_amount: { type: 'number', description: 'Minimum donation amount in USD', }, limit: { type: 'number', description: 'Max rows to return (1-100)', default: 20, }, }, required: ['min_amount'], }, }, ], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === 'query_donors') { // Validate input at the boundary const input = QueryDonorsInputSchema.parse(request.params.arguments); try { // Swap for D1 REST API or Drizzle query in production const db = new Database(process.env['DB_PATH'] ?? ':memory:'); const rows = db .prepare( 'SELECT id, name, total_donated, last_gift_date FROM donors WHERE total_donated >= ? LIMIT ?', ) .all(input.min_amount, input.limit); // Validate output at the boundary const output = QueryDonorsOutputSchema.parse({ donors: rows, count: rows.length }); return { content: [ { type: 'text' as const, text: JSON.stringify(output, null, 2), }, ], }; } catch (err) { // MCP error convention: isError=true + structured message return { isError: true, content: [ { type: 'text' as const, text: JSON.stringify({ code: 'DB_ERROR', message: err instanceof Error ? err.message : 'Unknown database error', }), }, ], }; } } return { isError: true, content: [{ type: 'text' as const, text: `Unknown tool: ${request.params.name}` }], }; }); // ── Resource: annual-report/{year} ────────────────────────────────────────── server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [ { uri: 'file:///reports/{year}.md', name: 'Annual Report', description: 'Markdown annual report for the given year. Substitute {year} with e.g. 2025.', mimeType: 'text/markdown', }, ], })); server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const uri = request.params.uri; const match = uri.match(/^file:\/\/\/reports\/(\d{4})\.md$/); if (!match?.[1]) { return { contents: [ { uri, mimeType: 'text/plain', text: 'Error: URI must match file:///reports/{year}.md', }, ], }; } const year = match[1]; const reportsDir = process.env['REPORTS_DIR'] ?? './reports'; try { const content = readFileSync(`${reportsDir}/${year}.md`, 'utf-8'); return { contents: [ { uri, mimeType: 'text/markdown', text: content, }, ], }; } catch { return { contents: [ { uri, mimeType: 'text/plain', text: `Annual report for ${year} not found at ${reportsDir}/${year}.md`, }, ], }; } }); // ── Prompt: donor-brief-generator ─────────────────────────────────────────── server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [ { name: 'donor_brief_generator', description: 'Generates a concise quarterly donor brief for a nonprofit.', arguments: [ { name: 'organization', description: 'Nonprofit name', required: true }, { name: 'quarter', description: 'Quarter, e.g. Q1 2026', required: true }, { name: 'total_raised', description: 'Total dollars raised', required: true }, { name: 'top_program', description: 'Top-funded program name', required: true }, ], }, ], })); server.setRequestHandler(GetPromptRequestSchema, async (request) => { if (request.params.name === 'donor_brief_generator') { const args = GenerateBriefInputSchema.parse(request.params.arguments); return { description: 'Quarterly donor brief prompt', messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Write a 2-paragraph donor brief for ${args.organization}. Quarter: ${args.quarter} Total raised: $${args.total_raised.toLocaleString()} Top program: ${args.top_program} Be factual, specific, and professional. No fluff. Include the dollar figure verbatim.`, }, }, ], }; } throw new Error(`Unknown prompt: ${request.params.name}`); }); // ── Boot ───────────────────────────────────────────────────────────────────── async function main() { const transport = new StdioServerTransport(); await server.connect(transport); // stdio transport reads from stdin, writes to stdout — no port needed // Log to stderr only: stdout is reserved for JSON-RPC protocol messages process.stderr.write('nonprofit-mcp server running on stdio\n'); } main().catch((err) => { process.stderr.write(`Fatal: ${err.message}\n`); process.exit(1); }); ``` --- ## .claude.json registration ```json { "mcpServers": { "nonprofit-mcp": { "command": "node", "args": ["/Users/Apple/emdash/mcp-server/dist/index.js"], "env": { "DB_PATH": "/Users/Apple/emdash/data/donors.db", "REPORTS_DIR": "/Users/Apple/emdash/reports" } } } } ``` --- ## Smoke-test ```bash npx @modelcontextprotocol/inspector node dist/index.js # Opens at http://localhost:5173 — test each tool/resource/prompt interactively ``` --- ## Rules - **stderr only for logs** — stdout is the JSON-RPC wire; any non-protocol write breaks the transport. - **Zod at every boundary** — input schema before touching the system, output schema before returning. - **isError pattern** — never throw from a tool handler; return `{ isError: true, content: [{ type: 'text', text: JSON.stringify({ code, message }) }] }`. - **No secrets in tool schemas** — secrets live in `.claude.json` `env` block and arrive via `process.env`. ## See - `19-mcp-authoring/http-server-on-workers.md` — HTTP+SSE variant for CF Workers - `19-mcp-authoring/forge-mcp-from-openapi.md` — auto-generate from OpenAPI spec - `[[cloudflare-lock-in-is-leverage]]` — when to prefer HTTP transport - modelcontextprotocol.io/docs/concepts/tools
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.