Jake Shore 8d65417afe Add 11 MCP agent skills to repo — 550KB of encoded pipeline knowledge
Skills added:
- mcp-api-analyzer (43KB) — Phase 1: API analysis
- mcp-server-builder (88KB) — Phase 2: Server build
- mcp-server-development (31KB) — TS MCP patterns
- mcp-app-designer (85KB) — Phase 3: Visual apps
- mcp-apps-integration (20KB) — structuredContent UI
- mcp-apps-official (48KB) — MCP Apps SDK
- mcp-apps-merged (39KB) — Combined apps reference
- mcp-localbosses-integrator (61KB) — Phase 4: LocalBosses wiring
- mcp-qa-tester (113KB) — Phase 5: Full QA framework
- mcp-deployment (17KB) — Phase 6: Production deploy
- mcp-skill (exa integration)

These skills are the encoded knowledge that lets agents build
production-quality MCP servers autonomously through the pipeline.
2026-02-06 06:36:37 -05:00

86 KiB
Raw Blame History

MCP Server Builder — Phase 2: Build the MCP Server

When to use this skill: You have a completed {service}-api-analysis.md from Phase 1 and need to produce a fully compiled MCP server. This skill contains every pattern, template, and standard needed to build from scratch.

What this covers: Project scaffolding, TypeScript MCP server with Feb 2026 SDK standards (annotations, title, outputSchema, structuredContent, lazy loading, Zod validation), auth patterns, error handling, rate limiting, circuit breaker, structured logging, pagination strategies, request timeouts, and tool description optimization.

Pipeline position: Phase 2 of 6 → Input from mcp-api-analyzer (Phase 1), output feeds mcp-app-designer (Phase 3) and mcp-localbosses-integrator (Phase 4)

MCP Spec Compliance: 2025-11-25 spec, TypeScript SDK ^1.26.0


1. Inputs & Outputs

Input: {service}-api-analysis.md (from Phase 1) Output: Complete MCP server directory:

{service}-mcp/
├── src/
│   ├── index.ts              # Server entry, transport selection, orchestration
│   ├── client.ts             # API client (auth, timeouts, circuit breaker, retry, rate limiting)
│   ├── logger.ts             # Structured JSON logging on stderr
│   ├── tools/
│   │   ├── index.ts          # Tool registry + lazy loader
│   │   ├── health.ts         # health_check tool (always included)
│   │   ├── {group1}.ts       # Tool group: definitions + handlers
│   │   ├── {group2}.ts       # Tool group: definitions + handlers
│   │   └── ...
│   └── types.ts              # Shared TypeScript interfaces
├── app-ui/                   # (Created in Phase 3)
├── dist/                     # Compiled output
├── package.json
├── tsconfig.json
├── .env.example
├── .gitignore
└── README.md

When to use one-file pattern instead: If the analysis doc shows ≤15 tools total, put everything in src/index.ts. Split into modules only when there are 15+ tools or multiple tool groups.

Reference template: mcp-diagrams/mcp-servers/template/ — use as starting point, then customize.


2. Template Variable Reference

IMPORTANT: All templates use placeholder variables that MUST be replaced before use. Search-and-replace all of these:

Pattern Convention Example Used In
{service} lowercase, hyphenated calendly directory names, package name, MCP name
{SERVICE} UPPER_SNAKE_CASE CALENDLY environment variable names
{Service} PascalCase Calendly class names, display titles
{Service Name} Title Case with spaces Calendly README headings, descriptions
{group} lowercase contacts tool group filenames
{group_name} lowercase with underscores contact_management group identifiers
{resources} lowercase plural contacts tool names, API endpoints
{resource} lowercase singular contact tool names, API endpoints
{Resource} PascalCase singular Contact TypeScript type names

Verification step: After building, run grep -r '{service}\|{SERVICE}\|{Service}\|{group}\|{resource}\|{Resource}' src/ — output should be empty.


3. Project Scaffolding

Step 1: Create directory and init

mkdir -p {service}-mcp/src/tools
cd {service}-mcp

# Initialize package.json
cat > package.json << 'EOF'
{
  "name": "mcp-server-{service}",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "bin": {
    "mcp-server-{service}": "dist/index.js"
  },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js",
    "start:http": "MCP_TRANSPORT=http node dist/index.js",
    "dev": "tsx src/index.ts"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.26.0",
    "zod": "^3.25.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "tsx": "^4.7.0",
    "typescript": "^5.5.0"
  }
}
EOF

npm install

Security Note (Feb 2026): v1.26.0 fixes GHSA-345p-7cg4-v4c7 (cross-client data leak in shared transport instances). Always use ≥1.26.0.

SDK v2 Warning: The TypeScript SDK v2 is in pre-alpha (stable expected Q1 2026). Pin to v1.x for production. v1.x will receive bug fixes for 6+ months after v2 ships.

Zod v4 Warning: Do NOT use Zod v4.x with MCP SDK v1.x — known incompatibility (issue #1429, w._parse is not a function). The ^3.25.0 pin is correct and will not pull in Zod v4.

Step 2: TypeScript config

cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "app-ui"]
}
EOF

Step 3: .env.example

cat > .env.example << 'EOF'
# {Service Name} MCP Server Configuration
{SERVICE}_API_KEY=your_api_key_here
# {SERVICE}_API_SECRET=your_secret_here        # If OAuth2
# {SERVICE}_BASE_URL=https://api.example.com   # Override for sandbox
# {SERVICE}_ACCOUNT_ID=your_account_id         # If multi-tenant

# Transport (optional — default: stdio)
# MCP_TRANSPORT=http
# MCP_HTTP_PORT=3000
EOF

Step 4: .gitignore

cat > .gitignore << 'EOF'
node_modules/
dist/
.env
*.log
EOF

4. Core Files — Templates

4.1 src/types.ts — Shared Types

// Types derived from the API analysis document

export interface PaginationParams {
  page?: number;
  pageSize?: number;
}

export interface PaginatedResponse<T> {
  data: T[];
  meta: {
    total: number;
    page: number;
    pageSize: number;
    hasMore: boolean;
  };
}

export interface ToolGroup {
  name: string;
  tools: ToolDefinition[];
  handlers: Record<string, ToolHandler>;
  loaded: boolean;
}

export interface ToolDefinition {
  name: string;
  title: string;
  description: string;
  inputSchema: {
    type: "object";
    properties: Record<string, unknown>;
    required?: string[];
  };
  outputSchema?: Record<string, unknown>;
  annotations?: {
    readOnlyHint?: boolean;
    destructiveHint?: boolean;
    idempotentHint?: boolean;
    openWorldHint?: boolean;
  };
  icons?: Array<{ src: string; mimeType: string }>;
}

export type ToolHandler = (args: Record<string, unknown>) => Promise<{
  content: Array<{ type: string; text: string } | { type: "resource_link"; uri: string; name: string; mimeType?: string }>;
  structuredContent?: unknown;
  isError?: boolean;
}>;

4.2 src/logger.ts — Structured Logging

// Structured JSON logger — all output to stderr (stdout reserved for MCP protocol)
// Logs: tool invocations, API calls, errors, with request IDs and timing

import { randomUUID } from "crypto";

type LogLevel = "debug" | "info" | "warn" | "error";

interface LogEntry {
  ts: string;
  level: LogLevel;
  event: string;
  requestId?: string;
  durationMs?: number;
  [key: string]: unknown;
}

class Logger {
  private serverName: string;

  constructor(serverName: string) {
    this.serverName = serverName;
  }

  private write(level: LogLevel, event: string, data: Record<string, unknown> = {}): void {
    const entry: LogEntry = {
      ts: new Date().toISOString(),
      level,
      event,
      server: this.serverName,
      ...data,
    };
    console.error(JSON.stringify(entry));
  }

  debug(event: string, data?: Record<string, unknown>): void {
    this.write("debug", event, data);
  }

  info(event: string, data?: Record<string, unknown>): void {
    this.write("info", event, data);
  }

  warn(event: string, data?: Record<string, unknown>): void {
    this.write("warn", event, data);
  }

  error(event: string, data?: Record<string, unknown>): void {
    this.write("error", event, data);
  }

  // Generate a request ID for tracing
  requestId(): string {
    return randomUUID().slice(0, 8);
  }

  // Time an async operation
  async time<T>(event: string, fn: () => Promise<T>, data?: Record<string, unknown>): Promise<T> {
    const requestId = this.requestId();
    const start = performance.now();
    this.info(`${event}.start`, { requestId, ...data });
    try {
      const result = await fn();
      const durationMs = Math.round(performance.now() - start);
      this.info(`${event}.done`, { requestId, durationMs, ...data });
      return result;
    } catch (error) {
      const durationMs = Math.round(performance.now() - start);
      this.error(`${event}.error`, {
        requestId,
        durationMs,
        error: error instanceof Error ? error.message : String(error),
        stack: error instanceof Error ? error.stack : undefined,
        ...data,
      });
      throw error;
    }
  }
}

export const logger = new Logger("{service}");

4.3 src/client.ts — API Client with Timeouts, Circuit Breaker, and Pluggable Pagination

// API Client for {Service}
// Handles auth, request timeouts, circuit breaker, retry, rate limiting, and pagination

import { logger } from "./logger.js";

const DEFAULT_BASE_URL = "https://api.example.com";
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY = 1000; // ms
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds

// ============================================
// CIRCUIT BREAKER
// ============================================
type CircuitState = "closed" | "open" | "half-open";

