Building Custom MCP Servers
Create your own MCP servers in TypeScript or Python to connect Claude Code to any tool or data source
Building Custom MCP Servers
When existing MCP servers don't cover your needs — a proprietary API, internal tool, or domain-specific data source — you can build your own. A basic MCP server takes about 100 lines of code.
Build vs. Browse
Before building, check the MCP directories: mcp.so, Smithery.ai, and the awesome-mcp-servers list. Someone may have already built what you need.
When to Build a Custom MCP
You should build when:
- Your team has a proprietary API or internal tool that no public MCP covers
- You need domain-specific logic (e.g., coral reef data processing, lab equipment control)
- You want to compose multiple data sources into a single, focused interface
- Existing MCPs are too broad — you need a curated subset of capabilities
You should browse instead when:
- A well-maintained community server already exists
- The integration is standard (GitHub, Postgres, Slack, etc.)
- You need something quickly without custom logic
MCP Core Concepts
Every MCP server can expose three types of capabilities:
| Primitive | What It Does | Web Analogy |
|---|---|---|
| Tools | Functions the AI can call to perform actions | POST endpoints (actions with side effects) |
| Resources | Read-only data for context | GET endpoints (information retrieval) |
| Prompts | User-selectable message templates | Form templates with pre-filled fields |
Part 1: TypeScript MCP Server
Step 1: Project Setup
mkdir project-metrics-mcpcd project-metrics-mcpnpm init -ynpm install @modelcontextprotocol/sdk zodnpm install -D typescript @types/nodeCreate tsconfig.json:
{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true }, "include": ["src/**/*"]}Update package.json:
{ "type": "module", "scripts": { "build": "tsc", "start": "node build/index.js" }}Step 2: Implementation
Create src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod"; // Configuration from environmentconst API_URL = process.env.METRICS_API_URL || "https://api.example.com";const API_KEY = process.env.METRICS_API_KEY || ""; // Helper for API callsasync function apiFetch<T>(endpoint: string): Promise<T | null> { try { const response = await fetch(`${API_URL}${endpoint}`, { headers: { Authorization: `Bearer ${API_KEY}`, Accept: "application/json", }, }); if (!response.ok) { console.error(`API error: ${response.status}`); return null; } return (await response.json()) as T; } catch (error) { console.error(`Fetch failed: ${error}`); return null; }} // Initialize the MCP serverconst server = new McpServer({ name: "project-metrics", version: "1.0.0",}); // Register a tool: list projectsserver.registerTool( "list_projects", { description: "List all active projects with their current status", inputSchema: { status: z .enum(["active", "completed", "archived"]) .optional() .describe("Filter by project status"), }, }, async ({ status }) => { const endpoint = status ? `/projects?status=${status}` : "/projects"; const data = await apiFetch<{ projects: Array<{ name: string; id: string; status: string }> }>(endpoint); if (!data) { return { content: [{ type: "text" as const, text: "Failed to fetch projects." }] }; } const list = data.projects .map((p) => `- **${p.name}** (${p.id}) — ${p.status}`) .join("\n"); return { content: [{ type: "text" as const, text: `## Projects\n\n${list}` }], }; }); // Register a tool: get project detailsserver.registerTool( "get_project_details", { description: "Get detailed metrics for a specific project", inputSchema: { project_id: z.string().describe("The project ID"), }, }, async ({ project_id }) => { const data = await apiFetch<Record<string, unknown>>(`/projects/${project_id}`); if (!data) { return { content: [{ type: "text" as const, text: `Project ${project_id} not found.` }], isError: true, }; } const details = Object.entries(data) .map(([key, value]) => `- **${key}**: ${value}`) .join("\n"); return { content: [{ type: "text" as const, text: `## Project Details\n\n${details}` }], }; }); // Register a resource: project summaryserver.registerResource( "metrics://summary", "metrics://summary", { description: "High-level summary of all project metrics", mimeType: "text/plain", }, async () => { const data = await apiFetch<{ summary: string }>("/summary"); return { contents: [ { uri: "metrics://summary", text: data?.summary || "Unable to fetch summary.", }, ], }; }); // Start the serverasync function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("Project Metrics MCP Server running on stdio");} main().catch((error) => { console.error("Fatal error:", error); process.exit(1);});Never Use console.log in stdio Servers
For stdio-based MCP servers, always use console.error() instead of console.log(). Writing to stdout corrupts the JSON-RPC messages and breaks the protocol.
Step 3: Build and Register
- 1
Build the server
Bashnpm run build - 2
Register in Claude Code
Bashclaude mcp add project-metrics -- node /absolute/path/to/project-metrics-mcp/build/index.jsOr add to your config:
JSON{"mcpServers": {"project-metrics": {"command": "node","args": ["/absolute/path/to/project-metrics-mcp/build/index.js"],"env": {"METRICS_API_URL": "https://api.yourlab.edu","METRICS_API_KEY": "your-api-key-here"}}}} - 3
Test it
BashList all active projects.Get the details for project ABC-123.
Part 2: Python MCP Server
Python's FastMCP uses type hints and docstrings to automatically generate tool definitions — far less boilerplate than TypeScript.
Step 1: Project Setup
# Install uv if you haven't alreadycurl -LsSf https://astral.sh/uv/install.sh | sh # Create and set up the projectuv init lab-data-mcpcd lab-data-mcpuv venvsource .venv/bin/activate # Install dependenciesuv add "mcp[cli]" httpxStep 2: Implementation
Create server.py:
"""Lab Data MCP Server — connects Claude to your lab's data system.""" from typing import Anyimport httpxfrom mcp.server.fastmcp import FastMCP mcp = FastMCP("lab-data") API_BASE = "https://api.yourlab.edu/v1"API_TOKEN = "" # Set via environment variable async def api_get(endpoint: str) -> dict[str, Any] | None: """Make an authenticated GET request to the lab API.""" headers = {"Authorization": f"Bearer {API_TOKEN}", "Accept": "application/json"} async with httpx.AsyncClient() as client: try: response = await client.get(f"{API_BASE}{endpoint}", headers=headers, timeout=30.0) response.raise_for_status() return response.json() except Exception as e: print(f"Request failed: {e}", flush=True) return None @mcp.tool()async def list_datasets(project: str | None = None, limit: int = 20) -> str: """List available datasets in the lab data system. Args: project: Filter by project name (optional) limit: Maximum number of datasets to return (default 20) """ endpoint = f"/datasets?limit={limit}" if project: endpoint += f"&project={project}" data = await api_get(endpoint) if not data or "datasets" not in data: return "Unable to fetch datasets." lines = [] for ds in data["datasets"]: lines.append( f"- **{ds['name']}** ({ds['id']})\n" f" Project: {ds.get('project', 'N/A')} | " f"Rows: {ds.get('row_count', '?')}" ) return "## Datasets\n\n" + "\n\n".join(lines) @mcp.tool()async def query_dataset(dataset_id: str, sql: str, limit: int = 100) -> str: """Run a SQL query against a lab dataset. Args: dataset_id: The ID of the dataset to query sql: SQL query to execute (SELECT only) limit: Maximum rows to return (default 100) """ if not sql.strip().upper().startswith("SELECT"): return "Error: Only SELECT queries are allowed." # POST the query to the API async with httpx.AsyncClient() as client: response = await client.post( f"{API_BASE}/datasets/{dataset_id}/query", json={"sql": sql, "limit": limit}, headers={"Authorization": f"Bearer {API_TOKEN}"}, timeout=30.0, ) if not response.is_success: return f"Query failed: {response.status_code}" result = response.json() columns = result.get("columns", []) rows = result.get("rows", []) if not rows: return "Query returned no results." header = " | ".join(columns) separator = " | ".join(["---"] * len(columns)) body = "\n".join(" | ".join(str(cell) for cell in row) for row in rows) return f"**Results** ({len(rows)} rows):\n\n{header}\n{separator}\n{body}" @mcp.resource("lab://projects/active")async def active_projects() -> str: """List all currently active research projects.""" data = await api_get("/projects?status=active") if not data: return "Unable to fetch active projects." return "\n".join( f"{p['name']} ({p['id']}): {p.get('description', 'No description')}" for p in data.get("projects", []) ) @mcp.prompt()def analyze_dataset(dataset_name: str) -> str: """Generate a prompt for Claude to analyze a dataset. Args: dataset_name: Name of the dataset to analyze """ return ( f"Please analyze the '{dataset_name}' dataset. " f"List columns and types, provide summary statistics, " f"identify anomalies, and suggest interesting analyses." ) def main(): mcp.run(transport="stdio") if __name__ == "__main__": main()FastMCP Magic
Notice: no manual JSON Schema definitions. FastMCP reads your function signatures, type hints, and docstrings to automatically generate the inputSchema that MCP requires.
Step 3: Register and Run
claude mcp add lab-data -- uv --directory /absolute/path/to/lab-data-mcp run server.pyPart 3: Testing and Debugging
The MCP Inspector
The MCP Inspector is an interactive browser-based tool for testing servers without connecting to Claude.
# Inspect a TypeScript servernpx @modelcontextprotocol/inspector node /path/to/build/index.js # Inspect a Python servernpx @modelcontextprotocol/inspector uv --directory /path/to/server run server.pyThe Inspector opens at http://localhost:6274. You can browse tools, call them with custom inputs, and inspect raw JSON-RPC messages.
Common Debugging Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| Server not in Claude Code | Relative path in config | Use absolute paths. Run pwd to get full path. |
| Tools show but calls fail | Runtime error in handler | Check ~/Library/Logs/Claude/mcp-server-NAME.log |
| Parse error or garbled output | console.log in stdio server | Replace console.log with console.error |
| Server connects then drops | Unhandled exception on startup | Wrap main() in try/catch, log to stderr |
| Env vars not available | Missing env in config | Add env field to your mcpServers config |
CLI-Level Debugging
# Send a raw JSON-RPC initialize requestecho '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | node build/index.js 2>/dev/null | jq .Part 4: Advanced Patterns
Rate Limiting and Caching
For servers that call external APIs:
const cache = new Map<string, { data: unknown; expires: number }>(); function getCached<T>(key: string): T | null { const entry = cache.get(key); if (!entry || Date.now() > entry.expires) { cache.delete(key); return null; } return entry.data as T;} function setCache(key: string, data: unknown, ttlMs = 60_000): void { cache.set(key, { data, expires: Date.now() + ttlMs });}Streamable HTTP for Remote Servers
For servers that need to be accessed over the network:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";import express from "express"; const app = express();app.use(express.json()); const server = new McpServer({ name: "remote-metrics", version: "1.0.0" }); // Register tools... const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, // Stateless mode}); app.post("/mcp", async (req, res) => { await transport.handleRequest(req, res, req.body);}); await server.connect(transport);app.listen(3001, () => { console.error("Remote MCP server at http://localhost:3001/mcp");});stdio vs HTTP
Use stdio for local servers on your machine. Use Streamable HTTP for remote servers that multiple users connect to over the network.
The Community MCP Ecosystem
Before building from scratch, check these directories:
| Directory | Size | Best For |
|---|---|---|
| Smithery.ai | 2,200+ servers | Automated install guides, marketplace browsing |
| MCP.so | Curated hub | Tutorials, news, community recommendations |
| awesome-mcp-servers (GitHub) | 1,200+ servers | Quality-verified, categorized lists |
| mcpservers.org | Growing | Clean categorization and search |
| Official MCP Examples | Reference | Canonical implementations from the MCP team |
Evaluating Community MCPs for Security
- 1
Check the source
Is the code open source? Can you read every line? Avoid closed-source MCP servers.
- 2
Review permissions
What does the server access? A "filesystem" server that makes network requests is a red flag.
- 3
Check maintenance
When was the last commit? Are there open security issues?
- 4
Audit dependencies
Run
npm auditor checkrequirements.txtfor known vulnerabilities.
MCP Servers Have Power
An MCP server can do anything its tools allow — read files, make network requests, execute commands. Only install servers from sources you trust. Never put secrets into a project-scoped .mcp.json that gets committed to version control.
Quick Reference
TypeScript key imports:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import { z } from "zod";Python key imports:
from mcp.server.fastmcp import FastMCPmcp = FastMCP("your-server-name")Next Steps
- Workflows & Troubleshooting → — Put your servers to use in real workflows
- Essential Servers → — See what's already available
- MCP Fundamentals → — Protocol deep-dive
Build What You Need
Custom MCP servers are where MCP truly shines for domain-specific work. A Python FastMCP server for your lab's data API takes 30 minutes to build and gives Claude direct access to your research data across every session.