
Want to unlock the full potential of Claude's capabilities in your own applications? Model Context Protocol (MCP) servers are the answer. But here's the real question: how do you build production-grade MCP servers that scale, validate inputs safely, and integrate seamlessly with Claude?
If you're a TypeScript developer looking to extend Claude's functionality with custom tools, resources, and prompts, this guide is for you. We'll walk through everything from project setup to publishing your server, with practical code examples and real-world best practices. By the time you finish, you'll understand not just how to build an MCP server, but how to build one that's robust enough for production environments and mature enough to power real applications.
Table of Contents
- What Are MCP Servers and Why They Matter
- Understanding the Architecture of MCP
- Project Setup and Dependencies
- Designing Your Tools
- The Philosophy of Tool Design for AI
- Tool Granularity: Doing One Thing Well
- Building the Tool Handlers
- Creating the MCP Server
- Error Handling and Validation
- Testing Your Server
- Comprehensive Testing Strategy for MCP Servers
- Building for Production
- Understanding Tool Schema Design at Scale
- Real-World Deployment Patterns
- Scaling Your Server
- Version Management
- Why TypeScript Matters for Production Systems
- Learning from Real MCP Server Deployments
- Extending Your MCP Server Over Time
- Advanced Pattern: Resource-Based Tools
- Bearer Token
- Token Generation
- Rate Limiting
- GET /api/users/:id
- POST /api/users
- users table
- orders table
- Handling Streaming Responses
- Advanced Error Recovery
- Observability and Production Monitoring
- Deploying TypeScript MCP Servers
- Iterating on Your Server
- Wrapping Up
What Are MCP Servers and Why They Matter
Before we dive into the mechanics, let's ground ourselves in the "why." Model Context Protocol is a standardized way to provide Claude with access to tools, resources, and prompts through a clean, extensible interface. Instead of embedding all capabilities directly in your application, MCP servers decouple concerns: Claude stays focused on reasoning, while your server handles domain-specific logic.
Understanding the Architecture of MCP
The traditional approach to AI integration is monolithic. You embed Claude API calls directly in your application. You define tools inline. You manage context yourself. This works for simple cases, but it creates problems as you scale. Your application code becomes tangled with Claude-specific logic. Adding new tools requires changing your application. Updating a tool requires redeploying the whole application. Testing tools requires testing through the full application stack.
MCP inverts this architecture. Instead of embedding Claude in your app, you expose your app's capabilities through a standard protocol. Claude (or any client implementing MCP) can discover those capabilities and use them. This is a fundamental shift. Your server becomes reusable infrastructure. Multiple clients can use the same server. The server can be updated independently. Tools can be tested in isolation. This is the architectural power of protocols: they create clean boundaries between systems.
The Model Context Protocol defines how clients and servers communicate. A client (like Claude) connects to a server and asks "what tools do you have?" The server responds with a list of tools, their descriptions, and their input schemas. When the client wants to use a tool, it sends a request with the tool name and arguments. The server executes and returns results. This simple request-response pattern is the foundation of everything MCP servers do.
Think of it like this: an MCP server is a structured bridge between Claude and your data or APIs. Claude can ask your server for information, invoke tools, and work with reusable prompts—all through a well-defined protocol. This matters because:
- Type Safety: TypeScript ensures your tools won't accept malformed inputs. The compiler catches mistakes before they reach production. There's no guessing about what types are valid; the type system enforces it.
- Scalability: Servers can be deployed independently and updated without touching your main Claude integration. This is crucial for teams managing multiple services. You can update your authentication logic without redeploying everything.
- Separation of Concerns: Your domain logic lives in one place; Claude's reasoning lives in another. Each can evolve independently. Your MCP server doesn't need to know anything about Claude's reasoning; Claude doesn't need to know your database schema.
- Composability: Multiple MCP servers work together seamlessly, giving Claude access to multiple domains. You can have one server for databases, one for APIs, one for file systems—Claude uses all of them.
- Version Control: You can version your MCP server independently, allowing gradual upgrades without breaking existing Claude integrations. You can release version 2.0 with breaking changes while version 1.0 still serves clients.
- Reusability: The same MCP server can power multiple applications or Claude instances. It becomes foundational infrastructure.
The real power emerges when you realize this: you're building infrastructure that Claude can use to reason about your domain. You're not writing "Claude integration code"—you're writing a domain-specific API that Claude happens to be able to call. That's a subtle but important distinction. It means your server stays useful even if Claude changes, even if you integrate with other AI systems, even if you build web UIs or mobile apps on top. It's foundational infrastructure, not glue code.
Project Setup and Dependencies
Let's start with a real project. Create a new directory and initialize it with proper TypeScript and dependency management. This foundation matters: you're building production infrastructure, not a one-off script.
mkdir my-mcp-server
cd my-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk typescript ts-node dotenv zod
npm install --save-dev @types/node typescript
npx tsc --initThe key dependency is @modelcontextprotocol/sdk, which provides the protocol implementation. We're also installing zod, which is critical for input validation—you never want malformed inputs reaching your business logic. When Claude sends a tool request, Zod validates it matches the schema. If it doesn't, your code doesn't run. This is how you prevent cascading failures.
Create your tsconfig.json with strict settings:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}This configuration gives you strict type checking, source maps for debugging, and declaration files so other packages can use your server's types. Strict mode catches mistakes at compile time instead of runtime—critical for reliability in production systems. When the TypeScript compiler rejects your code, that's a win: you've caught a bug before it runs.
Designing Your Tools
Before you write a single line of implementation code, think carefully about your tools. What should Claude be able to do in your domain? What information does it need? Good tool design is half the battle, and it determines whether Claude can use your server effectively. A well-designed tool is like a well-designed API: it's easy to understand, hard to misuse, and provides exactly the information needed.
The Philosophy of Tool Design for AI
Tools designed for AI have different requirements than tools designed for humans. A human using a CLI learns the tool over time. They memorize flags, build muscle memory, develop intuition. An AI encounters the tool fresh each time. It relies entirely on the tool's description, parameters, and schema to understand what it does. This means your documentation becomes functional: Claude reads the descriptions and makes decisions based on them. If your description is unclear, Claude will misuse the tool.
Think about the asymmetry. You know what your database schema is because you designed it. You know what your API does because you built it. Claude knows nothing except what you tell it in the tool description and schema. Every ambiguity becomes an opportunity for Claude to make a wrong decision. So precision in documentation isn't optional—it's foundational.
Consider two approaches to the same tool:
Vague: "Search for customers. Input: search term. Output: list of customers."
Precise: "Search for customers by name, email, or phone. Input: search_term (string, required). Searches all three fields with substring matching. Output: array of matching customer objects with id, name, email, phone, created_at. Returns maximum 100 results. Empty search returns error."
The precise version tells Claude exactly what the tool does, what it accepts, and what it returns. Claude can use it correctly. The vague version leaves Claude guessing. Does it search all fields or just name? Does it match substrings or exact matches? What's returned? How many results? Claude has to guess, and guesses are often wrong.
Tool Granularity: Doing One Thing Well
A principle from Unix philosophy applies to MCP tools: do one thing well. A tool that tries to do multiple things becomes confusing and hard to use correctly. Instead of a tool that "manages users" (which could mean create, read, update, delete, search, block, unblock, admin-ify), create multiple focused tools: "create_user", "get_user_by_id", "search_users", "update_user_profile", "admin_promote_user", "block_user".
This focuses Claude's decision-making. When Claude wants to search for users, it calls the search tool. When it wants to promote a user, it calls the promotion tool. Each tool has a clear purpose, clear inputs, clear outputs. The tool names form a coherent vocabulary for the domain.
The constraint is token efficiency. More tools means larger tool lists. Claude reads tool descriptions, and longer lists cost tokens. But tokens are cheaper than mistakes. A well-designed set of focused tools that Claude uses correctly is better than a poorly-designed monolithic tool that Claude misuses.
For a concrete example, let's build a calculator MCP server. It seems simple, but it teaches you the important patterns you'll use in real applications, especially the pattern of validating inputs thoroughly before passing them to business logic. Everything you learn here scales to complex servers.
// src/types.ts
import { z } from "zod";
// Define input schemas using Zod
export const AddInputSchema = z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
});
export const MultiplyInputSchema = z.object({
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
precision: z
.number()
.optional()
.describe("Decimal places for rounding (default: 2)"),
});
export const DivideInputSchema = z.object({
a: z.number().describe("Dividend"),
b: z.number().describe("Divisor (must not be zero)"),
});The schemas are self-documenting. The describe() calls become descriptions in the Claude interface. The validators ensure bad input is caught immediately—before it reaches your business logic. This matters because validation errors become learning opportunities for Claude; if it sees a validation error, it adjusts its approach on retry.
Building the Tool Handlers
Now implement the actual logic. Notice how validation happens first, enabling clean business logic without defensive programming:
// src/calculator.ts
import {
AddInputSchema,
MultiplyInputSchema,
DivideInputSchema,
} from "./types";
export class Calculator {
add(input: unknown): number {
const parsed = AddInputSchema.parse(input);
return parsed.a + parsed.b;
}
subtract(a: number, b: number): number {
return a - b;
}
multiply(input: unknown): number {
const parsed = MultiplyInputSchema.parse(input);
const result = parsed.a * parsed.b;
if (parsed.precision !== undefined) {
const factor = Math.pow(10, parsed.precision);
return Math.round(result * factor) / factor;
}
return result;
}
divide(input: unknown): number {
const parsed = DivideInputSchema.parse(input);
if (parsed.b === 0) {
throw new Error("Division by zero is not allowed");
}
return parsed.a / parsed.b;
}
}Notice that validation happens first. The Zod schema will throw an error if input doesn't match. This means your handler can assume the input is valid. No defensive checks, no null coalescing—just clean business logic. This pattern separates concerns perfectly: Zod validates; your code implements.
Creating the MCP Server
Now wire everything together using the official SDK. This is where the protocol abstraction handles communication details:
// src/server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
ListToolsRequestSchema,
CallToolRequestSchema,
Tool,
} from "@modelcontextprotocol/sdk/types.js";
import { Calculator } from "./calculator";
const server = new Server({
name: "calculator-server",
version: "1.0.0",
});
const calculator = new Calculator();
// Define your tools
const tools: Tool[] = [
{
name: "add",
description: "Add two numbers together",
inputSchema: {
type: "object",
properties: {
a: {
type: "number",
description: "First number",
},
b: {
type: "number",
description: "Second number",
},
},
required: ["a", "b"],
},
},
{
name: "multiply",
description: "Multiply two numbers with optional precision",
inputSchema: {
type: "object",
properties: {
a: {
type: "number",
description: "First number",
},
b: {
type: "number",
description: "Second number",
},
precision: {
type: "number",
description: "Decimal places for rounding (optional, default: 2)",
},
},
required: ["a", "b"],
},
},
{
name: "divide",
description: "Divide one number by another",
inputSchema: {
type: "object",
properties: {
a: {
type: "number",
description: "Dividend",
},
b: {
type: "number",
description: "Divisor (must not be zero)",
},
},
required: ["a", "b"],
},
},
];
// Handle tool listing
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools,
}));
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request;
try {
let result;
switch (name) {
case "add":
result = calculator.add(args);
break;
case "multiply":
result = calculator.multiply(args);
break;
case "divide":
result = calculator.divide(args);
break;
default:
return {
content: [
{
type: "text",
text: `Unknown tool: ${name}`,
},
],
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Result: ${result}`,
},
],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
// Connect to stdio
server.connect(process.stdin, process.stdout);
console.error("Calculator MCP server started");The pattern is straightforward: define tools, register handlers, connect to stdio. The SDK handles the protocol details. This server-per-method pattern scales: as you add more tools, you add more cases in the switch statement and more tools to the tools array. The structure remains clean because each tool is a discrete case.
Error Handling and Validation
The Zod schemas give you validation, but you also need application-level error handling. Let's create a comprehensive error handling system:
// src/error-handler.ts
export class ValidationError extends Error {
constructor(
message: string,
public field?: string,
) {
super(message);
this.name = "ValidationError";
}
}
export class ApplicationError extends Error {
constructor(
message: string,
public code?: string,
) {
super(message);
this.name = "ApplicationError";
}
}
export function formatError(error: unknown): string {
if (error instanceof ValidationError) {
return `Validation error${error.field ? ` in ${error.field}` : ""}: ${error.message}`;
}
if (error instanceof ApplicationError) {
return `Error: ${error.message}`;
}
if (error instanceof Error) {
return `Unexpected error: ${error.message}`;
}
return `Unknown error: ${String(error)}`;
}Then use this in your handlers:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request;
try {
let result;
switch (name) {
case "add":
result = calculator.add(args);
break;
// ... other cases
default:
throw new ApplicationError(`Unknown tool: ${name}`, "UNKNOWN_TOOL");
}
return {
content: [
{
type: "text",
text: `Result: ${result}`,
},
],
};
} catch (error) {
const message = formatError(error);
return {
content: [
{
type: "text",
text: message,
},
],
isError: true,
};
}
});Good error messages matter. When Claude sees an error, it can learn from it and retry with different input. Cryptic errors waste Claude's attempts and frustrate users.
Testing Your Server
You need tests at multiple levels. Tool-level tests verify business logic. Server-level tests verify the MCP interface works correctly. This is the foundation of reliability.
Comprehensive Testing Strategy for MCP Servers
Testing MCP servers requires a multi-layered approach because failures can occur at different levels. Tool-level tests verify that your business logic works. Protocol-level tests verify that the MCP interface works correctly. Integration tests verify that tools work together as expected.
Tool-level tests are straightforward—you're testing functions, validating inputs and outputs, checking error cases. These are unit tests in the traditional sense. You validate that the calculator correctly adds numbers, handles negative numbers, rejects invalid input.
Protocol-level tests are different. You're testing that the MCP server correctly implements the protocol. When a client requests a list of tools, does the server return valid JSON? When a client calls a tool with invalid parameters, does the server reject it? When a tool returns data, is it in the correct format? These tests verify the server's contract with clients.
Integration tests combine multiple tools and verify they work together. If one tool depends on another's output, do they compose correctly? If a tool can fail in multiple ways, does the client handle all failure modes? These tests catch problems that unit tests miss.
// src/__tests__/calculator.test.ts
import { Calculator } from "../calculator";
import { AddInputSchema } from "../types";
describe("Calculator", () => {
let calc: Calculator;
beforeEach(() => {
calc = new Calculator();
});
describe("add", () => {
it("should add two positive numbers", () => {
const result = calc.add({ a: 2, b: 3 });
expect(result).toBe(5);
});
it("should add negative numbers", () => {
const result = calc.add({ a: -2, b: 3 });
expect(result).toBe(1);
});
it("should reject invalid input", () => {
expect(() => calc.add({ a: "not a number", b: 3 })).toThrow();
});
it("should reject missing fields", () => {
expect(() => calc.add({ a: 2 })).toThrow();
});
});
describe("divide", () => {
it("should divide numbers", () => {
const result = calc.divide({ a: 10, b: 2 });
expect(result).toBe(5);
});
it("should reject division by zero", () => {
expect(() => calc.divide({ a: 10, b: 0 })).toThrow("Division by zero");
});
});
describe("multiply", () => {
it("should multiply numbers", () => {
const result = calc.multiply({ a: 3, b: 4 });
expect(result).toBe(12);
});
it("should apply precision", () => {
const result = calc.multiply({ a: 1.234, b: 2.345, precision: 2 });
expect(result).toBe(2.89); // Rounded to 2 decimal places
});
});
});Run with:
npm testBuilding for Production
When you're ready to deploy, you need additional considerations. First, add a build step to package.json:
{
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts",
"test": "jest",
"lint": "eslint src --ext .ts"
}
}Create an environment configuration system:
// src/config.ts
import dotenv from "dotenv";
dotenv.config();
export const config = {
environment: process.env.NODE_ENV || "development",
debug: process.env.DEBUG === "true",
apiKey: process.env.API_KEY,
databaseUrl: process.env.DATABASE_URL,
};
// Validate required config at startup
if (config.environment === "production" && !config.apiKey) {
throw new Error("API_KEY is required in production");
}Add monitoring and metrics:
// src/monitoring.ts
export class ServerMetrics {
private toolCalls: Map<string, number> = new Map();
private toolErrors: Map<string, number> = new Map();
private toolDurations: Map<string, number[]> = new Map();
recordToolCall(toolName: string, duration: number, success: boolean) {
this.toolCalls.set(toolName, (this.toolCalls.get(toolName) || 0) + 1);
if (!success) {
this.toolErrors.set(toolName, (this.toolErrors.get(toolName) || 0) + 1);
}
if (!this.toolDurations.has(toolName)) {
this.toolDurations.set(toolName, []);
}
this.toolDurations.get(toolName)!.push(duration);
}
getMetrics() {
const metrics: Record<string, any> = {};
for (const [tool, count] of this.toolCalls.entries()) {
const durations = this.toolDurations.get(tool) || [];
const avgDuration =
durations.length > 0
? durations.reduce((a, b) => a + b, 0) / durations.length
: 0;
metrics[tool] = {
invocations: count,
errors: this.toolErrors.get(tool) || 0,
averageDurationMs: Math.round(avgDuration),
successRate: `${(((count - (this.toolErrors.get(tool) || 0)) / count) * 100).toFixed(1)}%`,
};
}
return metrics;
}
}
export const metrics = new ServerMetrics();Understanding Tool Schema Design at Scale
Before you move to deployment, understand that schemas are the interface between Claude and your server. A poorly designed schema makes it hard for Claude to use your tools effectively, even if the implementation is solid. Your schema is Claude's documentation. Claude reads the schema and decides if a tool is appropriate for the task at hand.
When designing schemas, think about what Claude needs to understand. A field name like "path" is fine, but "path_to_database_backup_file_relative_to_project_root" is more informative. Include detailed descriptions for every field. Those descriptions are what Claude reads when deciding if a tool is appropriate.
The structure of your schemas matters too. Deeply nested objects confuse Claude. If a tool accepts a configuration object with ten levels of nesting, Claude struggles to understand what's required. Flatten your schemas where possible. If you have truly complex configurations, consider breaking them into multiple simpler tools.
Constraints in your schemas help Claude avoid mistakes. If a numeric field has valid bounds, specify them. If a string field has enumerated values, use enums. These constraints are both documentation and guardrails that keep Claude from making mistakes.
Real-World Deployment Patterns
When moving to production, consider how your server will be discovered and how multiple Claude instances will reach it. The MCP protocol supports stdio transport (good for local development) and socket transport (better for distributed systems). If multiple Claude instances need access, expose your server over a network socket:
// src/socket-server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SocketServerTransport } from "@modelcontextprotocol/sdk/server/socket.js";
import net from "net";
const server = new Server({
name: "my-server",
version: "1.0.0",
});
// For socket transport, create a TCP server
const tcpServer = net.createServer((socket) => {
const transport = new SocketServerTransport(socket);
server.connect(transport).catch(console.error);
});
tcpServer.listen(3000, "127.0.0.1", () => {
console.log("MCP server listening on port 3000");
});For environment-specific configuration, use separate .env files:
# .env.development
NODE_ENV=development
DEBUG=true
DATABASE_URL=postgres://localhost/dev_db
# .env.production
NODE_ENV=production
DEBUG=false
DATABASE_URL=postgres://prod-db.example.com/prodScaling Your Server
As usage grows, your server might need to handle significantly more traffic. Consider these patterns:
Horizontal Scaling: Run multiple instances behind a load balancer. This works great if your server is stateless. Use a shared cache (Redis) if you need to maintain state across instances. When you spin up a second instance, traffic automatically balances between them.
Rate Limiting: Implement token-bucket rate limiting to prevent overwhelming external APIs:
// src/rate-limiter.ts
export class RateLimiter {
private buckets: Map<string, { tokens: number; lastRefill: number }> =
new Map();
constructor(private tokensPerSecond: number) {}
isAllowed(key: string): boolean {
const now = Date.now();
const bucket = this.buckets.get(key) || {
tokens: this.tokensPerSecond,
lastRefill: now,
};
const secondsElapsed = (now - bucket.lastRefill) / 1000;
bucket.tokens = Math.min(
this.tokensPerSecond,
bucket.tokens + secondsElapsed * this.tokensPerSecond,
);
bucket.lastRefill = now;
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
this.buckets.set(key, bucket);
return true;
}
return false;
}
}Tool-Specific Timeouts: Prevent long-running operations from blocking other requests:
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const timeout = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Tool execution timeout")), 30000),
);
const toolExecution = executeToolWithName(request.name, request.arguments);
try {
return await Promise.race([toolExecution, timeout]);
} catch (error) {
return {
content: [{ type: "text", text: `Timeout or error: ${error}` }],
isError: true,
};
}
});Version Management
As you iterate, version your server and manage backward compatibility. This is crucial when other systems depend on your server.
// src/server.ts
const server = new Server({
name: "my-server",
version: "2.1.0", // Semantic versioning
});When you make breaking changes (removing a tool, changing required fields), increment the major version. When you add optional features, increment minor. Patches are for bug fixes.
For schema changes, maintain backward compatibility when possible. Adding optional fields is safe. Removing optional fields should be deprecated first—keep both versions for a transition period.
Why TypeScript Matters for Production Systems
TypeScript's type system is invaluable for MCP servers precisely because tool errors have consequences. When your tool returns wrong data, Claude makes decisions based on that wrong data. When your tool throws an error, Claude might get stuck or make poor choices. TypeScript catches entire classes of errors at compile time: type mismatches, missing fields, incorrect method calls. By the time your code runs, you've already eliminated a huge category of potential bugs.
The best practices for TypeScript MCP servers involve thinking about failure modes. What happens when a database connection fails? When an external API is slow? When input is malformed? A production server handles all these cases gracefully, returning clear errors that Claude can learn from.
Consider a tool that queries a customer database. Your schema specifies that it returns a list of customers, each with an id, name, and email. You implement the handler correctly, test it locally, and deploy. In production, you discover that some customers have null emails (because their profile is incomplete). Your code crashes trying to serialize null to the response format. Claude sees an error, but the error is cryptic. A better implementation validates the data before responding: "Some customers have incomplete email data. Returning only those with complete profiles."
TypeScript's type system helps you think through these cases before they happen. You define what "valid response" means. You implement serialization carefully. You handle edge cases. The result is more robust code.
Learning from Real MCP Server Deployments
Teams that have deployed MCP servers learn some hard lessons. One common mistake: servers that are too slow. A tool that takes 30 seconds to execute might work in development but fail in production. Claude might time out waiting for the response. The lesson: always be aware of your tool's latency. If a tool might be slow, document it. If you can make it faster (caching, indexing, optimization), do it. If you can't, make the constraint explicit in the tool's description so Claude knows to use it sparingly.
Another common mistake: servers that are too greedy. A tool that tries to do too much (fetch data, transform it, enrich it, format it, send notifications) becomes fragile. Better to build small tools that do one thing well. Claude can compose small tools together. Small tools are easier to test, easier to debug, easier to update.
A third lesson: schema clarity matters more than you think. Teams describe their tools vaguely ("search for customers") when they should be specific ("search for customers by name, email, or ID. Returns list of matching customers with id, name, email, and last purchase date."). Claude uses these descriptions to decide if a tool is relevant. Vague descriptions mean Claude doesn't understand your tool well. Specific descriptions mean Claude uses it effectively.
Extending Your MCP Server Over Time
As your MCP server grows, keep these principles in mind:
Add tools incrementally: Don't build the entire API at once. Build one tool. Get it working perfectly. Deploy it. Observe how Claude uses it. Then add the next tool. This iterative approach lets you refine your schema design based on actual usage rather than speculation.
Maintain backward compatibility: When you add new tools, existing integrations continue working. When you change an existing tool's schema, make it backward compatible: add fields to the end, don't remove or rename fields. If you must break compatibility, increment the major version and give users time to migrate.
Monitor real usage: Instrument your tools to understand how they're actually being used. Which tools does Claude use most? Which ones have the highest error rates? Which ones are slowest? This data guides your next iteration.
Iterate on schemas: Your first schema design won't be perfect. As you use the tool, you'll discover improvements. Add better descriptions. Restructure complex nested objects into simpler flat ones. Add optional parameters that users discover they need. The schema evolves based on real experience.
Advanced Pattern: Resource-Based Tools
Beyond simple tools, MCP supports resources—static content Claude can reference. This is useful for documentation, configurations, or reference data that informs tool usage. Resources load once and stay cached, making them perfect for providing context:
// src/resources.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server({
name: "documentation-server",
version: "1.0.0",
});
// Define resources
const resources = [
{
uri: "doc://api/authentication",
name: "API Authentication Guide",
description: "How to authenticate with our APIs",
},
{
uri: "doc://api/endpoints",
name: "Available Endpoints",
description: "Complete list of API endpoints",
},
{
uri: "doc://database/schema",
name: "Database Schema",
description: "Table definitions and relationships",
},
];
// Handle resource listing
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: resources.map((r) => ({
uri: r.uri,
name: r.name,
description: r.description,
mimeType: "text/markdown",
})),
}));
// Handle resource reading
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request;
let content = "";
if (uri === "doc://api/authentication") {
content = `# API Authentication
## Bearer Token
All requests require a Bearer token in the Authorization header:
\`\`\`
Authorization: Bearer YOUR_API_KEY
\`\`\`
## Token Generation
Generate tokens in the admin dashboard. Tokens expire after 90 days.
## Rate Limiting
- 1000 requests per minute per token
- 10MB request body limit
`;
} else if (uri === "doc://api/endpoints") {
content = `# Available Endpoints
## GET /api/users/:id
Retrieve a user by ID.
Response:
\`\`\`json
{
"id": "user_123",
"name": "John Doe",
"email": "john@example.com",
"created_at": "2024-01-15T10:30:00Z"
}
\`\`\`
## POST /api/users
Create a new user.
Request body:
\`\`\`json
{
"name": "string",
"email": "string"
}
\`\`\``;
} else if (uri === "doc://database/schema") {
content = `# Database Schema
## users table
- id: UUID (primary key)
- name: VARCHAR(255) NOT NULL
- email: VARCHAR(255) UNIQUE
- created_at: TIMESTAMP DEFAULT NOW()
## orders table
- id: UUID (primary key)
- user_id: UUID (foreign key)
- amount: DECIMAL(10,2)
- status: ENUM('pending', 'completed', 'failed')
`;
} else {
return {
contents: [{ uri, mimeType: "text/plain", text: "Resource not found" }],
};
}
return {
contents: [
{
uri,
mimeType: "text/markdown",
text: content,
},
],
};
});
server.connect(process.stdin, process.stdout);Resources let Claude understand your domain deeply. When Claude has your actual API documentation, database schema, and authentication requirements built-in, it makes better decisions and generates more correct code.
Handling Streaming Responses
For long-running operations or tools that produce large outputs, streaming responses prevent timeouts and improve perceived responsiveness. Instead of waiting for the entire result, Claude receives incremental updates:
// src/streaming-tools.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
CallToolRequestSchema,
TextContent,
ToolResponse,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server({
name: "streaming-server",
version: "1.0.0",
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name } = request;
if (name === "stream_large_dataset") {
// For tools with large outputs, build response incrementally
const chunks: TextContent[] = [];
for (let i = 0; i < 10; i++) {
// Simulate processing batches
await new Promise((resolve) => setTimeout(resolve, 100));
chunks.push({
type: "text",
text: `Processed batch ${i + 1}/10\n`,
});
}
chunks.push({
type: "text",
text: `Complete. Total records: ${10 * 1000}`,
});
return {
content: chunks,
};
}
return {
content: [{ type: "text", text: "Unknown tool" }],
isError: true,
};
});
server.connect(process.stdin, process.stdout);Streaming is essential for tools that might take more than 30 seconds. Without it, Claude times out and the tool fails silently. With it, Claude sees progress and knows the tool is working.
Advanced Error Recovery
Production systems fail. The question is how gracefully. Implement retry logic and circuit breakers for external dependencies:
// src/resilience.ts
export class CircuitBreaker {
private failures = 0;
private lastFailureTime = 0;
private readonly threshold = 5;
private readonly resetTimeout = 60000; // 1 minute
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.isOpen()) {
throw new Error("Circuit breaker is open. Service unavailable.");
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private isOpen(): boolean {
if (this.failures < this.threshold) {
return false;
}
const timeSinceLastFailure = Date.now() - this.lastFailureTime;
return timeSinceLastFailure < this.resetTimeout;
}
private onSuccess() {
this.failures = 0;
}
private onFailure() {
this.failures++;
this.lastFailureTime = Date.now();
}
}
// Usage
const externalServiceBreaker = new CircuitBreaker();
export async function callExternalService(
params: Record<string, any>,
): Promise<any> {
return externalServiceBreaker.execute(async () => {
// Make the actual call
const response = await fetch("https://external-api.example.com/data", {
method: "POST",
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
return response.json();
});
}Circuit breakers prevent cascading failures. When an external service fails repeatedly, the circuit breaker "opens" and rejects calls immediately rather than wasting time on doomed requests. This keeps your MCP server responsive even when its dependencies fail.
Observability and Production Monitoring
Production MCP servers need visibility into what's happening. Implement structured logging and metrics collection:
// src/observability.ts
import pino from "pino";
const logger = pino({
level: process.env.LOG_LEVEL || "info",
transport: {
target: "pino-pretty",
options: {
colorize: true,
singleLine: false,
translateTime: "SYS:standard",
},
},
});
export interface ToolMetrics {
name: string;
invocations: number;
errors: number;
totalDurationMs: number;
maxDurationMs: number;
minDurationMs: number;
}
export class MetricsCollector {
private metrics: Map<string, ToolMetrics> = new Map();
recordToolCall(toolName: string, durationMs: number, success: boolean) {
if (!this.metrics.has(toolName)) {
this.metrics.set(toolName, {
name: toolName,
invocations: 0,
errors: 0,
totalDurationMs: 0,
maxDurationMs: 0,
minDurationMs: Infinity,
});
}
const metric = this.metrics.get(toolName)!;
metric.invocations++;
metric.totalDurationMs += durationMs;
metric.maxDurationMs = Math.max(metric.maxDurationMs, durationMs);
metric.minDurationMs = Math.min(metric.minDurationMs, durationMs);
if (!success) {
metric.errors++;
}
// Log with structured fields
logger.info(
{
tool: toolName,
duration: durationMs,
success,
invocations: metric.invocations,
errorRate: `${((metric.errors / metric.invocations) * 100).toFixed(1)}%`,
},
"Tool executed",
);
}
getMetrics(): ToolMetrics[] {
return Array.from(this.metrics.values());
}
getMetricsSummary() {
const metrics = this.getMetrics();
return metrics.map((m) => ({
tool: m.name,
invocations: m.invocations,
errors: m.errors,
errorRate: `${((m.errors / m.invocations) * 100).toFixed(1)}%`,
avgDurationMs: Math.round(m.totalDurationMs / m.invocations),
maxDurationMs: m.maxDurationMs,
}));
}
}
export const metricsCollector = new MetricsCollector();
// Instrument your tool handlers
export async function instrumentedToolCall<T>(
toolName: string,
fn: () => Promise<T>,
): Promise<T> {
const start = Date.now();
try {
const result = await fn();
const duration = Date.now() - start;
metricsCollector.recordToolCall(toolName, duration, true);
return result;
} catch (error) {
const duration = Date.now() - start;
metricsCollector.recordToolCall(toolName, duration, false);
logger.error(
{
tool: toolName,
error: error instanceof Error ? error.message : String(error),
},
"Tool failed",
);
throw error;
}
}With structured logging and metrics, you can answer questions about production behavior: Which tools are slow? Which ones fail frequently? When do errors spike? This data drives optimization.
Deploying TypeScript MCP Servers
When your server is ready for production, build and deploy it properly:
# Build for production
npm run build
# Create a minimal production image
docker build -f Dockerfile.prod -t my-mcp-server:latest .
docker push my-mcp-server:latest
# Deploy (example with Kubernetes)
kubectl apply -f deployment.yamlCreate a production Dockerfile that's minimal and secure:
# Dockerfile.prod
FROM node:20-slim
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install only production dependencies
RUN npm ci --only=production
# Copy built code
COPY dist ./dist
# Create non-root user for security
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD node -e "require('net').connect({port: process.env.PORT || 3000}, () => process.exit(0))"
CMD ["node", "dist/server.js"]Iterating on Your Server
Your first server design won't be perfect. Here's how to improve iteratively:
Week 1: Build one tool. Test it. Deploy it. Observe how Claude uses it.
Week 2: Add two more tools. Refine the first based on observations.
Week 3: Add error handling, logging, and monitoring to all tools.
Week 4: Optimize slow tools. Add caching if needed.
Month 2: Expand to more tools. Add resources. Refine schemas based on usage.
Month 3: Add rate limiting and advanced resilience patterns. Update documentation.
Each iteration is small and observable. You don't guess about what works; you measure and learn.
Wrapping Up
Building MCP servers with TypeScript gives you a powerful, type-safe way to extend Claude's capabilities. By leveraging Zod for validation, structuring your code clearly, and following best practices, you'll create servers that are robust, maintainable, and ready for production.
The pattern is consistent: define schemas, implement handlers, register with the server, test thoroughly, and deploy with confidence. Start simple—a calculator or file reader—then expand to more complex tools. Add error handling. Instrument for monitoring. Deploy to production. Learn from real usage. Iterate.
The servers that last are the ones that become foundational infrastructure. They're built once and used many times. They're updated frequently based on real usage. They accumulate edge case handling and optimization. They become valuable assets that power better Claude integrations for years.
When you deploy your first production MCP server, you'll realize how powerful this approach is. Claude gets immediate access to your domain knowledge. You maintain a clean, versioned API. The separation of concerns creates systems that are easier to understand, debug, and improve. Most importantly, you've created infrastructure that multiplies your team's capabilities—Claude can now reason about your domain, make recommendations, and help your team make better decisions.
Your teammates will thank you for building infrastructure that makes their work easier. That's the measure of a good MCP server—not how clever it is, but how much it enables others to do meaningful work and how well it serves them over time.
Build with intention. Ship with confidence. Iterate based on reality. The best MCP servers are the ones that solve genuine problems for real people and make their lives measurably better.
-iNet
Building infrastructure that scales with your ambitions.