class CircuitBreaker {
  private state: CircuitState = "closed";
  private failureCount = 0;
  private lastFailureTime = 0;
  private halfOpenLock = false; // Mutex: only ONE request passes in half-open
  private readonly failureThreshold: number;
  private readonly resetTimeoutMs: number;

  constructor(failureThreshold = 5, resetTimeoutMs = 60_000) {
    this.failureThreshold = failureThreshold;
    this.resetTimeoutMs = resetTimeoutMs;
  }

  canExecute(): boolean {
    if (this.state === "closed") return true;
    if (this.state === "open") {
      if (Date.now() - this.lastFailureTime >= this.resetTimeoutMs) {
        // Only allow ONE request through in half-open
        if (!this.halfOpenLock) {
          this.halfOpenLock = true;
          this.state = "half-open";
          logger.info("circuit_breaker.half_open");
          return true;
        }
        return false; // Another request already testing
      }
      return false;
    }
    // half-open: already locked, reject additional requests
    return false;
  }

  recordSuccess(): void {
    this.halfOpenLock = false;
    if (this.state !== "closed") {
      logger.info("circuit_breaker.closed", { previousFailures: this.failureCount });
    }
    this.failureCount = 0;
    this.state = "closed";
  }

  recordFailure(): void {
    this.halfOpenLock = false;
    this.failureCount++;
    this.lastFailureTime = Date.now();
    if (this.failureCount >= this.failureThreshold || this.state === "half-open") {
      this.state = "open";
      logger.warn("circuit_breaker.open", {
        failureCount: this.failureCount,
        resetAfterMs: this.resetTimeoutMs,
      });
    }
  }

  getState(): CircuitState {
    return this.state;
  }
}

// ============================================
// PAGINATION STRATEGIES
// ============================================
// Pluggable pagination — each tool specifies which strategy its endpoint uses

export type PaginationStrategy =
  | { type: "offset"; pageParam?: string; pageSizeParam?: string }
  | { type: "cursor"; cursorParam?: string; cursorPath?: string }
  | { type: "keyset"; afterParam?: string; afterField?: string }
  | { type: "link-header" }
  | { type: "next-url"; nextUrlPath?: string };

// ============================================
// API CLIENT
// ============================================
export class APIClient {
  private apiKey: string;
  private baseUrl: string;
  private rateLimitRemaining: number = Infinity;
  private rateLimitReset: number = 0;
  private circuitBreaker: CircuitBreaker;
  private timeoutMs: number;

  constructor(apiKey: string, baseUrl?: string, timeoutMs?: number) {
    this.apiKey = apiKey;
    this.baseUrl = baseUrl || DEFAULT_BASE_URL;
    this.timeoutMs = timeoutMs || DEFAULT_TIMEOUT_MS;
    this.circuitBreaker = new CircuitBreaker();
  }

