Skip to main content
Back to Blog
AI Development Tools

Building Custom MCP Servers for Claude Code

Learn to build custom Model Context Protocol servers for Claude Code. Includes architecture, TypeScript examples, and deployment strategies.

Keyur Patel
Keyur Patel
February 20, 2026
•
11 min read

Introduction

Claude Code's power multiplies when you extend it with custom tools. Building an mcp server claude code integration enables specialized services that provide Claude with access to your unique data, systems, and capabilities. The Model Context Protocol (MCP) is the standardized interface that allows you to create these custom servers.

Whether you need Claude to interact with proprietary databases, manage your infrastructure, query specialized APIs, or execute custom business logic, building a custom MCP server bridges the gap between Claude's general intelligence and your domain-specific requirements. If you are new to Claude Code, start with our getting started with Claude Code guide first.

This comprehensive guide walks you through building production-ready MCP servers that extend Claude Code's capabilities and unlock new levels of automation in your development workflow.

Understanding the Model Context Protocol

What Is MCP?

The Model Context Protocol is a standardized specification for how AI systems communicate with external tools and data sources. Rather than requiring each AI application to implement custom integrations, MCP provides a common language that any AI system can use to interact with any MCP server.

Think of MCP as the "HTTP of AI": a universal protocol that enables Claude Code to discover, understand, and use custom tools without needing specialized connectors for each integration.

Why Custom MCP Servers Matter

Off-the-shelf integrations have limitations. They can't access your proprietary systems, they can't understand your business logic, and they can't adapt to your unique workflows. Custom MCP servers solve this by allowing you to:

  • Extend Claude's capabilities with domain-specific functionality
  • Connect to proprietary systems that lack public APIs
  • Maintain security and control over how Claude accesses your data
  • Implement business logic specific to your organization
  • Create intelligent workflows that combine multiple systems

MCP Architecture Overview

MCP servers operate on a client-server model:

Claude Code (Client)
       ↓
    MCP Protocol
       ↓
Custom MCP Server
       ↓
Your Systems, APIs, Databases

The client (Claude Code) sends requests via the MCP protocol to your server, which handles the request logic and returns results. This separation of concerns allows Claude Code to remain agnostic about implementation details while your server can use any technology stack.

Core MCP Concepts

Resources

Resources represent data or functionality your server exposes. Think of them as endpoints that Claude Code can read or interact with.

typescript
// Example resources
/database/users          // Access user data
/api/analytics          // Query analytics
/files/documentation    // Browse documentation
/system/infrastructure  // View system metrics

Tools

Tools are actions that Claude Code can execute through your server. These are RPC-style functions with defined input and output schemas.

typescript
// Example tools
query_database(sql: string) // Execute database queries
deploy_service(name: string, version: string) // Deploy services
generate_report(format: string, dateRange: string) // Generate reports
update_config(service: string, config: object) // Modify configurations

Sampling

Sampling describes how Claude Code can request new capabilities or resources dynamically from your server, enabling discovery of available tools at runtime.

Building Your First MCP Server

Project Setup

Initialize a TypeScript project for your MCP server:

bash
# Create project directory
mkdir my-mcp-server
cd my-mcp-server

# Initialize npm
npm init -y

# Install dependencies
npm install @modelcontextprotocol/sdk typescript ts-node
npm install -D @types/node

# Create TypeScript configuration
cat > tsconfig.json << 'EOF'
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
EOF

Basic Server Structure

Here's a minimal but functional MCP server:

typescript
// src/index.ts
import {
  Server,
  StdioServerTransport,
} from "@modelcontextprotocol/sdk/server/index.js";
import {
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  ListToolsRequestSchema,
  CallToolRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";

// Create the server
const server = new Server({
  name: "my-custom-server",
  version: "1.0.0",
});

// Define available resources
server.setRequestHandler(ListResourcesRequestSchema, async () => {
  return {
    resources: [
      {
        uri: "file:///data/users",
        name: "User Database",
        description: "Access to user records",
        mimeType: "application/json",
      },
      {
        uri: "file:///data/config",
        name: "Configuration",
        description: "System configuration",
        mimeType: "application/json",
      },
    ],
  };
});

// Handle resource reading
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;

  if (uri === "file:///data/users") {
    return {
      contents: [
        {
          uri,
          mimeType: "application/json",
          text: JSON.stringify([
            { id: 1, name: "Alice", role: "admin" },
            { id: 2, name: "Bob", role: "user" },
          ]),
        },
      ],
    };
  }

  throw new Error(`Unknown resource: ${uri}`);
});

// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "query_database",
        description: "Execute SQL queries against the database",
        inputSchema: {
          type: "object",
          properties: {
            sql: {
              type: "string",
              description: "SQL query to execute",
            },
          },
          required: ["sql"],
        },
      },
      {
        name: "create_user",
        description: "Create a new user record",
        inputSchema: {
          type: "object",
          properties: {
            name: { type: "string" },
            email: { type: "string" },
            role: { type: "string", enum: ["admin", "user", "viewer"] },
          },
          required: ["name", "email", "role"],
        },
      },
    ],
  };
});

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

  if (name === "query_database") {
    // In production, execute actual database queries
    return {
      content: [
        {
          type: "text",
          text: `Query executed: ${args.sql}`,
        },
      ],
    };
  }

  if (name === "create_user") {
    return {
      content: [
        {
          type: "text",
          text: `User created: ${args.name} (${args.email}) with role ${args.role}`,
        },
      ],
    };
  }

  throw new Error(`Unknown tool: ${name}`);
});

// Start the server
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP server running on stdio");
}

main().catch(console.error);

Real-World MCP Server Examples

Example 1: Database Query Server

A practical server for executing database queries:

typescript
// src/servers/database-server.ts
import Database from "better-sqlite3";
import {
  Server,
  StdioServerTransport,
} from "@modelcontextprotocol/sdk/server/index.js";

const db = new Database("app.db");

const server = new Server({
  name: "database-server",
  version: "1.0.0",
});

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "execute_query",
        description: "Execute a SQL SELECT query",
        inputSchema: {
          type: "object",
          properties: {
            query: {
              type: "string",
              description: "SQL SELECT query",
            },
            params: {
              type: "array",
              description: "Query parameters for parameterized queries",
            },
          },
          required: ["query"],
        },
      },
      {
        name: "get_schema",
        description: "Get database schema information",
        inputSchema: {
          type: "object",
          properties: {
            table: {
              type: "string",
              description: "Table name (optional)",
            },
          },
        },
      },
    ],
  };
});

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

  if (name === "execute_query") {
    try {
      const stmt = db.prepare(args.query);
      const results = args.params
        ? stmt.all(...args.params)
        : stmt.all();

      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(results, null, 2),
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Query error: ${error.message}`,
            isError: true,
          },
        ],
      };
    }
  }

  if (name === "get_schema") {
    const query = args.table
      ? `SELECT sql FROM sqlite_master WHERE type='table' AND name=?`
      : `SELECT name, sql FROM sqlite_master WHERE type='table'`;

    const results = args.table
      ? db.prepare(query).all(args.table)
      : db.prepare(query).all();

    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(results, null, 2),
        },
      ],
    };
  }

  throw new Error(`Unknown tool: ${name}`);
});

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
}

main().catch(console.error);

Example 2: API Wrapper Server

A server that wraps multiple external APIs:

typescript
// src/servers/api-wrapper-server.ts
import fetch from "node-fetch";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";

const server = new Server({
  name: "api-wrapper-server",
  version: "1.0.0",
});

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "fetch_github_user",
        description: "Fetch GitHub user information",
        inputSchema: {
          type: "object",
          properties: {
            username: {
              type: "string",
              description: "GitHub username",
            },
          },
          required: ["username"],
        },
      },
      {
        name: "get_weather",
        description: "Get current weather for a location",
        inputSchema: {
          type: "object",
          properties: {
            city: {
              type: "string",
              description: "City name",
            },
          },
          required: ["city"],
        },
      },
    ],
  };
});

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

  if (name === "fetch_github_user") {
    try {
      const response = await fetch(
        `https://api.github.com/users/${args.username}`,
        {
          headers: {
            Authorization: `token ${process.env.GITHUB_TOKEN}`,
          },
        }
      );

      const data = await response.json();
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(data, null, 2),
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error fetching user: ${error.message}`,
            isError: true,
          },
        ],
      };
    }
  }

  if (name === "get_weather") {
    try {
      const response = await fetch(
        `https://api.openweathermap.org/data/2.5/weather?q=${args.city}&appid=${process.env.WEATHER_API_KEY}`
      );

      const data = await response.json();
      return {
        content: [
          {
            type: "text",
            text: `Weather in ${args.city}: ${data.main.temp}°K, ${data.weather[0].description}`,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error fetching weather: ${error.message}`,
            isError: true,
          },
        ],
      };
    }
  }

  throw new Error(`Unknown tool: ${name}`);
});

Example 3: File System Server

A server providing intelligent file system access:

typescript
// src/servers/filesystem-server.ts
import fs from "fs/promises";
import path from "path";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";

const SAFE_BASE_PATH = "/app/documents";

const server = new Server({
  name: "filesystem-server",
  version: "1.0.0",
});

server.setRequestHandler(ListResourcesRequestSchema, async () => {
  try {
    const files = await fs.readdir(SAFE_BASE_PATH);
    return {
      resources: files.map((file) => ({
        uri: `file://${SAFE_BASE_PATH}/${file}`,
        name: file,
        description: `File: ${file}`,
        mimeType: "text/plain",
      })),
    };
  } catch (error) {
    return { resources: [] };
  }
});

