EdgeCases Logo
Apr 2026
Agentic AI
Expert
8 min read

Building Custom MCP Servers: From Concept to Claude Integration

Model Context Protocol (MCP) servers extend Claude with custom tools. Learn the architecture, implement resource/tool patterns, and handle the bidirectional JSON-RPC communication protocol.

mcp
claude
ai-agents
json-rpc
tool-integration
advanced

Model Context Protocol (MCP) connects Claude to external systems via custom servers. Unlike simple function calling, MCP enables bidirectional communication with resources (data) and tools (actions). Here's how to build production-ready MCP servers.

MCP Architecture: More Than Function Calling

MCP uses JSON-RPC 2.0 over stdio/transport with three core concepts:

// MCP server structure
interface MCPServer {
  resources: Resource[];  // Data Claude can read
  tools: Tool[];         // Actions Claude can execute
  prompts: Prompt[];     // Templated instructions
}

Resources are read-only data sources—files, database records, API responses. Tools are executable functions that modify state. Prompts provide templated instructions with placeholders.

Unlike OpenAI function calling (stateless, single request), MCP maintains persistent connections with streaming updates and state synchronization.

Implementing the MCP Protocol

Start with the official TypeScript SDK:

import {
  Server,
  StdioServerTransport,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
  ListToolsRequestSchema,
  CallToolRequestSchema
} from '@modelcontextprotocol/sdk/server';

class DatabaseMCPServer {
  private server = new Server(
    { name: 'database-server', version: '1.0.0' },
    { capabilities: { resources: {}, tools: {} } }
  );

  constructor() {
    this.setupResourceHandlers();
    this.setupToolHandlers();
  }

  private setupResourceHandlers() {
    // List available database tables as resources
    this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
      resources: [
        {
          uri: 'db://tables/users',
          name: 'Users Table',
          description: 'User account data',
          mimeType: 'application/json'
        },
        {
          uri: 'db://tables/orders',
          name: 'Orders Table',
          description: 'Order transaction data',
          mimeType: 'application/json'
        }
      ]
    }));

    // Read specific resource content
    this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
      const { uri } = request.params;

      if (uri === 'db://tables/users') {
        const users = await this.queryDatabase('SELECT * FROM users LIMIT 10');
        return {
          contents: [{
            uri,
            mimeType: 'application/json',
            text: JSON.stringify(users, null, 2)
          }]
        };
      }

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

Tool Implementation: Actions with Side Effects

Tools execute actions that modify external state:

private setupToolHandlers() {
  // List available tools
  this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
      {
        name: 'create_user',
        description: 'Create a new user account',
        inputSchema: {
          type: 'object',
          properties: {
            name: { type: 'string', description: 'Full name' },
            email: { type: 'string', format: 'email' },
            role: {
              type: 'string',
              enum: ['user', 'admin', 'moderator'],
              default: 'user'
            }
          },
          required: ['name', 'email']
        }
      },
      {
        name: 'send_notification',
        description: 'Send notification to user',
        inputSchema: {
          type: 'object',
          properties: {
            userId: { type: 'number' },
            message: { type: 'string' },
            channel: {
              type: 'string',
              enum: ['email', 'sms', 'push'],
              default: 'email'
            }
          },
          required: ['userId', 'message']
        }
      }
    ]
  }));

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

    switch (name) {
      case 'create_user':
        return await this.createUser(args as CreateUserArgs);

      case 'send_notification':
        return await this.sendNotification(args as NotificationArgs);

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

private async createUser(args: CreateUserArgs) {
  try {
    const userId = await this.database.query(
      'INSERT INTO users (name, email, role) VALUES ($1, $2, $3) RETURNING id',
      [args.name, args.email, args.role || 'user']
    );

    return {
      content: [{
        type: 'text',
        text: `Successfully created user with ID ${userId}. \n\nDetails:\n- Name: ${args.name}\n- Email: ${args.email}\n- Role: ${args.role || 'user'}`
      }]
    };
  } catch (error) {
    return {
      content: [{
        type: 'text',
        text: `Failed to create user: ${error.message}`
      }],
      isError: true
    };
  }
}

Advanced Patterns: Streaming and State Management

MCP supports server-initiated notifications for real-time updates:

class RealtimeMCPServer {
  private subscriptions = new Map

Error Handling and Recovery

MCP servers must handle failures gracefully:

// Implement retry logic for flaky external APIs
private async withRetry

Production Deployment and Configuration

Package your MCP server for Claude Desktop integration:

// package.json - Make it executable
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "bin": {
    "my-mcp-server": "./dist/index.js"
  },
  "files": ["dist/"],
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

// index.ts - Server entry point
#!/usr/bin/env node

async function main() {
  const server = new DatabaseMCPServer();

  // Connect via stdio transport for Claude Desktop
  const transport = new StdioServerTransport();
  await server.connect(transport);

  console.error('Database MCP Server started'); // stderr for logging
}

main().catch(console.error);

Claude Desktop configuration (claude_desktop_config.json):

{
  "mcpServers": {
    "database": {
      "command": "npx",
      "args": ["my-mcp-server"],
      "env": {
        "DATABASE_URL": "postgresql://localhost/myapp"
      }
    }
  }
}

Security and Access Control

MCP servers have full system access—implement security layers:

// Environment-based access control
class SecureMCPServer {
  private readonly allowedOperations: Set

Security best practices:

  • Validate all inputs using JSON Schema
  • Use environment variables for sensitive configuration
  • Implement rate limiting for destructive operations
  • Log all tool executions for audit trails
  • Sanitize resource content to prevent injection attacks

Key architectural insight: MCP servers are persistent processes that maintain state and bidirectional communication with Claude, unlike stateless function calls. This enables sophisticated workflows but requires careful resource management and error recovery.

Advertisement

Advertisement