Back to Tutorials
AdvancedAI/ML

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.

by Michael Eakins
43 min read
1/12/2026

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

TypeScriptNode.jsZodJestClaude DesktopMCP Protocol

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.)
  1. User asks Claude a question requiring external data
  2. Claude recognizes it needs a tool from your MCP server
  3. Claude sends a tool invocation request via MCP protocol
  4. Your server executes the tool and returns results
  5. 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 implementations
  • zod: Runtime schema validation (essential for input validation)
  • typescript and @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 support
  • sourceMap: Enables debugging with proper line numbers
  • declaration: 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:

  1. Class-based architecture: Encapsulates state and provides clear lifecycle management
  2. Dependency injection ready: Logger can be mocked for testing
  3. Graceful shutdown: SIGINT handler ensures clean process termination
  4. Error boundaries: Try-catch in tool execution prevents server crashes
  5. 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:

  1. Zod validation: Runtime type checking prevents invalid inputs from reaching execution logic
  2. Typed definitions: Separate schema definition from execution implementation
  3. Error mapping: Translate system errors into domain-specific errors
  4. Structured responses: MCP-compliant response format with content array
  5. 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:

  1. Extend base Error: Maintains stack traces and instanceof checks
  2. Error codes: Machine-readable codes enable client-side handling
  3. Structured details: Include contextual information for debugging
  4. Serializable: toJSON() method for logging and transmission
  5. 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:

  1. Build the server: npm run build
  2. Add to Claude Desktop configuration
  3. Restart Claude Desktop
  4. Ask Claude questions that should trigger your tools
  5. 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.

Last updated: 1/12/2026