  // === Core request with timeout + circuit breaker + retry + rate limit ===
  async request<T = unknown>(
    endpoint: string,
    options: RequestInit = {}
  ): Promise<T> {
    // Circuit breaker check
    if (!this.circuitBreaker.canExecute()) {
      throw new Error(
        `Circuit breaker is open — API is unavailable. Retry after ${Math.ceil(60)} seconds.`
      );
    }

    // Wait if rate limited
    await this.waitForRateLimit();

    let lastError: Error | null = null;

    for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
      try {
        const url = `${this.baseUrl}${endpoint}`;

        // AbortController for request timeout
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);

        const requestId = logger.requestId();
        const start = performance.now();

        logger.debug("api_request.start", {
          requestId,
          method: options.method || "GET",
          endpoint,
          attempt: attempt + 1,
        });

        try {
          const response = await fetch(url, {
            ...options,
            signal: controller.signal,
            headers: {
              "Authorization": `Bearer ${this.apiKey}`,
              "Content-Type": "application/json",
              "Accept": "application/json",
              ...options.headers,
            },
          });

          const durationMs = Math.round(performance.now() - start);

          // Track rate limit headers
          this.updateRateLimits(response);

          // Handle rate limit response
          if (response.status === 429) {
            const retryAfter = parseInt(
              response.headers.get("Retry-After") || "5",
              10
            );
            logger.warn("api_request.rate_limited", { requestId, retryAfter, endpoint });
            await this.delay(retryAfter * 1000);
            continue;
          }

          // Handle server errors (retry)
          if (response.status >= 500) {
            this.circuitBreaker.recordFailure();
            lastError = new Error(
              `Server error: ${response.status} ${response.statusText}`
            );
            logger.warn("api_request.server_error", {
              requestId, durationMs, status: response.status, endpoint, attempt: attempt + 1,
            });
            const baseDelay = RETRY_BASE_DELAY * Math.pow(2, attempt);
            const jitter = Math.random() * baseDelay * 0.5; // 0-50% random jitter
            await this.delay(baseDelay + jitter);
            continue;
          }

          // Handle client errors (don't retry)
          if (!response.ok) {
            const errorBody = await response.text();
            logger.error("api_request.client_error", {
              requestId, durationMs, status: response.status, endpoint, body: errorBody.slice(0, 500),
            });
            throw new Error(
              `API error ${response.status}: ${response.statusText}${errorBody}`
            );
          }

          // Success — record with circuit breaker
          this.circuitBreaker.recordSuccess();

          logger.debug("api_request.done", {
            requestId, durationMs, status: response.status, endpoint,
          });

          // Handle empty responses (204 No Content)
          if (response.status === 204) {
            return { success: true } as T;
          }

          return (await response.json()) as T;
        } finally {
          clearTimeout(timeoutId);
        }
      } catch (error) {
        if (error instanceof Error && error.name === "AbortError") {
          this.circuitBreaker.recordFailure();
          lastError = new Error(`Request timeout after ${this.timeoutMs}ms: ${endpoint}`);
          logger.error("api_request.timeout", { endpoint, timeoutMs: this.timeoutMs });
          continue;
        }
        if (error instanceof Error && !error.message.startsWith("Server error")) {
          throw error; // Don't retry client errors
        }
        lastError = error instanceof Error ? error : new Error(String(error));
      }
    }

    throw lastError || new Error("Request failed after retries");
  }

  // === Convenience methods ===
  async get<T = unknown>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: "GET" });
  }

  async post<T = unknown>(endpoint: string, data: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: "POST",
      body: JSON.stringify(data),
    });
  }

  async put<T = unknown>(endpoint: string, data: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: "PUT",
      body: JSON.stringify(data),
    });
  }

  async patch<T = unknown>(endpoint: string, data: unknown): Promise<T> {
    return this.request<T>(endpoint, {
      method: "PATCH",
      body: JSON.stringify(data),
    });
  }

  async delete<T = unknown>(endpoint: string): Promise<T> {
    return this.request<T>(endpoint, { method: "DELETE" });
  }

  // === Pluggable pagination ===
  async paginate<T>(
    endpoint: string,
    params: {
      page?: number;
      pageSize?: number;
      extraParams?: Record<string, string>;
      strategy?: PaginationStrategy;
    } = {}
  ): Promise<{ data: T[]; meta: { total: number; page: number; pageSize: number; hasMore: boolean } }> {
    const { page = 1, pageSize = 25, extraParams = {}, strategy } = params;
    const paginationStrategy = strategy || { type: "offset" as const };

    switch (paginationStrategy.type) {
      // === Offset/page-number pagination (most common) ===
      case "offset": {
        const pageParam = paginationStrategy.pageParam || "page";
        const sizeParam = paginationStrategy.pageSizeParam || "pageSize";
        const queryParams = new URLSearchParams({
          [pageParam]: String(page),
          [sizeParam]: String(Math.min(pageSize, 100)),
          ...extraParams,
        });
        const result = await this.get<any>(`${endpoint}?${queryParams}`);
        const data = Array.isArray(result) ? result : result.data || result.items || result.results || [];
        const total = result.meta?.total || result.total || result.totalCount || data.length;
        return { data, meta: { total, page, pageSize, hasMore: page * pageSize < total } };
      }

      // === Cursor-based pagination (Slack, Facebook, etc.) ===
      case "cursor": {
        const cursorParam = paginationStrategy.cursorParam || "cursor";
        const cursorPath = paginationStrategy.cursorPath || "meta.nextCursor";
        const queryParams = new URLSearchParams({
          limit: String(Math.min(pageSize, 100)),
          ...extraParams,
        });
        // If page > 1, caller must supply cursor via extraParams
        const result = await this.get<any>(`${endpoint}?${queryParams}`);
        const data = Array.isArray(result) ? result : result.data || result.items || result.results || [];
        const nextCursor = this.getNestedValue(result, cursorPath);
        const total = result.meta?.total || result.total || data.length;
        return {
          data,
          meta: { total, page, pageSize, hasMore: !!nextCursor },
        };
      }

      // === Keyset pagination (Stripe-style: starting_after=obj_xxx) ===
      case "keyset": {
        const afterParam = paginationStrategy.afterParam || "starting_after";
        const queryParams = new URLSearchParams({
          limit: String(Math.min(pageSize, 100)),
          ...extraParams,
        });
        const result = await this.get<any>(`${endpoint}?${queryParams}`);
        const data = Array.isArray(result) ? result : result.data || result.items || [];
        const hasMore = result.has_more ?? result.hasMore ?? data.length >= pageSize;
        return {
          data,
          meta: { total: -1, page, pageSize, hasMore },
        };
      }

      // === Link-header pagination (GitHub-style) ===
      case "link-header": {
        const queryParams = new URLSearchParams({
          per_page: String(Math.min(pageSize, 100)),
          page: String(page),
          ...extraParams,
        });
        const url = `${this.baseUrl}${endpoint}?${queryParams}`;
        const controller = new AbortController();
        const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
        try {
          const response = await fetch(url, {
            signal: controller.signal,
            headers: {
              "Authorization": `Bearer ${this.apiKey}`,
              "Accept": "application/json",
            },
          });
          this.updateRateLimits(response);
          const data = await response.json() as T[];
          const linkHeader = response.headers.get("Link") || "";
          const hasMore = linkHeader.includes('rel="next"');
          return {
            data: Array.isArray(data) ? data : [],
            meta: { total: -1, page, pageSize, hasMore },
          };
        } finally {
          clearTimeout(timeoutId);
        }
      }

      // === Next-URL pagination (API returns full URL for next page) ===
      case "next-url": {
        const nextUrlPath = paginationStrategy.nextUrlPath || "next";
        const queryParams = new URLSearchParams({
          limit: String(Math.min(pageSize, 100)),
          ...extraParams,
        });
        const result = await this.get<any>(`${endpoint}?${queryParams}`);
        const data = Array.isArray(result) ? result : result.data || result.items || result.results || [];
        const nextUrl = this.getNestedValue(result, nextUrlPath);
        const total = result.count || result.total || data.length;
        return {
          data,
          meta: { total, page, pageSize, hasMore: !!nextUrl },
        };
      }

      default:
        throw new Error(`Unknown pagination strategy: ${(paginationStrategy as any).type}`);
    }
  }

  // Helper: access nested object values by dot path
  private getNestedValue(obj: any, path: string): any {
    return path.split(".").reduce((o, k) => o?.[k], obj);
  }

  // === Health check: validate connectivity + auth ===
  async healthCheck(): Promise<{ reachable: boolean; authenticated: boolean; latencyMs: number; error?: string }> {
    const start = performance.now();
    try {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 10_000);
      try {
        const response = await fetch(this.baseUrl, {
          signal: controller.signal,
          headers: {
            "Authorization": `Bearer ${this.apiKey}`,
            "Accept": "application/json",
          },
        });
        const latencyMs = Math.round(performance.now() - start);
        return {
          reachable: true,
          authenticated: response.status !== 401 && response.status !== 403,
          latencyMs,
          ...(response.status >= 400 ? { error: `Status ${response.status}` } : {}),
        };
      } finally {
        clearTimeout(timeoutId);
      }
    } catch (error) {
      return {
        reachable: false,
        authenticated: false,
        latencyMs: Math.round(performance.now() - start),
        error: error instanceof Error ? error.message : String(error),
      };
    }
  }

  // === Rate limit helpers ===
  private updateRateLimits(response: Response): void {
    const remaining = response.headers.get("X-RateLimit-Remaining");
    const reset = response.headers.get("X-RateLimit-Reset");

    if (remaining) this.rateLimitRemaining = parseInt(remaining, 10);
    if (reset) this.rateLimitReset = parseInt(reset, 10) * 1000;
  }

  private async waitForRateLimit(): Promise<void> {
    if (this.rateLimitRemaining <= 1 && this.rateLimitReset > Date.now()) {
      const waitMs = this.rateLimitReset - Date.now() + 100;
      logger.warn("rate_limit.waiting", { waitMs: Math.min(waitMs, 30000) });
      await this.delay(Math.min(waitMs, 30000));
    }
  }

  private delay(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

4.4 src/tools/index.ts — Tool Registry with Lazy Loading

import { z } from "zod";
import type { APIClient } from "../client.js";
import type { ToolDefinition, ToolHandler, ToolGroup } from "../types.js";

// Import tool group loaders (lazy — they return definitions + handlers)
// Each group file exports: getTools(client) => { tools, handlers }

export class ToolRegistry {
  private groups: Map<string, ToolGroup> = new Map();
  private toolToGroup: Map<string, string> = new Map();
  private client: APIClient;

  // Group loader functions — add one per tool group from the analysis
  private groupLoaders: Record<
    string,
    () => Promise<{ tools: ToolDefinition[]; handlers: Record<string, ToolHandler> }>
  > = {};

  constructor(client: APIClient) {
    this.client = client;
    this.registerGroupLoaders();
  }

  private registerGroupLoaders(): void {
    // Register lazy loaders for each tool group
    // These import() calls only execute when the group is first needed
    this.groupLoaders = {
      health: async () => {
        const mod = await import("./health.js");
        return mod.getTools(this.client);
      },
      contacts: async () => {
        const mod = await import("./contacts.js");
        return mod.getTools(this.client);
      },
      deals: async () => {
        const mod = await import("./deals.js");
        return mod.getTools(this.client);
      },
      // ... add one per group from analysis doc
    };
  }

  // Load a specific group on demand
  private async loadGroup(groupName: string): Promise<void> {
    if (this.groups.has(groupName) && this.groups.get(groupName)!.loaded) {
      return; // Already loaded
    }

    const loader = this.groupLoaders[groupName];
    if (!loader) {
      throw new Error(`Unknown tool group: ${groupName}`);
    }

    const { tools, handlers } = await loader();

    this.groups.set(groupName, {
      name: groupName,
      tools,
      handlers,
      loaded: true,
    });

    // Map tool names to their group for handler lookup
    for (const tool of tools) {
      this.toolToGroup.set(tool.name, groupName);
    }
  }

  // Load ALL groups (for ListTools — must show all available tools)
  async loadAllGroups(): Promise<void> {
    await Promise.all(
      Object.keys(this.groupLoaders).map((name) => this.loadGroup(name))
    );
  }

  // Get all tool definitions (loads all groups if needed)
  async getAllTools(): Promise<ToolDefinition[]> {
    await this.loadAllGroups();
    const allTools: ToolDefinition[] = [];
    for (const group of this.groups.values()) {
      allTools.push(...group.tools);
    }
    return allTools;
  }

  // Get handler for a specific tool
  async getHandler(toolName: string): Promise<ToolHandler> {
    // Ensure the tool's group is loaded
    const groupName = this.toolToGroup.get(toolName);
    if (!groupName) {
      // Group might not be loaded yet — load all and retry
      await this.loadAllGroups();
      const retryGroup = this.toolToGroup.get(toolName);
      if (!retryGroup) {
        throw new Error(`Unknown tool: ${toolName}`);
      }
      const group = this.groups.get(retryGroup)!;
      const handler = group.handlers[toolName];
      if (!handler) throw new Error(`No handler for tool: ${toolName}`);
      return handler;
    }

    await this.loadGroup(groupName);
    const group = this.groups.get(groupName)!;
    const handler = group.handlers[toolName];
    if (!handler) throw new Error(`No handler for tool: ${toolName}`);
    return handler;
  }
}

4.5 src/tools/health.ts — Health Check Tool (Always Included)

// Health check tool — validates environment, API connectivity, and auth
// Always include this tool in every MCP server

import type { APIClient } from "../client.js";
import type { ToolDefinition, ToolHandler } from "../types.js";
import { logger } from "../logger.js";

function getToolDefinitions(): ToolDefinition[] {
  return [
    {
      name: "health_check",
      title: "Health Check",
      description:
        "Validate server health: checks that environment variables are set, the API is reachable, and authentication is valid. Use when diagnosing connection issues or verifying server setup.",
      inputSchema: {
        type: "object",
        properties: {},
      },
      outputSchema: {
        type: "object",
        properties: {
          status: { type: "string", enum: ["healthy", "degraded", "unhealthy"] },
          checks: {
            type: "object",
            properties: {
              envVars: { type: "object", properties: { ok: { type: "boolean" }, missing: { type: "array", items: { type: "string" } } } },
              apiReachable: { type: "boolean" },
              authValid: { type: "boolean" },
              latencyMs: { type: "number" },
            },
          },
          error: { type: "string" },
        },
        required: ["status", "checks"],
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
  ];
}

function getToolHandlers(client: APIClient): Record<string, ToolHandler> {
  return {
    health_check: async () => {
      const checks: Record<string, unknown> = {};

      // Check 1: Required environment variables
      const requiredEnvVars = ["{SERVICE}_API_KEY"];
      const missing = requiredEnvVars.filter((v) => !process.env[v]);
      checks.envVars = { ok: missing.length === 0, missing };

      // Check 2: API reachability + auth
      const healthResult = await client.healthCheck();
      checks.apiReachable = healthResult.reachable;
      checks.authValid = healthResult.authenticated;
      checks.latencyMs = healthResult.latencyMs;

      // Determine overall status
      let status: "healthy" | "degraded" | "unhealthy";
      if (missing.length > 0 || !healthResult.reachable) {
        status = "unhealthy";
      } else if (!healthResult.authenticated) {
        status = "degraded";
      } else {
        status = "healthy";
      }

      const result = {
        status,
        checks,
        ...(healthResult.error ? { error: healthResult.error } : {}),
      };

      logger.info("health_check", { status, checks });

      return {
        content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
        structuredContent: result,
      };
    },
  };
}

export function getTools(client: APIClient) {
  return {
    tools: getToolDefinitions(),
    handlers: getToolHandlers(client),
  };
}

4.6 src/tools/{group}.ts — Tool Group Template

// Tool group: {group_name}
// Generated from {service}-api-analysis.md

import { z } from "zod";
import type { APIClient } from "../client.js";
import type { ToolDefinition, ToolHandler } from "../types.js";
import { logger } from "../logger.js";

// === Zod Schemas ===
const ListContactsSchema = z.object({
  page: z.number().optional().default(1).describe("Page number (default 1)"),
  pageSize: z.number().optional().default(25).describe("Results per page (default 25, max 100)"),
  query: z.string().optional().describe("Search by name, email, or phone"),
  status: z.enum(["active", "inactive", "all"]).optional().describe("Filter by status"),
});

const GetContactSchema = z.object({
  contact_id: z.string().describe("Contact ID"),
});

const CreateContactSchema = z.object({
  name: z.string().describe("Contact full name"),
  email: z.string().email().optional().describe("Contact email address"),
  phone: z.string().optional().describe("Contact phone number"),
});

const UpdateContactSchema = z.object({
  contact_id: z.string().describe("Contact ID"),
  name: z.string().optional().describe("Updated name"),
  email: z.string().email().optional().describe("Updated email"),
  phone: z.string().optional().describe("Updated phone"),
});

const DeleteContactSchema = z.object({
  contact_id: z.string().describe("Contact ID to delete"),
});

// === Tool Definitions ===
// Note: Every tool MUST have: name, title, description, inputSchema, outputSchema, annotations
// See Section 11 (Token Budget) for description length targets
function getToolDefinitions(): ToolDefinition[] {
  return [
    {
      name: "list_contacts",
      title: "List Contacts",
      description:
        "List contacts with optional filters and pagination. Returns name, email, phone, and status. Use when the user wants to browse or filter contacts. Do NOT use to search by keyword (use search_contacts) or get one contact's details (use get_contact).",
      inputSchema: {
        type: "object",
        properties: {
          page: { type: "number", description: "Page number (default 1)" },
          pageSize: { type: "number", description: "Results per page (default 25, max 100)" },
          query: { type: "string", description: "Search by name, email, or phone" },
          status: { type: "string", enum: ["active", "inactive", "all"], description: "Filter by status" },
        },
      },
      outputSchema: {
        type: "object",
        properties: {
          data: {
            type: "array",
            items: {
              type: "object",
              properties: {
                id: { type: "string" },
                name: { type: "string" },
                email: { type: "string" },
                phone: { type: "string" },
                status: { type: "string" },
              },
            },
          },
          meta: {
            type: "object",
            properties: {
              total: { type: "number" },
              page: { type: "number" },
              pageSize: { type: "number" },
              hasMore: { type: "boolean" },
            },
          },
        },
        required: ["data", "meta"],
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    {
      name: "get_contact",
      title: "Get Contact Details",
      description:
        "Get full details for a specific contact by ID. Returns all fields including activity history and tags. Use when the user references a known contact or needs detailed info. Do NOT use to browse multiple contacts (use list_contacts).",
      inputSchema: {
        type: "object",
        properties: {
          contact_id: { type: "string", description: "Contact ID" },
        },
        required: ["contact_id"],
      },
      outputSchema: {
        type: "object",
        properties: {
          id: { type: "string" },
          name: { type: "string" },
          email: { type: "string" },
          phone: { type: "string" },
          status: { type: "string" },
          tags: { type: "array", items: { type: "string" } },
          created_at: { type: "string" },
          updated_at: { type: "string" },
        },
        required: ["id", "name"],
      },
      annotations: {
        readOnlyHint: true,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    {
      name: "create_contact",
      title: "Create Contact",
      description:
        "Create a new contact. Returns the created contact with assigned ID. Use when the user wants to add a new person to the system.",
      inputSchema: {
        type: "object",
        properties: {
          name: { type: "string", description: "Contact full name" },
          email: { type: "string", description: "Contact email address" },
          phone: { type: "string", description: "Contact phone number" },
        },
        required: ["name"],
      },
      outputSchema: {
        type: "object",
        properties: {
          id: { type: "string" },
          name: { type: "string" },
          email: { type: "string" },
          phone: { type: "string" },
          status: { type: "string" },
          created_at: { type: "string" },
        },
        required: ["id", "name"],
      },
      annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: false,
        openWorldHint: false,
      },
    },
    {
      name: "update_contact",
      title: "Update Contact",
      description:
        "Update an existing contact's fields. Only include fields to change. Use when the user wants to modify contact information.",
      inputSchema: {
        type: "object",
        properties: {
          contact_id: { type: "string", description: "Contact ID" },
          name: { type: "string", description: "Updated name" },
          email: { type: "string", description: "Updated email" },
          phone: { type: "string", description: "Updated phone" },
        },
        required: ["contact_id"],
      },
      outputSchema: {
        type: "object",
        properties: {
          id: { type: "string" },
          name: { type: "string" },
          email: { type: "string" },
          phone: { type: "string" },
          status: { type: "string" },
          updated_at: { type: "string" },
        },
        required: ["id"],
      },
      annotations: {
        readOnlyHint: false,
        destructiveHint: false,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
    {
      name: "delete_contact",
      title: "Delete Contact",
      description:
        "Permanently delete a contact. Cannot be undone. Use only when the user explicitly asks to delete a contact.",
      inputSchema: {
        type: "object",
        properties: {
          contact_id: { type: "string", description: "Contact ID to delete" },
        },
        required: ["contact_id"],
      },
      outputSchema: {
        type: "object",
        properties: {
          success: { type: "boolean" },
          deleted_id: { type: "string" },
        },
        required: ["success"],
      },
      annotations: {
        readOnlyHint: false,
        destructiveHint: true,
        idempotentHint: true,
        openWorldHint: false,
      },
    },
  ];
}

// === Tool Handlers ===
// Every handler returns BOTH content (text fallback) AND structuredContent (typed JSON)
function getToolHandlers(client: APIClient): Record<string, ToolHandler> {
  return {
    list_contacts: async (args) => {
      const params = ListContactsSchema.parse(args);
      const result = await logger.time("tool.list_contacts", () =>
        client.paginate("/contacts", {
          page: params.page,
          pageSize: params.pageSize,
          extraParams: {
            ...(params.query ? { query: params.query } : {}),
            ...(params.status ? { status: params.status } : {}),
          },
        })
      , { tool: "list_contacts" });

      return {
        content: [{
          type: "text",
          text: JSON.stringify(result, null, 2),
          annotations: { audience: ["user", "assistant"], priority: 0.7 },
        }],
        structuredContent: result,
      };
    },

    get_contact: async (args) => {
      const { contact_id } = GetContactSchema.parse(args);
      const result = await logger.time("tool.get_contact", () =>
        client.get(`/contacts/${contact_id}`)
      , { tool: "get_contact", contact_id });

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(result, null, 2),
            annotations: { audience: ["user"], priority: 0.8 },
          },
          // resource_link — allows clients to subscribe to updates for this contact
          {
            type: "resource_link" as const,
            uri: `{service}://contacts/${contact_id}`,
            name: `Contact ${contact_id}`,
            mimeType: "application/json",
          },
        ],
        structuredContent: result,
      };
    },

    create_contact: async (args) => {
      const data = CreateContactSchema.parse(args);
      const result = await logger.time("tool.create_contact", () =>
        client.post("/contacts", data)
      , { tool: "create_contact" });

      return {
        content: [{
          type: "text",
          text: JSON.stringify(result, null, 2),
          annotations: { audience: ["user"], priority: 0.9 },
        }],
        structuredContent: result,
      };
    },

    update_contact: async (args) => {
      const { contact_id, ...updateData } = UpdateContactSchema.parse(args);
      const result = await logger.time("tool.update_contact", () =>
        client.patch(`/contacts/${contact_id}`, updateData)
      , { tool: "update_contact", contact_id });

      return {
        content: [{
          type: "text",
          text: JSON.stringify(result, null, 2),
          annotations: { audience: ["user"], priority: 0.9 },
        }],
        structuredContent: result,
      };
    },

    delete_contact: async (args) => {
      const { contact_id } = DeleteContactSchema.parse(args);
      await logger.time("tool.delete_contact", () =>
        client.delete(`/contacts/${contact_id}`)
      , { tool: "delete_contact", contact_id });

      const result = { success: true, deleted_id: contact_id };
      return {
        content: [{
          type: "text",
          text: JSON.stringify(result, null, 2),
          annotations: { audience: ["user"], priority: 1.0 },
        }],
        structuredContent: result,
      };
    },
  };
}

// === Export: getTools(client) ===
export function getTools(client: APIClient) {
  return {
    tools: getToolDefinitions(),
    handlers: getToolHandlers(client),
  };
}

4.7 src/index.ts — Server Entry Point (Stdio + Streamable HTTP)

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { APIClient } from "./client.js";
import { ToolRegistry } from "./tools/index.js";
import { logger } from "./logger.js";

// ============================================
// CONFIGURATION
// ============================================
const MCP_NAME = "{service}";
const MCP_VERSION = "1.0.0";

// ============================================
// SERVER SETUP
// ============================================
async function main() {
  // Validate environment variables
  const apiKey = process.env["{SERVICE}_API_KEY"];
  if (!apiKey) {
    logger.error("startup.missing_env", { variable: "{SERVICE}_API_KEY" });
    console.error("Error: {SERVICE}_API_KEY environment variable required");
    console.error("Copy .env.example to .env and fill in your credentials");
    process.exit(1);
  }

  const baseUrl = process.env["{SERVICE}_BASE_URL"];

  // Initialize client and tool registry
  const client = new APIClient(apiKey, baseUrl);
  const registry = new ToolRegistry(client);

  // Create MCP server — only declare capabilities that are actually implemented
  const server = new Server(
    { name: `${MCP_NAME}-mcp`, version: MCP_VERSION },
    {
      capabilities: {
        tools: { listChanged: false },
        logging: {},
        // Enable these ONLY when the server actually implements them:
        // resources: { subscribe: false, listChanged: false },
        // prompts: { listChanged: false },
      },
    }
  );

  // List all available tools
  server.setRequestHandler(ListToolsRequestSchema, async () => {
    const tools = await registry.getAllTools();
    logger.info("tools.list", { count: tools.length });
    return { tools };
  });

  // Handle tool execution
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    const requestId = logger.requestId();

    logger.info("tool.call.start", { requestId, tool: name, args });
    const start = performance.now();

    try {
      const handler = await registry.getHandler(name);
      const result = await handler(args || {});

      const durationMs = Math.round(performance.now() - start);
      logger.info("tool.call.done", { requestId, tool: name, durationMs, isError: false });

      return result;
    } catch (error) {
      const durationMs = Math.round(performance.now() - start);

      // === Error Classification ===
      // Protocol Errors: JSON-RPC codes for structural issues (unknown tool, malformed request)
      // Tool Execution Errors: isError=true for API/validation/business failures
      //   → Input validation errors are Tool Execution Errors (enables LLM self-correction)

      let message: string;
      if (error instanceof z.ZodError) {
        // Input validation error → Tool Execution Error (NOT protocol error)
        // Returning this as isError lets the LLM self-correct the input
        message = `Validation error: ${error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(", ")}`;
        logger.warn("tool.call.validation_error", {
          requestId, tool: name, durationMs, errors: error.errors,
        });
      } else if (error instanceof Error) {
        message = error.message;
        logger.error("tool.call.error", {
          requestId, tool: name, durationMs, error: message, stack: error.stack,
        });
      } else {
        message = String(error);
        logger.error("tool.call.error", { requestId, tool: name, durationMs, error: message });
      }

      return {
        content: [{ type: "text", text: `Error: ${message}` }],
        structuredContent: { error: message, tool: name },
        isError: true,
      };
    }
  });

  // === Transport Selection ===
  // stdio: For local use (Claude Desktop, Cursor, direct subprocess spawning)
  // Streamable HTTP: For remote/production deployment (network-accessible server)
  const transportMode = process.env.MCP_TRANSPORT || "stdio";

  if (transportMode === "http") {
    await startHttpTransport(server);
  } else {
    await startStdioTransport(server);
  }
}

// === Stdio Transport (default — local use) ===
async function startStdioTransport(server: Server) {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  logger.info("server.started", { transport: "stdio", name: MCP_NAME });
}

// === Streamable HTTP Transport (remote/production deployment) ===
// Use when: deploying as a network service, multi-client access, load balancing
// Requires: MCP_TRANSPORT=http, optional MCP_HTTP_PORT (default 3000)
async function startHttpTransport(server: Server) {
  // Dynamic import — only load HTTP transport when needed
  const { StreamableHTTPServerTransport } = await import(
    "@modelcontextprotocol/sdk/server/streamableHttp.js"
  );
  const { createServer } = await import("http");

  const port = parseInt(process.env.MCP_HTTP_PORT || "3000", 10);

  // Session management with TTL, max sessions, and cleanup
  const sessions = new Map<string, { transport: StreamableHTTPServerTransport; lastActivity: number }>();
  const MAX_SESSIONS = 100;
  const SESSION_TTL_MS = 30 * 60 * 1000; // 30 minutes

  // Session cleanup interval — evict expired sessions every 60s
  const cleanupInterval = setInterval(() => {
    const now = Date.now();
    for (const [id, session] of sessions.entries()) {
      if (now - session.lastActivity > SESSION_TTL_MS) {
        logger.info("session.expired", { sessionId: id });
        sessions.delete(id);
      }
    }
  }, 60_000);

  // Evict oldest session if at capacity
  function evictOldestSession(): void {
    let oldest: string | null = null;
    let oldestTime = Infinity;
    for (const [id, s] of sessions.entries()) {
      if (s.lastActivity < oldestTime) {
        oldestTime = s.lastActivity;
        oldest = id;
      }
    }
    if (oldest) {
      logger.info("session.evicted", { sessionId: oldest });
      sessions.delete(oldest);
    }
  }

  const httpServer = createServer(async (req, res) => {
    const url = new URL(req.url || "/", `http://localhost:${port}`);

    // Health endpoint (non-MCP)
    if (url.pathname === "/health") {
      res.writeHead(200, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ status: "ok", server: MCP_NAME, activeSessions: sessions.size }));
      return;
    }

    // MCP endpoint
    if (url.pathname === "/mcp") {
      const sessionId = req.headers["mcp-session-id"] as string | undefined;

      if (req.method === "POST") {
        // New or existing session
        let transport: StreamableHTTPServerTransport;

        if (sessionId && sessions.has(sessionId)) {
          const session = sessions.get(sessionId)!;
          session.lastActivity = Date.now();
          transport = session.transport;
        } else {
          // Enforce max sessions — evict oldest if at capacity
          if (sessions.size >= MAX_SESSIONS) {
            evictOldestSession();
          }

          transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: () => crypto.randomUUID(),
          });
          await server.connect(transport);
          // Store session after connection
          const newSessionId = transport.sessionId;
          if (newSessionId) {
            sessions.set(newSessionId, { transport, lastActivity: Date.now() });
          }
        }

        await transport.handleRequest(req, res);
        return;
      }

      if (req.method === "GET") {
        // SSE stream for server-initiated messages
        if (sessionId && sessions.has(sessionId)) {
          const session = sessions.get(sessionId)!;
          session.lastActivity = Date.now();
          await session.transport.handleRequest(req, res);
          return;
        }
        res.writeHead(400, { "Content-Type": "application/json" });
        res.end(JSON.stringify({ error: "No session. Send POST first." }));
        return;
      }

      if (req.method === "DELETE") {
        // Session cleanup
        if (sessionId && sessions.has(sessionId)) {
          const session = sessions.get(sessionId)!;
          await session.transport.handleRequest(req, res);
          sessions.delete(sessionId);
          return;
        }
      }
    }

    res.writeHead(404);
    res.end();
  });

  // Clean up on server shutdown
  process.on("SIGTERM", () => {
    clearInterval(cleanupInterval);
    sessions.clear();
  });

  httpServer.listen(port, () => {
    logger.info("server.started", { transport: "http", name: MCP_NAME, port, endpoint: `/mcp` });
  });
}

