DEV Community

Santhosh M
Santhosh M

Posted on

Building Production-Ready MCP Servers with TypeScript: A Complete Guide

TL;DR: Learn how to build Model Context Protocol (MCP) servers that connect AI agents to any data source or tool. We'll build a production-ready file system MCP server with TypeScript, authentication, and error handling.


What is MCP and Why Should You Care?

The Model Context Protocol (MCP) is an open standard created by Anthropic that acts like "USB-C for AI applications." It provides a standardized way to connect AI agents to external systems—databases, APIs, file systems, or any tool your agent needs.

The Problem MCP Solves

Before MCP, connecting an AI agent to external tools required custom integrations for every combination:

Agent A → Custom Integration → Tool X
Agent A → Custom Integration → Tool Y
Agent B → Custom Integration → Tool X  // Duplicate effort!
Enter fullscreen mode Exit fullscreen mode

With MCP:

Agent A → MCP Protocol → Any MCP Server
Agent B → MCP Protocol → Any MCP Server  // Build once, use everywhere
Enter fullscreen mode Exit fullscreen mode

Real-World Use Cases

  • AI IDEs: Claude Code can generate web apps from Figma designs
  • Enterprise Chatbots: Connect to multiple databases across your organization
  • Personal Assistants: Access Google Calendar, Notion, and email
  • Creative Workflows: Control Blender, 3D printers, or design tools

Architecture Overview

MCP follows a client-server architecture with three core primitives:

Primitive Controlled By Use Case
Tools Model Actions the AI can take (search, calculate, send email)
Resources Application Data exposed to the AI (files, database records)
Prompts User Reusable prompt templates with context injection

Communication Flow

┌─────────────┐     JSON-RPC      ┌─────────────┐
│  MCP Client │  ←────────────→   │ MCP Server  │
│  (Claude,   │   stdio or SSE    │ (Your Code) │
│   Your App) │                   │             │
└─────────────┘                   └──────┬──────┘
                                         │
                                    ┌────┴────┐
                                    │ External│
                                    │ System  │
                                    │(DB, API)│
                                    └─────────┘
Enter fullscreen mode Exit fullscreen mode

Project Setup

Let's build a production-ready MCP server that provides file system operations to AI agents.

Step 1: Initialize the Project

mkdir mcp-filesystem-server
cd mcp-filesystem-server
npm init -y
Enter fullscreen mode Exit fullscreen mode

Step 2: Install Dependencies

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure TypeScript

// tsconfig.json
{
  "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"]
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Update package.json

{
  "name": "mcp-filesystem-server",
  "version": "1.0.0",
  "description": "Production-ready MCP server for file system operations",
  "type": "module",
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc",
    "dev": "tsx src/index.ts",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit"
  },
  "keywords": ["mcp", "model-context-protocol", "filesystem", "ai"],
  "author": "Your Name",
  "license": "MIT"
}
Enter fullscreen mode Exit fullscreen mode

Building the MCP Server

Step 1: Create the Server Structure

// src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  ErrorCode,
  McpError,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { promises as fs } from 'fs';
import path from 'path';

// Configuration
const CONFIG = {
  allowedDirectories: process.env.ALLOWED_DIRS?.split(',') || [process.cwd()],
  maxFileSize: parseInt(process.env.MAX_FILE_SIZE || '10485760'), // 10MB
  logLevel: process.env.LOG_LEVEL || 'info',
} as const;

// Validation schemas
const ReadFileSchema = z.object({
  path: z.string().describe('Absolute path to the file to read'),
});

const WriteFileSchema = z.object({
  path: z.string().describe('Absolute path to write the file'),
  content: z.string().describe('Content to write'),
});

const ListDirectorySchema = z.object({
  path: z.string().describe('Absolute path to the directory'),
});

const SearchFilesSchema = z.object({
  path: z.string().describe('Directory to search in'),
  pattern: z.string().describe('Search pattern (glob)'),
});

// Type definitions
type ToolName = 'read_file' | 'write_file' | 'list_directory' | 'search_files';

interface ToolDefinition {
  name: ToolName;
  description: string;
  inputSchema: object;
}

// Logger utility
class Logger {
  static info(message: string, meta?: Record<string, unknown>) {
    if (CONFIG.logLevel === 'info' || CONFIG.logLevel === 'debug') {
      console.error(`[INFO] ${message}`, meta ? JSON.stringify(meta) : '');
    }
  }

