Building Production MCP Servers - Complete Implementation Guide with TypeScript and Claude
Build production-ready MCP servers with TypeScript - complete guide covering architecture, testing, deployment, and enterprise patterns for AI tool integration with Claude and other LLM platforms.
Prerequisites
- Node.js 18 or later installed
- TypeScript experience (intermediate level)
- Basic understanding of Claude and LLMs
- Familiarity with async/await patterns
What You'll Learn
- Understand MCP architecture and protocol design
- Build production-ready MCP servers from scratch
- Implement comprehensive testing strategies for MCP tools
- Deploy MCP servers with enterprise-grade error handling
- Integrate MCP servers with Claude Desktop and API
Technologies Covered
The Model Context Protocol (MCP) represents a standardization moment for AI tool integration—think USB-C for language models. Instead of custom API implementations for every AI platform, MCP provides a universal protocol that lets you build tools once and connect them to any compatible LLM client.
This tutorial builds a production-grade MCP server from scratch using TypeScript, covering architecture decisions, error handling, testing strategies, and deployment patterns that separate hobby projects from enterprise-ready systems.
What You'll Build
By the end of this tutorial, you'll have a fully functional MCP server that:
- Exposes multiple tools with rich parameter validation
- Implements comprehensive error handling and logging
- Includes automated testing and CI/CD pipelines
- Deploys as a standalone service or embedded component
- Follows TypeScript best practices and production patterns
The example server provides file system operations (read, write, search), demonstrating patterns applicable to any domain: database queries, API integrations, system utilities, or custom business logic.
Prerequisites
Required Knowledge:
- TypeScript fundamentals (async/await, types, generics)
- Basic understanding of JSON-RPC protocols
- Familiarity with Node.js development
Development Environment:
- Node.js 18+ and npm/yarn
- TypeScript 5.0+
- Claude Desktop or compatible MCP client for testing
- Git for version control
Recommended Skills:
- Experience with REST APIs or RPC protocols
- Understanding of language model tool calling
- Basic knowledge of software testing patterns
Understanding MCP Architecture
Before writing code, understand how MCP servers fit into the AI ecosystem.
Protocol Overview
MCP uses JSON-RPC 2.0 over stdio (standard input/output) or Server-Sent Events (SSE). Servers expose three core capabilities:
Tools: Functions the LLM can invoke
Resources: Data the LLM can read (files, database entries, API responses)
Prompts: Pre-defined prompt templates the LLM can use
For this tutorial, we focus on tools—the most commonly used capability.
Communication Flow
Claude Desktop ←→ MCP Client (stdio) ←→ MCP Server (TypeScript)
↑ ↓
User Query Tool Execution
(File operations, APIs, etc.)
- User asks Claude a question requiring external data
- Claude recognizes it needs a tool from your MCP server
- Claude sends a tool invocation request via MCP protocol
- Your server executes the tool and returns results
- Claude incorporates results into its response
Why TypeScript?
TypeScript provides the type safety critical for production systems where malformed tool calls can cause silent failures or security vulnerabilities. Strong typing catches errors at compile time rather than runtime, and autocomplete makes development faster.
Project Setup
Create the project structure with proper tooling and dependencies.
Initialize Project
mkdir mcp-file-server
cd mcp-file-server
npm init -y
npm install typescript @types/node --save-dev
npm install @modelcontextprotocol/sdk zod
Dependencies Explained:
@modelcontextprotocol/sdk: Official MCP SDK with protocol implementationszod: Runtime schema validation (essential for input validation)typescriptand@types/node: TypeScript compiler and Node.js type definitions
TypeScript Configuration
Create tsconfig.json with strict settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
Key Settings:
strict: Enables all TypeScript strict checking (essential for production)module: "Node16": Modern ES modules supportsourceMap: Enables debugging with proper line numbersdeclaration: Generates .d.ts files for library consumers
Project Structure
mcp-file-server/
├── src/
│ ├── server.ts # Main server implementation
│ ├── tools/ # Tool definitions
│ │ ├── readFile.ts
│ │ ├── writeFile.ts
│ │ └── searchFiles.ts
│ ├── types.ts # Shared TypeScript types
│ ├── validators.ts # Zod validation schemas
│ └── utils/
│ ├── logger.ts # Logging utilities
│ └── errors.ts # Custom error types
├── tests/
│ ├── integration/ # End-to-end tests
│ └── unit/ # Unit tests
├── package.json
├── tsconfig.json
└── README.md
Implementing the MCP Server
Build the server incrementally, starting with the core protocol handling.
Core Server Class
Create src/server.ts:
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js'
import { readFileTool, writeFileTool, searchFilesTool } from './tools/index.js'
import { Logger } from './utils/logger.js'
export class FileServer {
private server: Server
private logger: Logger
constructor() {
this.logger = new Logger('FileServer')
this.server = new Server(
{
name: 'file-operations-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
)
this.setupToolHandlers()
this.setupErrorHandling()
}
private setupToolHandlers(): void {
// Register tools discovery
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
readFileTool.definition,
writeFileTool.definition,
searchFilesTool.definition,
],
}))
// Handle tool invocations
this.server.setRequestHandler(CallToolRequestSchema, async request => {
const { name, arguments: args } = request.params
this.logger.info(`Tool invoked: ${name}`, { args })
try {
switch (name) {
case 'read_file':
return await readFileTool.execute(args)
case 'write_file':
return await writeFileTool.execute(args)
case 'search_files':
return await searchFilesTool.execute(args)
default:
throw new Error(`Unknown tool: ${name}`)
}
} catch (error) {
this.logger.error(`Tool execution failed: ${name}`, { error })
throw error
}
})
}
private setupErrorHandling(): void {
this.server.onerror = error => {
this.logger.error('MCP Server Error', { error })
}
process.on('SIGINT', async () => {
this.logger.info('Shutting down server...')
await this.server.close()
process.exit(0)
})
}
async start(): Promise<void> {
const transport = new StdioServerTransport()
await this.server.connect(transport)
this.logger.info('File Operations MCP Server running on stdio')
}
}
// Entry point
const server = new FileServer()
server.start().catch(error => {
console.error('Fatal error:', error)
process.exit(1)
})
Design Decisions:
- Class-based architecture: Encapsulates state and provides clear lifecycle management
- Dependency injection ready: Logger can be mocked for testing
- Graceful shutdown: SIGINT handler ensures clean process termination
- Error boundaries: Try-catch in tool execution prevents server crashes
- Structured logging: Contextual information aids debugging
Tool Definition Pattern
Each tool follows a consistent pattern. Create src/tools/readFile.ts:
import { z } from 'zod'
import { promises as fs } from 'fs'
import { Tool } from '@modelcontextprotocol/sdk/types.js'
import { FileNotFoundError, PermissionDeniedError } from '../utils/errors.js'
// Validation schema
const ReadFileArgsSchema = z.object({
path: z.string().min(1, 'Path cannot be empty'),
encoding: z.enum(['utf-8', 'base64']).default('utf-8'),
})
type ReadFileArgs = z.infer<typeof ReadFileArgsSchema>
export const readFileTool = {
definition: {
name: 'read_file',
description: 'Read the contents of a file from the filesystem',
inputSchema: {
type: 'object',
properties: {
path: {
type: 'string',
description: 'Absolute or relative path to the file',
},
encoding: {
type: 'string',
enum: ['utf-8', 'base64'],
description: 'File encoding (default: utf-8)',
default: 'utf-8',
},
},
required: ['path'],
},
} as Tool,
async execute(args: unknown) {
// Validate inputs
const { path, encoding } = ReadFileArgsSchema.parse(args)
try {
const content = await fs.readFile(path, encoding)
return {
content: [
{
type: 'text',
text: content.toString(),
},
],
}
} catch (error: any) {
if (error.code === 'ENOENT') {
throw new FileNotFoundError(path)
}
if (error.code === 'EACCES') {
throw new PermissionDeniedError(path)
}
throw error
}
},
}
Pattern Highlights:
- Zod validation: Runtime type checking prevents invalid inputs from reaching execution logic
- Typed definitions: Separate schema definition from execution implementation
- Error mapping: Translate system errors into domain-specific errors
- Structured responses: MCP-compliant response format with content array
- Type inference:
z.infer<typeof Schema>keeps types in sync with validation
Input Validation with Zod
Zod provides runtime validation that TypeScript can't. Create
src/validators.ts:
import { z } from 'zod'
import { resolve } from 'path'
// Custom validators
const absolutePath = z
.string()
.refine(path => resolve(path) === path, 'Path must be absolute')
const safeFilename = z
.string()
.regex(/^[a-zA-Z0-9._-]+$/, 'Filename contains invalid characters')
// Reusable schemas
export const PathSchema = z.object({
path: absolutePath,
})
export const FileOperationSchema = PathSchema.extend({
createIfMissing: z.boolean().default(false),
})
export const SearchSchema = z.object({
directory: absolutePath,
pattern: z.string().min(1),
recursive: z.boolean().default(true),
maxResults: z.number().int().positive().max(1000).default(100),
})
Validation Best Practices:
- Security-first: Validate paths to prevent directory traversal attacks
- Sensible defaults: Provide defaults for optional parameters
- Fail fast: Invalid inputs throw immediately, before execution
- Compose schemas: Build complex schemas from simple primitives
- Document constraints: Error messages explain why validation failed
Error Handling Architecture
Production systems need structured error handling. Create src/utils/errors.ts:
export class MCPError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly details?: Record<string, unknown>
) {
super(message)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
toJSON() {
return {
name: this.name,
message: this.message,
code: this.code,
details: this.details,
}
}
}
export class FileNotFoundError extends MCPError {
constructor(path: string) {
super(`File not found: ${path}`, 'FILE_NOT_FOUND', { path })
}
}
export class PermissionDeniedError extends MCPError {
constructor(path: string) {
super(`Permission denied accessing: ${path}`, 'PERMISSION_DENIED', { path })
}
}
export class ValidationError extends MCPError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'VALIDATION_ERROR', details)
}
}
Error Handling Principles:
- Extend base Error: Maintains stack traces and instanceof checks
- Error codes: Machine-readable codes enable client-side handling
- Structured details: Include contextual information for debugging
- Serializable: toJSON() method for logging and transmission
- Semantic types: Specific error classes for different failure modes
Logging Infrastructure
Structured logging is essential for production debugging. Create
src/utils/logger.ts:
export enum LogLevel {
DEBUG = 0,
INFO = 1,
WARN = 2,
ERROR = 3,
}
interface LogContext {
[key: string]: unknown
}
export class Logger {
constructor(
private component: string,
private level: LogLevel = LogLevel.INFO
) {}
private log(level: LogLevel, message: string, context?: LogContext): void {
if (level < this.level) return
const logEntry = {
timestamp: new Date().toISOString(),
level: LogLevel[level],
component: this.component,
message,
...context,
}
const output = JSON.stringify(logEntry)
if (level >= LogLevel.ERROR) {
console.error(output)
} else {
console.log(output)
}
}
debug(message: string, context?: LogContext): void {
this.log(LogLevel.DEBUG, message, context)
}
info(message: string, context?: LogContext): void {
this.log(LogLevel.INFO, message, context)
}
warn(message: string, context?: LogContext): void {
this.log(LogLevel.WARN, message, context)
}
error(message: string, context?: LogContext): void {
this.log(LogLevel.ERROR, message, context)
}
}
Logging Design:
- Structured JSON: Logs are machine-parseable for analysis tools
- Component context: Identify which part of the system logged the message
- Level filtering: Production can filter out debug logs
- Timestamp: ISO 8601 format for time-series analysis
- Extensible context: Additional metadata without changing signatures
Testing Strategy
Production code requires comprehensive testing. Start with unit tests.
Unit Testing Tools
Install test dependencies:
npm install --save-dev jest @types/jest ts-jest
Configure Jest in jest.config.js:
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/server.ts', // Entry point tested via integration
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}
Testing Tool Execution
Create tests/unit/tools/readFile.test.ts:
import { readFileTool } from '../../../src/tools/readFile'
import { promises as fs } from 'fs'
import { FileNotFoundError } from '../../../src/utils/errors'
// Mock fs module
jest.mock('fs', () => ({
promises: {
readFile: jest.fn(),
},
}))
describe('readFileTool', () => {
const mockReadFile = fs.readFile as jest.MockedFunction<typeof fs.readFile>
beforeEach(() => {
jest.clearAllMocks()
})
describe('execute', () => {
it('should read file successfully with utf-8 encoding', async () => {
const mockContent = 'Hello, World!'
mockReadFile.mockResolvedValue(Buffer.from(mockContent))
const result = await readFileTool.execute({
path: '/test/file.txt',
encoding: 'utf-8',
})
expect(mockReadFile).toHaveBeenCalledWith('/test/file.txt', 'utf-8')
expect(result.content[0].text).toBe(mockContent)
})
it('should throw FileNotFoundError for missing files', async () => {
const error: any = new Error('File not found')
error.code = 'ENOENT'
mockReadFile.mockRejectedValue(error)
await expect(
readFileTool.execute({ path: '/missing/file.txt' })
).rejects.toThrow(FileNotFoundError)
})
it('should reject invalid arguments', async () => {
await expect(readFileTool.execute({ path: '' })).rejects.toThrow(
'Path cannot be empty'
)
})
it('should default to utf-8 encoding', async () => {
mockReadFile.mockResolvedValue(Buffer.from('test'))
await readFileTool.execute({ path: '/test.txt' })
expect(mockReadFile).toHaveBeenCalledWith('/test.txt', 'utf-8')
})
})
})
Testing Best Practices:
- Mock external dependencies: File system operations shouldn't hit disk in unit tests
- Test error paths: Verify error handling and error type correctness
- Test validation: Ensure invalid inputs are rejected with clear messages
- Test defaults: Verify optional parameters use correct defaults
- Coverage targets: 80% coverage ensures critical paths are tested
Integration Testing
Integration tests verify the MCP protocol implementation. Create
tests/integration/server.test.ts:
import { FileServer } from '../../src/server'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
describe('MCP Server Integration', () => {
let client: Client
let transport: StdioClientTransport
beforeAll(async () => {
// Spawn server as child process
transport = new StdioClientTransport({
command: 'node',
args: ['dist/server.js'],
})
client = new Client(
{
name: 'test-client',
version: '1.0.0',
},
{
capabilities: {},
}
)
await client.connect(transport)
})
afterAll(async () => {
await client.close()
})
it('should list available tools', async () => {
const response = await client.request(
{ method: 'tools/list' },
{ method: 'tools/list' }
)
expect(response.tools).toHaveLength(3)
expect(response.tools.map((t: any) => t.name)).toEqual(
expect.arrayContaining(['read_file', 'write_file', 'search_files'])
)
})
it('should execute read_file tool', async () => {
const result = await client.request(
{
method: 'tools/call',
params: {
name: 'read_file',
arguments: {
path: './package.json',
},
},
},
{ method: 'tools/call' }
)
expect(result.content[0].text).toContain('"name"')
})
it('should handle tool errors gracefully', async () => {
await expect(
client.request({
method: 'tools/call',
params: {
name: 'read_file',
arguments: {
path: '/nonexistent/file.txt',
},
},
})
).rejects.toThrow()
})
})
Integration Test Value:
- End-to-end verification: Tests the full MCP protocol implementation
- Real process communication: Spawns actual server process
- Protocol compliance: Ensures responses follow MCP specification
- Error propagation: Verifies errors are transmitted correctly
- Backward compatibility: Integration tests catch breaking changes
Configuration Management
Production systems need configurable behavior. Create src/config.ts:
import { z } from 'zod'
const ConfigSchema = z.object({
server: z.object({
name: z.string().default('file-operations-server'),
version: z.string().default('1.0.0'),
}),
filesystem: z.object({
allowedPaths: z.array(z.string()).default([]),
maxFileSize: z
.number()
.int()
.positive()
.default(10 * 1024 * 1024), // 10MB
blockedExtensions: z.array(z.string()).default(['.exe', '.dll']),
}),
logging: z.object({
level: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).default('INFO'),
pretty: z.boolean().default(false),
}),
})
export type Config = z.infer<typeof ConfigSchema>
export function loadConfig(): Config {
const envConfig = {
server: {
name: process.env.MCP_SERVER_NAME,
version: process.env.MCP_SERVER_VERSION,
},
filesystem: {
allowedPaths: process.env.MCP_ALLOWED_PATHS?.split(','),
maxFileSize: process.env.MCP_MAX_FILE_SIZE
? parseInt(process.env.MCP_MAX_FILE_SIZE)
: undefined,
blockedExtensions: process.env.MCP_BLOCKED_EXTENSIONS?.split(','),
},
logging: {
level: process.env.MCP_LOG_LEVEL,
pretty: process.env.MCP_LOG_PRETTY === 'true',
},
}
return ConfigSchema.parse(envConfig)
}
Configuration Patterns:
- Environment variables: Standard deployment approach for 12-factor apps
- Validated config: Zod ensures configuration is valid at startup
- Typed configuration: Config type provides autocomplete and type safety
- Fail fast: Invalid configuration crashes at startup, not runtime
- Sensible defaults: Development works without configuration
Deployment Strategies
MCP servers deploy in multiple contexts. Consider three deployment patterns.
Standalone Process
Package as standalone executable using pkg or Docker:
Dockerfile:
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY dist ./dist
USER node
CMD ["node", "dist/server.js"]
Docker Compose for Local Testing:
version: '3.8'
services:
mcp-server:
build: .
stdin_open: true
tty: true
environment:
- MCP_LOG_LEVEL=DEBUG
volumes:
- ./data:/data:ro
Claude Desktop Integration
Add to Claude Desktop's MCP configuration:
macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
Windows: %APPDATA%\Claude\claude_desktop_config.json
{
"mcpServers": {
"file-operations": {
"command": "node",
"args": ["/path/to/mcp-file-server/dist/server.js"],
"env": {
"MCP_LOG_LEVEL": "INFO"
}
}
}
}
Restart Claude Desktop to load the server.
NPM Package Distribution
Publish as npm package for easy installation:
package.json additions:
{
"name": "@yourorg/mcp-file-server",
"bin": {
"mcp-file-server": "./dist/server.js"
},
"files": ["dist/**/*", "README.md", "LICENSE"],
"publishConfig": {
"access": "public"
}
}
Users can then install globally:
npm install -g @yourorg/mcp-file-server
And configure in Claude Desktop:
{
"mcpServers": {
"file-operations": {
"command": "mcp-file-server"
}
}
}
CI/CD Pipeline
Automate testing and deployment. Create .github/workflows/test.yml:
name: Test and Publish
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/lcov.info
- name: Build
run: npm run build
publish:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm run build
- name: Publish to NPM
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Pipeline Features:
- Automated testing: Every push and PR runs tests
- Code coverage: Track test coverage over time with Codecov
- Continuous deployment: Main branch deploys to npm automatically
- Build verification: Ensure TypeScript compiles successfully
- Linting: Enforce code quality with ESLint
Production Monitoring
Add observability to track server health. Create src/utils/metrics.ts:
export class Metrics {
private counters: Map<string, number> = new Map()
private timers: Map<string, number[]> = new Map()
incrementCounter(name: string, value: number = 1): void {
this.counters.set(name, (this.counters.get(name) || 0) + value)
}
recordTiming(name: string, durationMs: number): void {
const timings = this.timers.get(name) || []
timings.push(durationMs)
this.timers.set(name, timings)
}
getMetrics(): Record<string, unknown> {
return {
counters: Object.fromEntries(this.counters),
timers: Object.fromEntries(
Array.from(this.timers.entries()).map(([name, values]) => [
name,
{
count: values.length,
mean: values.reduce((a, b) => a + b, 0) / values.length,
p50: this.percentile(values, 0.5),
p95: this.percentile(values, 0.95),
p99: this.percentile(values, 0.99),
},
])
),
}
}
private percentile(values: number[], p: number): number {
const sorted = values.slice().sort((a, b) => a - b)
const index = Math.floor(sorted.length * p)
return sorted[index]
}
}
// Instrument tool execution
export function instrumentTool<T extends (...args: any[]) => Promise<any>>(
toolName: string,
fn: T,
metrics: Metrics
): T {
return (async (...args: any[]) => {
const start = Date.now()
try {
const result = await fn(...args)
metrics.incrementCounter(`${toolName}.success`)
return result
} catch (error) {
metrics.incrementCounter(`${toolName}.error`)
throw error
} finally {
metrics.recordTiming(`${toolName}.duration`, Date.now() - start)
}
}) as T
}
Metrics to Track:
- Tool invocation counts: Which tools are used most frequently
- Success/failure rates: Identify problematic tools
- Execution duration: P50, P95, P99 latencies
- Error types: Categorize failures for root cause analysis
Export metrics via a health endpoint or periodic logging for monitoring systems to ingest.
Security Considerations
Production MCP servers must handle untrusted input safely.
Path Traversal Prevention
import { resolve, normalize, relative } from 'path'
export function validatePath(path: string, allowedRoots: string[]): string {
// Normalize and resolve to absolute path
const normalizedPath = resolve(normalize(path))
// Check if path is within allowed directories
const isAllowed = allowedRoots.some(root => {
const rel = relative(root, normalizedPath)
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
})
if (!isAllowed) {
throw new Error(`Access denied: ${path}`)
}
return normalizedPath
}
Rate Limiting
export class RateLimiter {
private requests: Map<string, number[]> = new Map()
constructor(
private maxRequests: number,
private windowMs: number
) {}
checkLimit(clientId: string): boolean {
const now = Date.now()
const timestamps = this.requests.get(clientId) || []
// Remove expired timestamps
const validTimestamps = timestamps.filter(ts => now - ts < this.windowMs)
if (validTimestamps.length >= this.maxRequests) {
return false // Rate limit exceeded
}
validTimestamps.push(now)
this.requests.set(clientId, validTimestamps)
return true
}
}
Input Sanitization
export function sanitizeFilename(filename: string): string {
// Remove path separators and null bytes
return filename.replace(/[/\\]/g, '').replace(/\0/g, '').trim()
}
export function sanitizeFileContent(content: string): string {
// Remove potentially dangerous control characters
return content.replace(/[\x00-\x1F\x7F-\x9F]/g, '')
}
Advanced Patterns
Take your MCP server to production-grade with advanced patterns.
Caching Layer
export class ToolCache {
private cache: Map<string, { value: any; expiresAt: number }> = new Map()
get(key: string): any | undefined {
const entry = this.cache.get(key)
if (!entry) return undefined
if (Date.now() > entry.expiresAt) {
this.cache.delete(key)
return undefined
}
return entry.value
}
set(key: string, value: any, ttlMs: number): void {
this.cache.set(key, {
value,
expiresAt: Date.now() + ttlMs,
})
}
generateKey(toolName: string, args: any): string {
return `${toolName}:${JSON.stringify(args)}`
}
}
Batch Processing
export class BatchProcessor {
private queue: Array<{
toolName: string
args: any
resolve: (value: any) => void
reject: (error: any) => void
}> = []
constructor(
private maxBatchSize: number,
private batchDelayMs: number
) {}
async enqueue(toolName: string, args: any): Promise<any> {
return new Promise((resolve, reject) => {
this.queue.push({ toolName, args, resolve, reject })
if (this.queue.length >= this.maxBatchSize) {
this.flush()
} else {
setTimeout(() => this.flush(), this.batchDelayMs)
}
})
}
private async flush(): Promise<void> {
if (this.queue.length === 0) return
const batch = this.queue.splice(0, this.maxBatchSize)
// Execute batch operations here
}
}
Retry Logic
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
if (attempt < maxRetries) {
await new Promise(resolve =>
setTimeout(resolve, delayMs * Math.pow(2, attempt))
)
}
}
}
throw lastError!
}
Debugging Techniques
Production debugging requires systematic approaches.
Logging Tool Execution
export function logToolExecution(
toolName: string,
args: any,
result: any
): void {
const logEntry = {
timestamp: new Date().toISOString(),
tool: toolName,
arguments: args,
result:
typeof result === 'object'
? JSON.stringify(result).slice(0, 200)
: result,
durationMs: Date.now() - (global as any).__toolStartTime,
}
console.log(JSON.stringify(logEntry))
}
Interactive Debugging
Use VS Code launch configuration:
.vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug MCP Server",
"runtimeArgs": ["-r", "ts-node/register"],
"args": ["${workspaceFolder}/src/server.ts"],
"env": {
"MCP_LOG_LEVEL": "DEBUG"
},
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen"
}
]
}
Set breakpoints in VS Code and step through tool execution.
Testing Against Claude
Manually test MCP server integration:
- Build the server:
npm run build - Add to Claude Desktop configuration
- Restart Claude Desktop
- Ask Claude questions that should trigger your tools
- Monitor server logs for tool invocations
Example test prompt:
Read the contents of ./package.json and tell me the project name.
Claude should invoke your read_file tool. Check logs:
{
"timestamp": "2026-01-12T10:30:15.123Z",
"level": "INFO",
"component": "FileServer",
"message": "Tool invoked: read_file",
"args": {
"path": "./package.json"
}
}
Performance Optimization
Optimize for production workloads.
Reduce Memory Footprint
// Stream large files instead of loading into memory
import { createReadStream } from 'fs'
async function streamFile(path: string): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
const stream = createReadStream(path, {
highWaterMark: 64 * 1024, // 64KB chunks
})
stream.on('data', chunk => chunks.push(chunk))
stream.on('end', () => resolve(Buffer.concat(chunks).toString()))
stream.on('error', reject)
})
}
Lazy Loading
// Lazy load tool implementations
const tools = {
read_file: () => import('./tools/readFile'),
write_file: () => import('./tools/writeFile'),
search_files: () => import('./tools/searchFiles'),
}
async function executeTool(name: string, args: any) {
const tool = await tools[name]()
return tool.execute(args)
}
Connection Pooling
For database or API-backed tools:
import { Pool } from 'pg'
const pool = new Pool({
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
})
export async function queryDatabase(sql: string, params: any[]) {
const client = await pool.connect()
try {
return await client.query(sql, params)
} finally {
client.release()
}
}
Documentation Standards
Document your MCP server for maintainability.
README Template
# File Operations MCP Server
Production-grade MCP server providing file system operations for Claude Desktop
and compatible LLM clients.
## Features
- **Read files**: Access file contents with configurable encoding
- **Write files**: Create or update files with atomic writes
- **Search files**: Glob-based file search with configurable depth
## Installation
\`\`\`bash npm install -g @yourorg/mcp-file-server \`\`\`
## Configuration
Configure via environment variables:
- `MCP_ALLOWED_PATHS`: Comma-separated list of allowed directories
- `MCP_MAX_FILE_SIZE`: Maximum file size in bytes (default: 10MB)
- `MCP_LOG_LEVEL`: Logging level (DEBUG, INFO, WARN, ERROR)
## Usage
### Claude Desktop
Add to `claude_desktop_config.json`:
\`\`\`json { "mcpServers": { "file-operations": { "command": "mcp-file-server",
"env": { "MCP_ALLOWED_PATHS": "/Users/yourname/Documents" } } } } \`\`\`
### Programmatic Usage
\`\`\`typescript import { FileServer } from '@yourorg/mcp-file-server';
const server = new FileServer(); await server.start(); \`\`\`
## Security
- Path validation prevents directory traversal
- Configurable allowed paths restrict access
- File size limits prevent resource exhaustion
- Rate limiting prevents abuse
## Development
\`\`\`bash npm install npm test npm run build \`\`\`
## License
MIT
API Documentation
Use TypeDoc for auto-generated documentation:
npm install --save-dev typedoc
typedoc.json:
{
"entryPoints": ["src/server.ts"],
"out": "docs",
"excludePrivate": true,
"excludeProtected": true
}
Generate with npx typedoc and publish to GitHub Pages.
GitHub Repository
Complete implementation available at:
github.com/CrashBytes/ByteSizedExamples/tree/main/mcp-file-server-typescript
The repository includes:
- Full TypeScript implementation with all patterns from this tutorial
- Comprehensive test suite (unit + integration tests)
- CI/CD pipeline with GitHub Actions
- Docker deployment configurations
- Example Claude Desktop integration
- Detailed API documentation
- Performance benchmarks
Clone and experiment:
git clone https://github.com/CrashBytes/ByteSizedExamples.git
cd ByteSizedExamples/mcp-file-server-typescript
npm install
npm test
npm run build
Follow the README for configuration and deployment instructions.
Key Takeaways
Building production MCP servers requires:
Type Safety: TypeScript catches errors at compile time. Use strict mode and validate inputs with Zod.
Error Handling: Structured error types with codes and context. Map system errors to domain errors.
Testing: Unit tests for logic, integration tests for protocol compliance. Aim for 80% coverage minimum.
Configuration: Environment-driven config with validation. Fail fast on invalid configuration.
Observability: Structured logging, metrics collection, health checks. Instrument tool execution.
Security: Path validation, rate limiting, input sanitization. Defense in depth.
Documentation: README, API docs, inline comments. Make maintainability a priority.
CI/CD: Automated testing, linting, deployment. Every push goes through the pipeline.
MCP represents a paradigm shift in AI tool integration. Building servers that follow these patterns positions you to contribute to this ecosystem at scale. The repository provides a foundation—extend it with your domain-specific tools, APIs, or business logic.
Related Content
This tutorial explores implementation patterns for Model Context Protocol servers. For the broader context of AI tool integration and the strategic significance of MCP adoption, see my analysis on enterprise AI infrastructure standardization and my prediction about MCP reaching 40% enterprise adoption by Q3 2026.
The future of AI integration is protocol-first. MCP enables that future.