main().catch((error) => {
  logger.error("server.fatal", { error: error instanceof Error ? error.message : String(error) });
  process.exit(1);
});

5. Auth Patterns

Choose the pattern from the analysis doc and use the corresponding client code:

Pattern A: API Key (most common)

headers: {
  "Authorization": `Bearer ${this.apiKey}`,
  // OR: "X-API-Key": this.apiKey,
  // OR: "Api-Key": this.apiKey,
}

Pattern B: OAuth2 Client Credentials

export class APIClient {
  private clientId: string;
  private clientSecret: string;
  private accessToken: string | null = null;
  private tokenExpiry: number = 0;
  private refreshPromise: Promise<string> | null = null; // Mutex: share one refresh across concurrent callers

  constructor(clientId: string, clientSecret: string) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
  }

  private async getAccessToken(): Promise<string> {
    // Return cached token if valid (5 min buffer)
    if (this.accessToken && Date.now() < this.tokenExpiry - 300_000) {
      return this.accessToken;
    }

    // If already refreshing, wait for that to complete (prevents thundering herd)
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    // Start a new refresh and let all concurrent callers share it
    this.refreshPromise = this._doRefresh();
    try {
      const token = await this.refreshPromise;
      return token;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async _doRefresh(): Promise<string> {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), 30_000);

    try {
      const response = await fetch("https://auth.example.com/oauth/token", {
        method: "POST",
        signal: controller.signal,
        headers: { "Content-Type": "application/x-www-form-urlencoded" },
        body: new URLSearchParams({
          grant_type: "client_credentials",
          client_id: this.clientId,
          client_secret: this.clientSecret,
        }),
      });

      if (!response.ok) {
        throw new Error(`Auth failed: ${response.status} ${response.statusText}`);
      }

      const data = await response.json();
      this.accessToken = data.access_token;
      this.tokenExpiry = Date.now() + data.expires_in * 1000;
      return this.accessToken!;
    } finally {
      clearTimeout(timeoutId);
    }
  }

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    const token = await this.getAccessToken();
    // ... use token in Authorization header, with AbortController timeout
  }
}

