Claude Cursor GitHub Copilot opencode Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download heymegabyte-claude-skills-19-mcp-authoring-e7acb91.zip · 14 KB
Part of heymegabyte/claude-skills — 18 skills

Install

skills CLI npx skills add https://github.com/heymegabyte/claude-skills/tree/master/19-mcp-authoring
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install heymegabyte-claude-skills@llmmart
Git 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 — 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

{
  "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 + 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
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.

No comments yet.

Reviews (0)

No reviews yet.

Related