  static error(message: string, error?: unknown) {
    console.error(`[ERROR] ${message}`, error);
  }

  static debug(message: string, meta?: Record<string, unknown>) {
    if (CONFIG.logLevel === 'debug') {
      console.error(`[DEBUG] ${message}`, meta ? JSON.stringify(meta) : '');
    }
  }
}

// Security: Validate paths are within allowed directories
function validatePath(requestedPath: string): string {
  const resolvedPath = path.resolve(requestedPath);
  const isAllowed = CONFIG.allowedDirectories.some(dir => {
    const resolvedDir = path.resolve(dir);
    return resolvedPath.startsWith(resolvedDir);
  });

  if (!isAllowed) {
    throw new McpError(
      ErrorCode.InvalidRequest,
      `Access denied: Path ${requestedPath} is outside allowed directories`
    );
  }

  return resolvedPath;
}

// File operations with error handling
async function readFile(filePath: string): Promise<string> {
  const validatedPath = validatePath(filePath);

  try {
    const stats = await fs.stat(validatedPath);

    if (!stats.isFile()) {
      throw new McpError(ErrorCode.InvalidRequest, 'Path is not a file');
    }

    if (stats.size > CONFIG.maxFileSize) {
      throw new McpError(
        ErrorCode.InvalidRequest,
        `File size (${stats.size} bytes) exceeds maximum (${CONFIG.maxFileSize} bytes)`
      );
    }

    const content = await fs.readFile(validatedPath, 'utf-8');
    Logger.info('File read successfully', { path: validatedPath, size: stats.size });
    return content;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      throw new McpError(ErrorCode.InvalidRequest, `File not found: ${filePath}`);
    }
    throw error;
  }
}

async function writeFile(filePath: string, content: string): Promise<void> {
  const validatedPath = validatePath(filePath);

  try {
    // Ensure parent directory exists
    const parentDir = path.dirname(validatedPath);
    await fs.mkdir(parentDir, { recursive: true });

    await fs.writeFile(validatedPath, content, 'utf-8');
    Logger.info('File written successfully', { 
      path: validatedPath, 
      size: Buffer.byteLength(content, 'utf-8') 
    });
  } catch (error) {
    Logger.error('Failed to write file', error);
    throw new McpError(
      ErrorCode.InternalError,
      `Failed to write file: ${(error as Error).message}`
    );
  }
}

async function listDirectory(dirPath: string): Promise<Array<{ name: string; type: 'file' | 'directory' }>> {
  const validatedPath = validatePath(dirPath);

  try {
    const entries = await fs.readdir(validatedPath, { withFileTypes: true });
    const result = entries.map(entry => ({
      name: entry.name,
      type: (entry.isDirectory() ? 'directory' : 'file') as 'file' | 'directory',
    }));

    Logger.info('Directory listed', { path: validatedPath, count: result.length });
    return result;
  } catch (error) {
    if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
      throw new McpError(ErrorCode.InvalidRequest, `Directory not found: ${dirPath}`);
    }
    throw error;
  }
}

async function searchFiles(dirPath: string, pattern: string): Promise<string[]> {
  const validatedPath = validatePath(dirPath);

  // Simple glob-like search (in production, use a proper glob library)
  const results: string[] = [];

  async function searchRecursive(currentPath: string) {
    const entries = await fs.readdir(currentPath, { withFileTypes: true });

    for (const entry of entries) {
      const fullPath = path.join(currentPath, entry.name);

      if (entry.name.includes(pattern.replace('*', ''))) {
        results.push(fullPath);
      }

      if (entry.isDirectory()) {
        await searchRecursive(fullPath);
      }
    }
  }

  await searchRecursive(validatedPath);
  Logger.info('Search completed', { path: validatedPath, pattern, matches: results.length });
  return results;
}