Pattern C: Basic Auth

headers: {
  "Authorization": `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`,
}

Pattern D: API Key + Account ID (multi-tenant)

headers: {
  "Authorization": `Bearer ${this.apiKey}`,
  "X-Account-ID": this.accountId,
}

6. MCP Annotations (Feb 2026 Standard)

EVERY tool MUST have annotations. The annotations object goes on each tool definition:

{
  name: "tool_name",
  title: "Tool Display Name",
  description: "...",
  inputSchema: { ... },
  outputSchema: { ... },
  annotations: {
    readOnlyHint: boolean,      // true if tool only reads data (GET)
    destructiveHint: boolean,   // true if tool deletes data (DELETE)
    idempotentHint: boolean,    // true if repeated calls have same effect (GET, PUT, DELETE)
    openWorldHint: boolean,     // true if affects systems outside this API (rare)
  }
}

Decision matrix:

Operation readOnly destructive idempotent openWorld
GET / list / search true false true false
POST / create false false false false
PUT / update / upsert false false true false
PATCH / partial update false false true false
DELETE false true true false
Send email / SMS false false false true
Trigger webhook false false false true

7. Tool Definition Standards (2025-11-25 Spec)

Every tool definition MUST include these fields:

{
  // REQUIRED
  name: "list_contacts",                    // machine name, snake_case
  title: "List Contacts",                   // human-readable display name
  description: "...",                        // routing signal for LLM (see Section 8)
  inputSchema: { type: "object", ... },     // JSON Schema for input parameters
  
  // REQUIRED (2025-06-18+)
  outputSchema: {                            // JSON Schema 2020-12 for structured output
    type: "object",
    properties: { ... },
    required: [ ... ],
  },
  
  // REQUIRED
  annotations: { ... },                     // behavioral hints (see Section 6)

  // OPTIONAL — for rich UI clients
  icons: [                                   // icon for display in tool lists/palettes
    { src: "https://example.com/icon.svg", mimeType: "image/svg+xml" },
  ],
}

