Playbook
Building an MCP server takes 30 minutes. Building one that works reliably in production takes deliberate design: good tool descriptions, proper error handling, timeouts, and transport awareness.
Custom Server: TypeScript
The TypeScript SDK is the most mature. Requires Node.js 18+ and moduleResolution: NodeNext in tsconfig.json.
import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
import type { CallToolResult } from "@modelcontextprotocol/server";
const server = new McpServer(
{ name: "app-metrics", version: "1.0.0" },
{
instructions: "Server for querying application metrics. Use when asked about performance data, error rates, or user analytics."
}
);
server.registerTool(
"get-error-rate",
{
title: "Error Rate",
description: "Get the error rate for a service over a time range. Returns rate as a decimal, total request count, and error count. Use for monitoring dashboards and incident investigation.",
inputSchema: z.object({
service: z.string().describe("Service name, e.g. 'api-gateway'"),
hours: z.number().default(24).describe("Lookback window in hours")
}),
outputSchema: z.object({
rate: z.number(),
total_requests: z.number(),
error_count: z.number()
})
},
async ({ service, hours }): Promise<CallToolResult> => {
const data = await queryMetrics(service, hours);
const output = {
rate: data.errors / data.total,
total_requests: data.total,
error_count: data.errors
};
return {
content: [{ type: "text", text: JSON.stringify(output) }],
structuredContent: output
};
}
);
const transport = new StdioServerTransport();
await server.connect(transport);Three patterns define production quality: descriptions that tell Claude when to use the tool (not just what it does), structured output schemas for machine-readable responses, and the instructions field on the server itself for cross-cutting context.
Custom Server: Python (FastMCP)
Python's FastMCP provides decorator-based registration. Docstrings become tool descriptions automatically.
from mcp.server.fastmcp import FastMCP
import json
mcp = FastMCP("app-metrics", json_response=True)
@mcp.tool()
def get_error_rate(service: str, hours: int = 24) -> dict:
"""Get the error rate for a service over a time range.
Returns rate as decimal, total requests, and error count.
Use for monitoring and incident investigation.
Args:
service: Service name, e.g. 'api-gateway'
hours: Lookback window in hours (default 24)
"""
data = query_metrics(service, hours)
return {
"rate": data["errors"] / data["total"],
"total_requests": data["total"],
"error_count": data["errors"]
}
@mcp.resource("services://all")
def list_services() -> str:
"""List all monitored services."""
return json.dumps(get_service_list())
if __name__ == "__main__":
mcp.run(transport="stdio")A more complete Python server with multiple tools and a resource template:
from mcp.server.fastmcp import FastMCP
import json
import subprocess
mcp = FastMCP("ci-pipeline", json_response=True)
@mcp.tool()
def get_pipeline_status(repo: str, branch: str = "main") -> dict:
"""Get CI pipeline status for a branch.
Returns latest run status, duration, and failing jobs.
Use when investigating build failures or checking deploy readiness.
Args:
repo: Repository name, e.g. 'myorg/api-service'
branch: Git branch name (default 'main')
"""
result = subprocess.run(
["gh", "run", "list", "--repo", repo, "--branch", branch,
"--limit", "1", "--json", "status,conclusion,name,updatedAt,databaseId"],
capture_output=True, text=True, timeout=15
)
runs = json.loads(result.stdout)
return runs[0] if runs else {"error": "No runs found"}
@mcp.tool()
def get_failed_jobs(repo: str, run_id: int) -> list:
"""Get details of failed jobs in a CI run.
Returns job names, step names, and error messages.
Args:
repo: Repository name
run_id: The workflow run ID from get_pipeline_status
"""
result = subprocess.run(
["gh", "run", "view", str(run_id), "--repo", repo,
"--json", "jobs", "--jq", '.jobs[] | select(.conclusion == "failure")'],
capture_output=True, text=True, timeout=15
)
return json.loads(f"[{result.stdout}]") if result.stdout else []
@mcp.resource("repos://{repo}/workflows")
def list_workflows(repo: str) -> str:
"""List all CI workflows configured for a repository."""
result = subprocess.run(
["gh", "workflow", "list", "--repo", repo, "--json", "name,state,id"],
capture_output=True, text=True, timeout=10
)
return result.stdout
if __name__ == "__main__":
mcp.run(transport="stdio")SDK version warning: Python SDK V1 to V2 is a breaking migration. Pin your version explicitly. FastMCP was the V1 class name; V2 uses McpServer. Check your import paths.
Error Handling Pattern
Never throw from a tool handler. Return isError: true to communicate failures to Claude as structured data rather than crashing the server.
async ({ url }): Promise<CallToolResult> => {
try {
const res = await fetch(url);
if (!res.ok) {
return {
content: [{ type: "text", text: `HTTP ${res.status}: ${res.statusText}` }],
isError: true
};
}
return { content: [{ type: "text", text: await res.text() }] };
} catch (error) {
return {
content: [{
type: "text",
text: `Failed: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
}Claude interprets isError: true as a failed operation and adjusts its strategy -- retrying, trying an alternative tool, or reporting the failure to the user. Unhandled exceptions crash the server process entirely.
Timeout Pattern
Every tool handler needs a timeout. Claude blocks on tool responses, and a tool that hangs for 60 seconds freezes the entire conversation.
server.registerTool("slow-query", {
description: "Execute an analytics query (30s timeout)",
inputSchema: z.object({ query: z.string() })
}, async ({ query }): Promise<CallToolResult> => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Query timed out after 30s")), 30000)
);
try {
const result = await Promise.race([executeQuery(query), timeout]);
return { content: [{ type: "text", text: JSON.stringify(result) }] };
} catch (error) {
return {
content: [{ type: "text", text: `Timeout: ${error.message}` }],
isError: true
};
}
});30 seconds is a reasonable ceiling for most tools. For legitimately slow operations, split into "start" and "poll" tool pairs.
Remote Transport: Streamable HTTP
For servers deployed to cloud infrastructure, use Streamable HTTP instead of stdio.
import { randomUUID } from "node:crypto";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
await server.connect(transport);Streamable HTTP handles bidirectional messaging over a single endpoint, with optional SSE for streaming. It survives proxy timeouts and load balancer quirks better than the deprecated SSE transport.
A complete Streamable HTTP server with Express, suitable for cloud deployment:
import express from "express";
import { randomUUID } from "node:crypto";
import { McpServer } from "@modelcontextprotocol/server";
import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node";
import * as z from "zod/v4";
const app = express();
app.use(express.json());
const server = new McpServer(
{ name: "deploy-status", version: "1.0.0" },
{ instructions: "Deployment status and health monitoring." }
);
server.registerTool("get-deploy-status", {
title: "Deployment Status",
description: "Get the current deployment status for a service. Returns version, health, and last deploy timestamp.",
inputSchema: z.object({
service: z.string().describe("Service name, e.g. 'api-gateway'"),
environment: z.enum(["staging", "production"]).default("production")
})
}, async ({ service, environment }) => {
const status = await fetchDeployStatus(service, environment);
return {
content: [{ type: "text", text: JSON.stringify(status, null, 2) }]
};
});
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID()
});
app.all("/mcp", async (req, res) => {
await transport.handleRequest(req, res);
});
await server.connect(transport);
app.listen(3001, () => console.error("MCP server on :3001/mcp"));Configure it in .mcp.json:
{
"mcpServers": {
"deploy-status": {
"type": "http",
"url": "http://localhost:3001/mcp"
}
}
}Database Servers
Two production-grade options cover most relational databases.
DBHub (Bytebase) -- universal bridge for PostgreSQL, MySQL, MariaDB, SQL Server, SQLite:
claude mcp add --transport stdio db -- npx -y @bytebase/dbhub \
--dsn "postgresql://readonly:pass@prod.db.com:5432/analytics"Postgres MCP Pro -- PostgreSQL-specific with safe SQL parsing, execution limits, and connection pooling:
claude mcp add --transport stdio postgres-pro -- npx -y @crystaldba/postgres-mcp \
--connection-string "postgresql://readonly:pass@prod.db.com:5432/analytics" \
--read-onlyAlways use read-only credentials for production databases. Claude should never have write access to production data through MCP.
API Integration Servers
GraphQL -- auto-convert .graphql query files to MCP tools:
claude mcp add --transport stdio graphql -- npx -y graphql-mcp-server \
--endpoint https://api.example.com/graphql \
--queries ./queries/REST API wrapper -- configure endpoints via YAML:
# api-config.yaml
apis:
- name: get_users
method: GET
url: https://api.example.com/users
description: "Fetch user list with optional status filter"
params:
- name: status
type: string
required: false
- name: create_ticket
method: POST
url: https://api.example.com/tickets
description: "Create a support ticket with title and priority"
body:
- name: title
type: string
required: true
- name: priority
type: string
required: trueMonitoring Servers
Datadog (official, remote):
claude mcp add --transport http datadog https://mcp.datadoghq.comSupports core metrics, alerting, APM, logs, traces, security findings, and synthetic tests. Provides live observability data directly in Claude Code sessions.
Grafana (local):
claude mcp add --transport stdio grafana -- npx -y @grafana/mcp-grafanaExposes distributed tracing, dashboard management, and natural language queries for exploring services.
CI/CD Servers
GitHub (official, remote):
claude mcp add --transport http github https://api.githubcopilot.com/mcp/Repository management, issue/PR operations, Actions workflow monitoring, build failure analysis, and release management.
Jenkins:
claude mcp add --transport stdio jenkins -- npx -y mcp-jenkins \
--url https://jenkins.example.com \
--user admin \
--token $JENKINS_TOKEN37 tools for job monitoring, build control, queue management, and pipeline configuration.
Configuration: .mcp.json
The project-scoped configuration file supports environment variable expansion with defaults.
{
"mcpServers": {
"db": {
"command": "npx",
"args": ["-y", "@bytebase/dbhub", "--dsn", "${DATABASE_URL}"],
"env": {
"DATABASE_URL": "${DATABASE_URL:-postgresql://localhost:5432/dev}"
}
},
"api": {
"type": "http",
"url": "${API_BASE_URL:-https://api.example.com}/mcp",
"headers": {
"Authorization": "Bearer ${API_KEY}"
}
},
"internal-api": {
"type": "http",
"url": "https://mcp.internal.example.com",
"headersHelper": "/opt/bin/get-mcp-auth-headers.sh"
}
}
}Environment variable syntax:
${VAR}-- expands to the value of VAR${VAR:-default}-- uses VAR if set, otherwise falls back to the default- Expansion works in
command,args,env,url, andheadersfields
Dynamic headers with headersHelper: Runs a shell command that outputs a JSON object of headers to stdout. Executes fresh on each connection (no caching). 10-second timeout. Receives CLAUDE_CODE_MCP_SERVER_NAME and CLAUDE_CODE_MCP_SERVER_URL as environment variables.
OAuth Authentication
For servers requiring OAuth:
# Dynamic client registration
claude mcp add --transport http my-server https://mcp.example.com/mcp
# Pre-configured credentials with fixed callback port
claude mcp add --transport http \
--client-id your-client-id --client-secret --callback-port 8080 \
my-server https://mcp.example.com/mcp
# CI/non-interactive: pass secret via environment
MCP_CLIENT_SECRET=your-secret claude mcp add --transport http \
--client-id your-client-id --client-secret --callback-port 8080 \
my-server https://mcp.example.com/mcpThe equivalent .mcp.json configuration for OAuth-authenticated servers:
{
"mcpServers": {
"my-server": {
"type": "http",
"url": "https://mcp.example.com/mcp",
"clientId": "your-client-id",
"clientSecret": "${MCP_CLIENT_SECRET}",
"callbackPort": 8080
}
}
}Windows Quirk
On native Windows (not WSL), npx requires a cmd /c wrapper:
claude mcp add --transport stdio my-server -- cmd /c npx -y @some/packageWithout this wrapper, the server process fails to start. This is the most common platform-specific MCP issue.