server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
  const { uri } = request.params;
  const filePath = uri.replace("file://", "");

  // Security: ensure path is within safe base
  const resolvedPath = path.resolve(filePath);
  if (!resolvedPath.startsWith(SAFE_BASE_PATH)) {
    throw new Error("Access denied: path outside safe directory");
  }

  try {
    const content = await fs.readFile(resolvedPath, "utf-8");
    return {
      contents: [
        {
          uri,
          mimeType: "text/plain",
          text: content,
        },
      ],
    };
  } catch (error: any) {
    throw new Error(`Cannot read file: ${error.message}`);
  }
});

server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "write_file",
        description: "Write content to a file",
        inputSchema: {
          type: "object",
          properties: {
            filename: { type: "string" },
            content: { type: "string" },
          },
          required: ["filename", "content"],
        },
      },
      {
        name: "list_directory",
        description: "List files in a directory",
        inputSchema: {
          type: "object",
          properties: {
            directory: { type: "string" },
          },
          required: ["directory"],
        },
      },
    ],
  };
});

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

  if (name === "write_file") {
    const filePath = path.join(SAFE_BASE_PATH, args.filename);
    const resolvedPath = path.resolve(filePath);

    if (!resolvedPath.startsWith(SAFE_BASE_PATH)) {
      return {
        content: [
          {
            type: "text",
            text: "Error: Access denied",
            isError: true,
          },
        ],
      };
    }

    try {
      await fs.writeFile(resolvedPath, args.content, "utf-8");
      return {
        content: [
          {
            type: "text",
            text: `File written: ${args.filename}`,
          },
        ],
      };
    } catch (error: any) {
      return {
        content: [
          {
            type: "text",
            text: `Error writing file: ${error.message}`,
            isError: true,
          },
        ],
      };
    }
  }

  throw new Error(`Unknown tool: ${name}`);
});

Connecting Your MCP Server to Claude Code

Configuration

Register your MCP server in Claude Code's configuration:

json
// claude-mcp-servers.json
{
  "servers": {
    "my-database": {
      "type": "command",
      "command": "ts-node",
      "args": ["src/servers/database-server.ts"],
      "env": {
        "DATABASE_URL": "sqlite:app.db"
      }
    },
    "my-apis": {
      "type": "command",
      "command": "ts-node",
      "args": ["src/servers/api-wrapper-server.ts"],
      "env": {
        "GITHUB_TOKEN": "${GITHUB_TOKEN}",
        "WEATHER_API_KEY": "${WEATHER_API_KEY}"
      }
    }
  }
}

Testing Your Server

Before deploying, thoroughly test your server:

typescript
// test/server.test.ts
import { test } from "@jest/globals";
import { CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";

test("should execute database query", async () => {
  const request = {
    params: {
      name: "execute_query",
      arguments: {
        query: "SELECT * FROM users WHERE id = ?",
        params: [1],
      },
    },
  };

  // Call your server handler
  const response = await server.handleRequest(request);

  expect(response.content).toBeDefined();
  expect(response.content[0].type).toBe("text");
});

test("should deny unauthorized file access", async () => {
  const request = {
    params: {
      name: "write_file",
      arguments: {
        filename: "../../etc/passwd",
        content: "hack",
      },
    },
  };

  const response = await server.handleRequest(request);
  expect(response.content[0].isError).toBe(true);
});

Security Best Practices

Input Validation

Always validate and sanitize user input:

typescript
// Validate input types and constraints
function validateQueryInput(query: string): boolean {
  // Check length
  if (query.length > 10000) return false;

  // Prevent dangerous operations
  const dangerous = ["DROP", "DELETE", "TRUNCATE"];
  return !dangerous.some((cmd) => query.toUpperCase().includes(cmd));
}

// Use parameterized queries to prevent SQL injection
const stmt = db.prepare("SELECT * FROM users WHERE id = ?");
const results = stmt.all(userId); // Safe parameter binding

Authentication and Authorization

Implement proper access control:

typescript
function authenticate(token: string): User | null {
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    return decoded as User;
  } catch {
    return null;
  }
}

function authorize(user: User, resource: string): boolean {
  const permissions = userPermissions[user.id] || [];
  return permissions.includes(resource);
}

Rate Limiting

Protect against abuse with rate limiting:

typescript
import rateLimit from "express-rate-limit";

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
});

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (!limiter.isAllowed(request.clientId)) {
    throw new Error("Rate limit exceeded");
  }
  // ... handle request
});

Deployment Strategies

Docker Deployment

Package your server for reliable deployment:

dockerfile
# Dockerfile
FROM node:20-alpine

WORKDIR /app

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

COPY src ./src
RUN npm run build

EXPOSE 3000
CMD ["node", "dist/index.js"]

Systemd Service

Run your server as a system service:

ini
# /etc/systemd/system/mcp-server.service
[Unit]
Description=Custom MCP Server
After=network.target

[Service]
Type=simple
User=mcp-user
WorkingDirectory=/opt/mcp-server
ExecStart=/usr/bin/node /opt/mcp-server/dist/index.js
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Kubernetes Deployment

Deploy at scale with Kubernetes:

yaml
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mcp-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: mcp-server
  template:
    metadata:
      labels:
        app: mcp-server
    spec:
      containers:
        - name: mcp-server
          image: myregistry/mcp-server:1.0.0
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: mcp-secrets
                  key: database-url

Build Your First MCP Server Today

Custom MCP servers unlock the full potential of Claude Code by extending it with your domain-specific capabilities. From querying proprietary databases to integrating with internal APIs, MCP servers enable seamless AI integration with your infrastructure.

The path from basic concept to production server is straightforward:

  • Start small with a simple resource or tool
  • Test thoroughly before deployment
  • Implement security from the beginning
  • Monitor and iterate based on usage patterns
Ready to extend Claude Code? Explore the MCP and Model Context Protocol explained for detailed specifications, check out our Claude Code plugins guide for plugin-based extensions, or dive into enterprise workflows with Claude Code and MCP for organization-scale deployments.

Key Takeaways

  • MCP is the universal protocol for AI systems to interact with custom tools and data
  • Custom servers extend Claude Code with domain-specific functionality unavailable through standard integrations
  • Basic architecture includes resources (data), tools (actions), and proper client-server communication
  • Real-world examples include database queries, API wrappers, and file system access
  • Security is paramount: validate input, authenticate users, and implement rate limiting
  • Testing and validation should happen before any production deployment
  • Multiple deployment options range from simple Docker containers to Kubernetes clusters
  • Start simple and iterate rather than building perfect-but-complex servers initially
The future of AI development is about specialized servers that speak the common language of MCP. Your custom server could be the foundation of unprecedented automation in your organization.

Keyur Patel

Written by Keyur Patel

AI Engineer & Founder

Keyur Patel is the founder of AiPromptsX and an AI engineer with extensive experience in prompt engineering, large language models, and AI application development. After years of working with AI systems like ChatGPT, Claude, and Gemini, he created AiPromptsX to share effective prompt patterns and frameworks with the broader community. His mission is to democratize AI prompt engineering and help developers, content creators, and business professionals harness the full potential of AI tools.

Prompt EngineeringAI DevelopmentLarge Language ModelsSoftware Engineering

Explore Related Frameworks

Try These Related Prompts