outputSchema guidelines (JSON Schema 2020-12):

  • Declare the shape of structuredContent returned by the tool
  • Use standard JSON Schema types: string, number, boolean, object, array
  • Include required array for non-optional fields
  • Keep schemas concise — only document fields the client needs to consume
  • The SDK validates structuredContent against outputSchema when both are present
// Example: List endpoint outputSchema
outputSchema: {
  type: "object",
  properties: {
    data: {
      type: "array",
      items: {
        type: "object",
        properties: {
          id: { type: "string" },
          name: { type: "string" },
          email: { type: "string" },
          status: { type: "string" },
        },
      },
    },
    meta: {
      type: "object",
      properties: {
        total: { type: "number" },
        page: { type: "number" },
        pageSize: { type: "number" },
        hasMore: { type: "boolean" },
      },
    },
  },
  required: ["data", "meta"],
},

// Example: Single entity outputSchema
outputSchema: {
  type: "object",
  properties: {
    id: { type: "string" },
    name: { type: "string" },
    email: { type: "string" },
    phone: { type: "string" },
    status: { type: "string" },
    created_at: { type: "string" },
  },
  required: ["id", "name"],
},

// Example: Delete/action outputSchema
outputSchema: {
  type: "object",
  properties: {
    success: { type: "boolean" },
    deleted_id: { type: "string" },
  },
  required: ["success"],
},

icons (optional):

// SVG preferred for crisp scaling at any size
icons: [
  { src: "https://cdn.example.com/contacts-icon.svg", mimeType: "image/svg+xml" },
],

// Or PNG for raster icons
icons: [
  { src: "https://cdn.example.com/contacts-icon.png", mimeType: "image/png" },
],

Icons are used by rich MCP clients (VS Code, Claude Desktop) to display tools in palettes and menus. Optional but improves discoverability. Use one icon per tool — prefer SVG.


8. Tool Description Best Practices for LLM Routing

The description is the MOST IMPORTANT field. It determines whether the LLM picks the right tool.

Formula:

{What it does in one sentence}. {What it returns — 2-3 key fields}. 
{When to use it — user intents}. {When NOT to use it — disambiguation}.

Good examples:

"List contacts with optional filters and pagination. Returns name, email, phone, and status. 
Use when the user wants to browse or filter contacts. Do NOT use to search by keyword 
(use search_contacts) or get one contact's details (use get_contact)."

"Get full details for a specific contact by ID. Returns all fields including activity history 
and tags. Use when the user references a known contact. Do NOT use to browse multiple contacts 
(use list_contacts)."

"Create a new contact. Returns the created contact with assigned ID. 
Use when the user wants to add a new person to the system."

"Permanently delete a contact. Cannot be undone. 
Use only when the user explicitly asks to delete a contact."

Bad examples:

"Gets contacts"                    // What contacts? How? When?
"Contact management tool"         // Not actionable
"CRUD operations for contacts"    // Technical jargon, no routing signal
"Fetches contact data from API"   // Implementation detail, not user intent

For similar tools, create clear differentiation:

list_contacts: "...browse or filter contacts. Do NOT use for keyword search."
search_contacts: "...full-text search. Use when searching by specific keyword."
get_contact: "...single contact by ID. Use for one specific contact's details."

9. Tool Result Standards (structuredContent)

Every tool handler MUST return both content (text fallback) and structuredContent (typed JSON).

This is required by the MCP 2025-06-18 spec. content is the universal text fallback for clients that don't support structured output. structuredContent is the typed JSON that matches outputSchema.

Standard return pattern:

// Basic: return both text and structured content
const result = await client.get(`/contacts/${contact_id}`);
return {
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
  structuredContent: result,
};

// With resource_link: tool result includes a link to a subscribable MCP Resource
const result = await client.get(`/contacts/${contact_id}`);
return {
  content: [
    { type: "text", text: JSON.stringify(result, null, 2) },
    {
      type: "resource_link",
      uri: `{service}://contacts/${contact_id}`,
      name: `Contact ${result.name}`,
      mimeType: "application/json",
    },
  ],
  structuredContent: result,
};

// Error: also use structuredContent for error responses
return {
  content: [{ type: "text", text: `Error: ${message}` }],
  structuredContent: { error: message, tool: name },
  isError: true,
};

Content annotations on tool results

Content blocks support annotations with audience and priority to control routing:

// Content annotation pattern — add to every content block
{
  type: "text",
  text: JSON.stringify(result, null, 2),
  annotations: {
    audience: ["user", "assistant"],  // Who should see this content
    priority: 0.7,                     // 0.0-1.0, higher = more prominent
  },
}

Use the content annotation planning from the analysis doc (Section 6b) to set appropriate values per tool type. See Section 4.6 for handler examples with annotations.

Note on HTML escaping in apps: If building apps that render user-supplied text, use a regex-based escapeHtml() — it's ~10x faster than DOM-based approaches (document.createElement('div').textContent), especially for large datasets.

  • GET single entity tools (get_contact, get_deal, get_invoice)
  • The uri should follow {service}://{resource_type}/{id} convention
  • Allows clients to subscribe to resource updates via MCP Resources
  • Don't include on list/search tools (too many links) or write tools

10. Error Handling Standards

Protocol Errors vs Tool Execution Errors

The MCP spec (2025-11-25) formally distinguishes two error categories:

Category When How LLM Behavior
Protocol Errors Unknown tool, malformed JSON-RPC, server crash JSON-RPC error codes (-32600 to -32603, -32700) LLM cannot self-correct
Tool Execution Errors API failure, validation error, business logic isError: true in result content LLM CAN self-correct

Critical rule: Input validation errors are Tool Execution Errors, NOT Protocol Errors. Returning validation errors as isError: true lets the LLM read the error, fix its input, and retry — enabling self-correction.

Three-level error handling:

Client-level (in client.ts):

  • Retry on 429 (rate limit) and 5xx (server error)
  • Don't retry on 4xx (client error — bad request, not found, unauthorized)
  • Circuit breaker prevents hammering a down service
  • Request timeout via AbortController prevents indefinite hangs
  • Parse error body for useful messages
  • Track rate limit headers

Handler-level (in tool handlers):

  • Zod validation catches bad input before API call
  • Catch specific error types for better messages

Server-level (in index.ts):

  • Never crash — always return an error response
  • Use isError: true flag for tool execution errors
  • Include the original error message so LLM can self-correct
  • Return structuredContent with error info
// In the CallToolRequest handler:
try {
  const handler = await registry.getHandler(name);
  const result = await handler(args || {});
  return result;
} catch (error) {
  let message: string;
  if (error instanceof z.ZodError) {
    // Input validation → Tool Execution Error (LLM self-corrects)
    message = `Validation error: ${error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join(", ")}`;
  } else if (error instanceof Error) {
    message = error.message;
  } else {
    message = String(error);
  }
  return {
    content: [{ type: "text", text: `Error: ${message}` }],
    structuredContent: { error: message, tool: name },
    isError: true,
  };
}

11. Token Budget Awareness

This is the real performance bottleneck. Each tool definition consumes 501000 tokens depending on schema complexity. Tool definitions are sent to the LLM on every request.

Budget targets:

Metric Target Why
Tokens per tool description < 200 tokens Prevents context bloat
Total tool definition tokens (per server) < 5,000 tokens Keeps 97.5% of context free
Max tools per server ~25 active Above this, accuracy degrades
Max tools per interaction 1520 Optimal accuracy range

Token optimization techniques:

  1. Concise descriptions — Cut filler words. "List contacts with optional filters" not "This tool allows you to list contacts with various optional filtering parameters."

  2. Minimal inputSchema — Only document parameters the LLM needs to set. Don't include internal/computed params.

  3. Short property descriptions"Page number (default 1)" not "The page number for paginated results. If not provided, defaults to 1."

  4. Combine similar tools — If list_contacts and search_contacts differ by one optional param, merge them. Fewer tools = better routing.

  5. outputSchema brevity — Include key fields, not exhaustive response bodies. The LLM doesn't need to know about every field the API returns.

Token counting helper

Run this after building to verify token budgets:

