2610 lines
86 KiB
Markdown
2610 lines
86 KiB
Markdown
# 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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
cat > .gitignore << 'EOF'
|
||
node_modules/
|
||
dist/
|
||
.env
|
||
*.log
|
||
EOF
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Core Files — Templates
|
||
|
||
### 4.1 `src/types.ts` — Shared Types
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
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)
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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)
|
||
|
||
```typescript
|
||
#!/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)
|
||
```typescript
|
||
headers: {
|
||
"Authorization": `Bearer ${this.apiKey}`,
|
||
// OR: "X-API-Key": this.apiKey,
|
||
// OR: "Api-Key": this.apiKey,
|
||
}
|
||
```
|
||
|
||
### Pattern B: OAuth2 Client Credentials
|
||
```typescript
|
||
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
|
||
```typescript
|
||
headers: {
|
||
"Authorization": `Basic ${Buffer.from(`${this.username}:${this.password}`).toString("base64")}`,
|
||
}
|
||
```
|
||
|
||
### Pattern D: API Key + Account ID (multi-tenant)
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
{
|
||
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:
|
||
|
||
```typescript
|
||
{
|
||
// 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
|
||
|
||
```typescript
|
||
// 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):
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
### When to include `resource_link`:
|
||
|
||
- 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
|
||
|
||
```typescript
|
||
// 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 50–1000 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 | **15–20** | 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:
|
||
|
||
```bash
|
||
# 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:
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
// 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)
|
||
```typescript
|
||
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)
|
||
```typescript
|
||
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)
|
||
```typescript
|
||
// ?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)
|
||
```typescript
|
||
// ?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)
|
||
```typescript
|
||
// ?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 },
|
||
});
|
||
```
|
||
|
||
### Strategy: Link Header (GitHub-style)
|
||
```typescript
|
||
// 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)
|
||
```typescript
|
||
// 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:
|
||
|
||
```typescript
|
||
{
|
||
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:
|
||
|
||
```typescript
|
||
capabilities: {
|
||
tools: { listChanged: false },
|
||
logging: {},
|
||
tasks: {
|
||
list: {},
|
||
cancel: {},
|
||
requests: { tools: { call: {} } },
|
||
},
|
||
}
|
||
```
|
||
|
||
### Task-aware handler pattern:
|
||
|
||
```typescript
|
||
// 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.
|
||
|
||
```typescript
|
||
#!/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
|
||
|
||
````markdown
|
||
# {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 remain** — `grep -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.*
|