// Tool definitions
const TOOLS: ToolDefinition[] = [
  {
    name: 'read_file',
    description: 'Read the contents of a file. Returns the file content as text.',
    inputSchema: {
      type: 'object',
      properties: {
        path: {
          type: 'string',
          description: 'Absolute path to the file to read',
        },
      },
      required: ['path'],
    },
  },
  {
    name: 'write_file',
    description: 'Write content to a file. Creates parent directories if needed.',
    inputSchema: {
      type: 'object',
      properties: {
        path: {
          type: 'string',
          description: 'Absolute path to write the file',
        },
        content: {
          type: 'string',
          description: 'Content to write to the file',
        },
      },
      required: ['path', 'content'],
    },
  },
  {
    name: 'list_directory',
    description: 'List all files and directories in a given path.',
    inputSchema: {
      type: 'object',
      properties: {
        path: {
          type: 'string',
          description: 'Absolute path to the directory',
        },
      },
      required: ['path'],
    },
  },
  {
    name: 'search_files',
    description: 'Search for files matching a pattern in a directory.',
    inputSchema: {
      type: 'object',
      properties: {
        path: {
          type: 'string',
          description: 'Directory to search in',
        },
        pattern: {
          type: 'string',
          description: 'Search pattern (matches against filenames)',
        },
      },
      required: ['path', 'pattern'],
    },
  },
];

// Create and configure the server
const server = new Server(
  {
    name: 'filesystem-mcp-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      tools: {},
      resources: {},
    },
  }
);

// Handler: List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  Logger.debug('Listing tools');
  return { tools: TOOLS };
});

// Handler: Execute a tool
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  Logger.info('Tool called', { name, args });

  try {
    switch (name as ToolName) {
      case 'read_file': {
        const { path: filePath } = ReadFileSchema.parse(args);
        const content = await readFile(filePath);
        return {
          content: [
            {
              type: 'text',
              text: content,
            },
          ],
        };
      }

      case 'write_file': {
        const { path: filePath, content } = WriteFileSchema.parse(args);
        await writeFile(filePath, content);
        return {
          content: [
            {
              type: 'text',
              text: `Successfully wrote to ${filePath}`,
            },
          ],
        };
      }

      case 'list_directory': {
        const { path: dirPath } = ListDirectorySchema.parse(args);
        const entries = await listDirectory(dirPath);
        return {
          content: [
            {
              type: 'text',
              text: JSON.stringify(entries, null, 2),
            },
          ],
        };
      }

      case 'search_files': {
        const { path: dirPath, pattern } = SearchFilesSchema.parse(args);
        const matches = await searchFiles(dirPath, pattern);
        return {
          content: [
            {
              type: 'text',
              text: matches.length > 0 
                ? `Found ${matches.length} matches:\n${matches.join('\n')}`
                : 'No matches found',
            },
          ],
        };
      }

      default:
        throw new McpError(
          ErrorCode.MethodNotFound,
          `Unknown tool: ${name}`
        );
    }
  } catch (error) {
    if (error instanceof z.ZodError) {
      throw new McpError(
        ErrorCode.InvalidParams,
        `Invalid parameters: ${error.errors.map(e => e.message).join(', ')}`
      );
    }
    throw error;
  }
});

// Handler: List available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: CONFIG.allowedDirectories.map(dir => ({
      uri: `file://${dir}`,
      mimeType: 'text/plain',
      name: path.basename(dir) || 'root',
    })),
  };
});

// Handler: Read a resource
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const uri = request.params.uri;

  if (!uri.startsWith('file://')) {
    throw new McpError(ErrorCode.InvalidRequest, 'Invalid URI scheme');
  }

  const filePath = uri.slice(7); // Remove 'file://'
  const content = await readFile(filePath);

  return {
    contents: [
      {
        uri,
        mimeType: 'text/plain',
        text: content,
      },
    ],
  };
});

// Error handling
server.onerror = (error) => {
  Logger.error('Server error:', error);
};

// Graceful shutdown
process.on('SIGINT', async () => {
  Logger.info('Shutting down server...');
  await server.close();
  process.exit(0);
});

process.on('SIGTERM', async () => {
  Logger.info('Shutting down server...');
  await server.close();
  process.exit(0);
});

// Start the server
async function main() {
  Logger.info('Starting MCP Filesystem Server', {
    allowedDirectories: CONFIG.allowedDirectories,
    maxFileSize: CONFIG.maxFileSize,
  });

  const transport = new StdioServerTransport();
  await server.connect(transport);

  Logger.info('Server running on stdio');
}