# Approximate token count per tool (words × 1.3)
node -e "
  const fs = require('fs');
  const src = fs.readFileSync('dist/index.js', 'utf8');
  // Extract tool definitions — look for name/description pairs
  const toolRegex = /name:\s*['\"](\w+)['\"][\s\S]*?description:\s*['\"]([^'\"]+)['\"]/g;
  let match, total = 0;
  while ((match = toolRegex.exec(src)) !== null) {
    const tokens = Math.ceil(match[2].split(/\s+/).length * 1.3);
    total += tokens;
    const status = tokens > 200 ? '⚠️' : '✅';
    console.log(\`\${status} \${match[1]}: ~\${tokens} tokens\`);
  }
  console.log(\`\nTotal description tokens: ~\${total}\`);
  console.log(total > 5000 ? '⚠️  Over 5,000 token budget!' : '✅ Within token budget');
"

Warning: Large servers

A server with 50+ tools at 200 tokens each = 10,000+ tokens consumed from context window before any conversation begins. For these servers:

  • Implement selective tool registration based on channel/context
  • Group tools and only register the relevant group per session
  • Consider splitting into multiple focused servers

12. Zod Validation Standards

Every tool handler MUST validate its input with Zod before making API calls:

import { z } from "zod";

// Define schema with descriptions (they appear in error messages)
const ListContactsSchema = z.object({
  page: z.number().int().positive().optional().default(1),
  pageSize: z.number().int().min(1).max(100).optional().default(25),
  query: z.string().optional(),
  status: z.enum(["active", "inactive", "all"]).optional(),
  sortBy: z.enum(["created", "updated", "name"]).optional(),
  createdAfter: z.string().datetime().optional(),
});

// In handler:
async (args) => {
  const params = ListContactsSchema.parse(args);
  // params is now fully typed and validated
}

Common Zod patterns:

// Required string
z.string().describe("Contact ID")

// Optional with default
z.number().optional().default(25)

// Enum
z.enum(["active", "inactive", "all"])

// Email
z.string().email()

// ISO date
z.string().datetime()

// Constrained number
z.number().int().min(1).max(100)

// Optional object
z.record(z.unknown()).optional()

// Array of strings
z.array(z.string()).optional()

13. Transport Selection Guide

Stdio (default — local use)

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const transport = new StdioServerTransport();
await server.connect(transport);

Use when:

  • Running as a local subprocess (Claude Desktop, Cursor, CLI tools)
  • Single-client access (one client spawns one server process)
  • No network exposure needed
  • Development/testing

Streamable HTTP (remote/production)

import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";

Use when:

  • Deploying as a network service
  • Multiple clients need to connect simultaneously
  • Running behind a load balancer or gateway
  • Production deployment with monitoring
  • Docker/containerized deployment

Key characteristics:

  • HTTP POST for client→server messages (JSON-RPC)
  • HTTP GET with SSE for server→client notifications
  • Session management via MCP-Session-Id header
  • Resumability via Last-Event-ID
  • Supports concurrent clients

Note: Legacy SSE transport is deprecated. Use Streamable HTTP for all new remote deployments.

The src/index.ts template (Section 4.7) includes both transports, selected via MCP_TRANSPORT env var.


14. Pagination Strategies

The API client supports pluggable pagination. Each tool specifies which strategy its endpoint uses:

Strategy: Offset (most common)

// ?page=2&pageSize=25
const result = await client.paginate<Contact>("/contacts", {
  page: 2, pageSize: 25,
  strategy: { type: "offset", pageParam: "page", pageSizeParam: "pageSize" },
});

Strategy: Cursor (Slack, Facebook, GraphQL)

// ?cursor=eyJsYXN0SWQiOiIxMjMifQ==&limit=25
const result = await client.paginate<Contact>("/contacts", {
  pageSize: 25,
  strategy: { type: "cursor", cursorParam: "cursor", cursorPath: "meta.nextCursor" },
  extraParams: { cursor: previousCursor },
});

Strategy: Keyset (Stripe — starting_after=obj_xxx)

// ?starting_after=con_abc123&limit=25
const result = await client.paginate<Contact>("/contacts", {
  pageSize: 25,
  strategy: { type: "keyset", afterParam: "starting_after" },
  extraParams: { starting_after: lastItemId },
});
// Reads Link: <url>; rel="next" from response headers
const result = await client.paginate<Contact>("/contacts", {
  page: 1, pageSize: 25,
  strategy: { type: "link-header" },
});

Strategy: Next URL (API returns full URL for next page)

// API response: { results: [...], next: "https://api.example.com/contacts?offset=50" }
const result = await client.paginate<Contact>("/contacts", {
  pageSize: 25,
  strategy: { type: "next-url", nextUrlPath: "next" },
});

Choosing a strategy: Check the API analysis doc. The pagination section should specify which pattern the API uses. Default to offset if not specified. Document the strategy choice in the tool group file.


15. Tasks (Async Operations) for Long-Running Tools

The 2025-11-25 spec adds experimental Tasks support (SEP-1686). For tools where the operation may take >10 seconds, declare task support so clients can poll for results instead of waiting.

When to use Tasks:

  • Report generation — compiling analytics, PDFs, exports (30-120s)
  • Bulk operations — updating 100+ records, mass imports (10-60s)
  • External processing — waiting on third-party webhooks, payment processing
  • Data migration — moving large datasets between systems

Tool definition with task support:

{
  name: "export_report",
  title: "Export Report",
  description: "Generate and export an analytics report. May take 30-120 seconds. Use when user requests a full report or data export.",
  inputSchema: { ... },
  outputSchema: { ... },
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  execution: {
    taskSupport: "optional",  // "required" | "optional" | "forbidden"
  },
}

Server capabilities with Tasks:

capabilities: {
  tools: { listChanged: false },
  logging: {},
  tasks: {
    list: {},
    cancel: {},
    requests: { tools: { call: {} } },
  },
}

Task-aware handler pattern:

// For task-enabled tools, the handler can return immediately with a task reference
// The SDK manages the task lifecycle — the handler just does the work
async function handleExportReport(args: Record<string, unknown>): Promise<ToolResult> {
  const params = ExportReportSchema.parse(args);
  
  // Long-running operation
  const result = await generateReport(params);
  
  return {
    content: [{
      type: "text",
      text: JSON.stringify(result, null, 2),
      annotations: { audience: ["user"], priority: 0.8 },
    }],
    structuredContent: result,
  };
}

Note: Tasks support is experimental in the 2025-11-25 spec. Implement only for tools identified as task candidates in the analysis doc (Section 10). Most tools should NOT use tasks — only long-running operations that would otherwise hit timeout limits.


16. One-File Pattern (for ≤15 tools)

If the analysis shows 15 or fewer tools, skip the modular structure and use a single src/index.ts. Still include all standards: title, outputSchema, structuredContent, logging, health check, timeouts, circuit breaker.

#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";

const MCP_NAME = "{service}";
const MCP_VERSION = "1.0.0";
const API_BASE_URL = "https://api.example.com";
const REQUEST_TIMEOUT_MS = 30_000;

// === STRUCTURED LOGGER (inline) ===
function log(level: string, event: string, data: Record<string, unknown> = {}) {
  console.error(JSON.stringify({ ts: new Date().toISOString(), level, event, server: MCP_NAME, ...data }));
}

// === CIRCUIT BREAKER (inline) ===
let cbFailures = 0;
let cbLastFailure = 0;
let cbState: "closed" | "open" | "half-open" = "closed";
const CB_THRESHOLD = 5;
const CB_RESET_MS = 60_000;

function cbCanExecute(): boolean {
  if (cbState === "closed") return true;
  if (cbState === "open" && Date.now() - cbLastFailure >= CB_RESET_MS) { cbState = "half-open"; return true; }
  return cbState === "half-open";
}
function cbSuccess() { cbFailures = 0; cbState = "closed"; }
function cbFailure() { cbFailures++; cbLastFailure = Date.now(); if (cbFailures >= CB_THRESHOLD) cbState = "open"; }

// === API CLIENT (inline) ===
class APIClient {
  constructor(private apiKey: string, private baseUrl = API_BASE_URL) {}

  async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
    if (!cbCanExecute()) throw new Error("Circuit breaker open — API unavailable");
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
    try {
      const response = await fetch(`${this.baseUrl}${endpoint}`, {
        ...options,
        signal: controller.signal,
        headers: { "Authorization": `Bearer ${this.apiKey}`, "Content-Type": "application/json", ...options.headers },
      });
      if (response.status >= 500) { cbFailure(); throw new Error(`Server error: ${response.status}`); }
      if (!response.ok) { const body = await response.text(); throw new Error(`API error ${response.status}: ${body}`); }
      cbSuccess();
      if (response.status === 204) return { success: true } as T;
      return (await response.json()) as T;
    } catch (error) {
      if (error instanceof Error && error.name === "AbortError") { cbFailure(); throw new Error(`Timeout after ${REQUEST_TIMEOUT_MS}ms`); }
      throw error;
    } finally {
      clearTimeout(timeoutId);
    }
  }

  async get<T>(endpoint: string): Promise<T> { return this.request<T>(endpoint); }
  async post<T>(endpoint: string, data: unknown): Promise<T> { return this.request<T>(endpoint, { method: "POST", body: JSON.stringify(data) }); }
  async patch<T>(endpoint: string, data: unknown): Promise<T> { return this.request<T>(endpoint, { method: "PATCH", body: JSON.stringify(data) }); }
  async delete<T>(endpoint: string): Promise<T> { return this.request<T>(endpoint, { method: "DELETE" }); }

  async paginate<T>(endpoint: string, params: { page?: number; pageSize?: number; extraParams?: Record<string, string> } = {}) {
    const { page = 1, pageSize = 25, extraParams = {} } = params;
    const qs = new URLSearchParams({ page: String(page), pageSize: String(Math.min(pageSize, 100)), ...extraParams });
    const result = await this.get<any>(`${endpoint}?${qs}`);
    const data = Array.isArray(result) ? result : result.data || result.items || result.results || [];
    const total = result.meta?.total || result.total || data.length;
    return { data, meta: { total, page, pageSize, hasMore: page * pageSize < total } };
  }

  async healthCheck() {
    const start = performance.now();
    try {
      const controller = new AbortController();
      const tid = setTimeout(() => controller.abort(), 10_000);
      try {
        const r = await fetch(this.baseUrl, { signal: controller.signal, headers: { "Authorization": `Bearer ${this.apiKey}` } });
        return { reachable: true, authenticated: r.status !== 401 && r.status !== 403, latencyMs: Math.round(performance.now() - start) };
      } finally { clearTimeout(tid); }
    } catch (e) {
      return { reachable: false, authenticated: false, latencyMs: Math.round(performance.now() - start), error: String(e) };
    }
  }
}

// === ZOD SCHEMAS ===
const ListItemsSchema = z.object({
  page: z.number().optional().default(1),
  pageSize: z.number().optional().default(25),
});
// ...add more schemas

// === TOOL DEFINITIONS ===
const tools = [
  {
    name: "health_check",
    title: "Health Check",
    description: "Validate server health: env vars set, API reachable, auth valid. Use to diagnose connection issues.",
    inputSchema: { type: "object" as const, properties: {} },
    outputSchema: {
      type: "object", properties: {
        status: { type: "string" }, checks: { type: "object" },
      }, required: ["status", "checks"],
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  {
    name: "list_items",
    title: "List Items",
    description: "List items with pagination. Returns name, status. Use to browse items. Do NOT use to get one item's details.",
    inputSchema: {
      type: "object" as const,
      properties: {
        page: { type: "number", description: "Page number (default 1)" },
        pageSize: { type: "number", description: "Results per page (default 25)" },
      },
    },
    outputSchema: {
      type: "object", properties: {
        data: { type: "array", items: { type: "object" } },
        meta: { type: "object" },
      }, required: ["data", "meta"],
    },
    annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
  },
  // ...add more tools
];

// === TOOL HANDLER ===
async function handleTool(client: APIClient, name: string, args: Record<string, unknown>) {
  switch (name) {
    case "health_check": {
      const required = ["{SERVICE}_API_KEY"];
      const missing = required.filter(v => !process.env[v]);
      const hc = await client.healthCheck();
      const status = missing.length > 0 || !hc.reachable ? "unhealthy" : !hc.authenticated ? "degraded" : "healthy";
      const result = { status, checks: { envVars: { ok: !missing.length, missing }, ...hc } };
      return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result };
    }
    case "list_items": {
      const params = ListItemsSchema.parse(args);
      const result = await client.paginate("/items", { page: params.page, pageSize: params.pageSize });
      return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result };
    }
    // ...add more cases
    default:
      throw new Error(`Unknown tool: ${name}`);
  }
}

// === SERVER ===
async function main() {
  const apiKey = process.env["{SERVICE}_API_KEY"];
  if (!apiKey) { console.error("Error: {SERVICE}_API_KEY required"); process.exit(1); }

  const client = new APIClient(apiKey);
  const server = new Server(
    { name: `${MCP_NAME}-mcp`, version: MCP_VERSION },
    {
      capabilities: {
        tools: { listChanged: false },
        logging: {},
        // Enable ONLY when implemented:
        // resources: { subscribe: false, listChanged: false },
        // prompts: { listChanged: false },
      },
    }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => {
    log("info", "tools.list", { count: tools.length });
    return { tools };
  });

  server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    const start = performance.now();
    log("info", "tool.call.start", { tool: name });
    try {
      const result = await handleTool(client, name, args || {});
      log("info", "tool.call.done", { tool: name, durationMs: Math.round(performance.now() - start) });
      return result;
    } catch (error) {
      const message = error instanceof Error ? error.message : String(error);
      log("error", "tool.call.error", { tool: name, error: message, durationMs: Math.round(performance.now() - start) });
      return {
        content: [{ type: "text", text: `Error: ${message}` }],
        structuredContent: { error: message, tool: name },
        isError: true,
      };
    }
  });

  const transport = new StdioServerTransport();
  await server.connect(transport);
  log("info", "server.started", { transport: "stdio" });
}

main().catch(console.error);

17. README Template

# {Service Name} MCP Server

MCP server for {Service Name} API integration. Provides {N} tools across {M} groups for {brief description of capabilities}.

## Setup

1. **Get API credentials:** {Instructions to get API key from service}
2. **Configure environment:**
   ```bash
   cp .env.example .env
   # Edit .env with your credentials
   ```
3. **Build and run:**
   ```bash
   npm install
   npm run build
   npm start          # stdio transport (default, for Claude Desktop)
   npm run start:http  # HTTP transport (for remote/production)
   ```

## Environment Variables

| Variable | Required | Description |
|----------|----------|-------------|
| `{SERVICE}_API_KEY` | Yes | Your API key from {service dashboard URL} |
| `{SERVICE}_BASE_URL` | No | Override base URL (default: {default URL}) |
| `MCP_TRANSPORT` | No | `stdio` (default) or `http` |
| `MCP_HTTP_PORT` | No | HTTP server port (default: 3000) |

## Available Tools

### Health
| Tool | Description |
|------|-------------|
| `health_check` | Validate server connectivity and auth |

### {Group 1}: {Group Description}
| Tool | Description |
|------|-------------|
| `list_{resources}` | List with filters and pagination |
| `get_{resource}` | Get by ID |
| `create_{resource}` | Create new |
| `update_{resource}` | Update existing |
| `delete_{resource}` | Delete |

{Repeat for each group}

## Transport Options

### Stdio (Local — Claude Desktop, Cursor)
```json
{
  "mcpServers": {
    "{service}": {
      "command": "node",
      "args": ["{absolute-path}/dist/index.js"],
      "env": {
        "{SERVICE}_API_KEY": "your_key_here"
      }
    }
  }
}
```

### Streamable HTTP (Remote — Production)
```bash
MCP_TRANSPORT=http MCP_HTTP_PORT=3000 node dist/index.js
```
Then connect clients to `http://your-server:3000/mcp`.

18. Quality Gate Checklist

Before passing the server to Phase 3/4, verify:

Core Requirements

  • npm run build succeeds — tsc compiles clean, zero errors
  • No template variables remaingrep -r '{service}\|{SERVICE}\|{Service}' src/ returns empty
  • SDK pinned to ^1.26.0 — security fix GHSA-345p-7cg4-v4c7, ensures 2025-11-25 spec support
  • Zod pinned to ^3.25.0 — compatible with SDK v1.x (do NOT use Zod v4 — issue #1429)

Tool Definitions (2025-11-25 Spec)

  • Every tool has title — human-readable display name
  • Every tool has outputSchema — JSON Schema 2020-12 declaring output shape
  • Every tool has annotations — readOnlyHint, destructiveHint, idempotentHint, openWorldHint
  • Every tool description follows the formula — what/returns/when/when-NOT
  • Every tool description under 200 tokens — concise for token budget
  • Total tool definitions under 5,000 tokens — prevents context bloat

Tool Results

  • Every handler returns structuredContent — typed JSON alongside text content
  • structuredContent matches outputSchema — validate shapes match
  • GET single-entity tools return resource_link — subscribable MCP Resource URIs
  • Error responses include isError: true — with both content and structuredContent

Resilience

  • All fetch calls have AbortController timeout — 30s default, no indefinite hangs
  • Circuit breaker is active — fails fast when API is down, auto-recovers
  • Retry logic on 429 and 5xx — with exponential backoff
  • Rate limit headers tracked — proactive wait before hitting limits

Server

  • Only implemented capabilities declared — tools + logging (add resources/prompts only when implemented)
  • health_check tool is included — validates env vars, API reach, auth
  • Structured logging on stderr — JSON-formatted, with request IDs and timing
  • Both transports available — stdio (default) + Streamable HTTP (via MCP_TRANSPORT=http)

Standard Files

  • All required env vars validated on startup — clear error messages if missing
  • .env.example lists ALL variables — with descriptive comments
  • README documents setup, tool list, both transports — copy-paste ready
  • Every tool has Zod input validation — schemas parse before API calls
  • Pagination uses the correct strategy — matches API's pagination pattern
  • No any types — strict TypeScript (except unavoidable API response parsing)
  • Tool names follow verb_noun convention — snake_case, descriptive

19. Execution Workflow

1. Read {service}-api-analysis.md
2. Determine pattern: one-file (≤15 tools) vs modular (15+ tools)
3. Scaffold project structure (mkdir, package.json, tsconfig.json)
4. Create logger.ts (structured JSON logging)
5. Build API client with correct auth pattern, timeouts, circuit breaker
6. Create health.ts (health_check tool — always included)
7. Create tool group files (one per group from analysis)
   - Every tool: name, title, description (with disambiguation), inputSchema, outputSchema, annotations
   - Every handler: Zod validation → API call → return { content, structuredContent }
   - GET single-entity handlers: include resource_link in content
8. Wire up tool registry with lazy loading
9. Create server entry point with both transports
10. Create .env.example and README.md
11. Run `npm install && npm run build`
12. Fix any compilation errors
13. Run token counting helper (Section 11) — verify <200 tokens/tool, <5,000 total
14. Run quality gate checklist
15. Output: compiled MCP server ready for Phase 3/4

Estimated time: 30-60 minutes for small servers, 1-2 hours for large ones.

Agent model recommendation: Sonnet — well-defined patterns, code generation. Escalate to Opus only if auth pattern is unusual or 25+ tools require careful description disambiguation.


This skill is Phase 2 of the MCP Factory pipeline. It takes an analysis document and produces a compiled, production-ready MCP server conforming to the MCP 2025-11-25 spec.