main().catch((error) => {
  Logger.error('Fatal error starting server:', error);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Testing Your MCP Server

Step 1: Build the Project

npm run build
Enter fullscreen mode Exit fullscreen mode

Step 2: Test with Environment Variables

# Set allowed directories
export ALLOWED_DIRS="/home/user/projects,/tmp"
export MAX_FILE_SIZE=5242880  # 5MB
export LOG_LEVEL=debug

# Run the server
npm start
Enter fullscreen mode Exit fullscreen mode

Step 3: Test with the MCP Inspector

The MCP Inspector is a development tool for testing MCP servers:

# Install globally
npm install -D @anthropics/mcp-inspector

# Run inspector with your server
npx @anthropics/mcp-inspector node dist/index.js
Enter fullscreen mode Exit fullscreen mode

Integrating with Claude Desktop

To use your MCP server with Claude Desktop, add it to your configuration:

macOS

// ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "filesystem": {
      "command": "node",
      "args": ["/absolute/path/to/mcp-filesystem-server/dist/index.js"],
      "env": {
        "ALLOWED_DIRS": "/Users/yourname/Documents,/Users/yourname/Projects",
        "MAX_FILE_SIZE": "10485760",
        "LOG_LEVEL": "info"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Windows

// %APPDATA%\Claude\claude_desktop_config.json
{
  "mcpServers": {
    "filesystem": {
      "command": "node",
      "args": ["C:\\path\\to\\mcp-filesystem-server\\dist\\index.js"],
      "env": {
        "ALLOWED_DIRS": "C:\\Users\\YourName\\Documents",
        "MAX_FILE_SIZE": "10485760"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Restart Claude Desktop after updating the config. You'll see a hammer icon when tools are available.


Production Deployment

Docker Deployment

# Dockerfile
FROM node:20-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./
RUN npm ci --only=production

# Copy built application
COPY dist ./dist

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S mcp -u 1001
USER mcp

# Environment
ENV NODE_ENV=production
ENV LOG_LEVEL=info

EXPOSE 3000

CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode
# docker-compose.yml
version: '3.8'

services:
  mcp-server:
    build: .
    environment:
      - ALLOWED_DIRS=/data
      - MAX_FILE_SIZE=10485760
      - LOG_LEVEL=info
    volumes:
      - ./data:/data:ro
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Security First

  • Path validation: Always validate paths are within allowed directories
  • Size limits: Prevent DoS with file size limits
  • Rate limiting: Add rate limiting for expensive operations
  • Input sanitization: Validate all inputs with Zod schemas

2. Error Handling

// Always use McpError for protocol-level errors
throw new McpError(
  ErrorCode.InvalidRequest,  // Use appropriate error code
  'Human-readable error message'
);

// Log errors but don't expose internals to clients
Logger.error('Database connection failed', error);
throw new McpError(
  ErrorCode.InternalError,
  'Failed to complete operation'
);
Enter fullscreen mode Exit fullscreen mode

3. Tool Design

  • Descriptive names: Use clear, action-oriented names (read_file not file_op)
  • Detailed descriptions: Help the AI understand when to use each tool
  • Required vs optional: Mark truly required parameters as required
  • Examples in descriptions: Show expected input format

What's Next?

Now that you've built a filesystem MCP server, try:

  1. Database MCP Server: Connect to PostgreSQL or MongoDB
  2. API Integration Server: Wrap REST APIs with MCP
  3. GitHub MCP Server: Enable AI to manage issues and PRs
  4. Slack/Discord Server: Send messages and manage channels

Resources


Conclusion

MCP is transforming how we build AI-powered applications. By providing a standardized protocol for connecting agents to tools and data, it eliminates integration fragmentation and enables an ecosystem of reusable components.

The filesystem server we built demonstrates production-ready patterns:

  • Type-safe tool definitions with Zod
  • Security with path validation
  • Comprehensive error handling
  • Configurable via environment variables
  • Ready for Docker deployment

Your AI agents are only as powerful as the tools they can access. Start building MCP servers today and unlock new possibilities.


🚀 Want to Build More AI Automations?

If you found this tutorial helpful, check out my AI Automation Starter Kit — a complete collection of templates and scripts for building AI-powered workflows:

👉 Get the AI Automation Starter Kit ($9)

Includes:

  • Pre-built MCP server templates
  • n8n automation workflows
  • OpenAI Agents SDK starters
  • Discord bot monetization templates

Built by QuantBitRealm. Need custom AI development? Hire us or check out our GitHub.

Star this tutorial if it helped you!

Top comments (0)