Understanding MCP Servers: Extending LLM Capabilities

Understanding MCP Servers: Extending LLM Capabilities

The Model Context Protocol (MCP) is an open standard that enables Large Language Models to securely connect with external data sources and tools. MCP servers act as bridges between LLMs and the services they need to access, providing a standardized interface for extending AI capabilities beyond their training data.

The Problem MCP Solves

LLMs are powerful but limited by their training data and lack of real-time information. They cannot:

Before MCP, each AI application had to implement custom integrations for every data source or tool. MCP standardizes this process.

How MCP Works

Architecture Overview

┌─────────────┐         ┌─────────────┐         ┌──────────────┐
│             │         │             │         │              │
│   LLM Host  │ ◄────► │ MCP Server  │ ◄────► │  Data Source │
│  (Claude)   │   MCP   │             │         │ (DB, API)    │
│             │         │             │         │              │
└─────────────┘         └─────────────┘         └──────────────┘

LLM Host: The application running the AI model (Claude Desktop, VS Code, custom apps)

MCP Server: A process that exposes resources and tools via the MCP protocol

Data Source: The actual service, database, API, or filesystem being accessed

Core Concepts

1. Resources

Resources are data that the LLM can read. Examples:

{
  "uri": "file:///home/user/docs/project.md",
  "name": "Project Documentation",
  "mimeType": "text/markdown",
  "contents": "# Project Overview\n..."
}

2. Tools

Tools are functions the LLM can execute. Examples:

{
  "name": "query_database",
  "description": "Execute a SQL query",
  "parameters": {
    "query": "string",
    "limit": "number"
  }
}

3. Prompts

Prompts are reusable templates that guide LLM interactions:

{
  "name": "analyze_logs",
  "description": "Analyze error logs for patterns",
  "arguments": {
    "timeRange": "1h",
    "severity": "error"
  }
}

Building an MCP Server

Basic Server Structure

Here’s a minimal MCP server in TypeScript:

import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';

const server = new Server(
  {
    name: 'example-server',
    version: '1.0.0',
  },
  {
    capabilities: {
      resources: {},
      tools: {},
    },
  }
);

// List available resources
server.setRequestHandler(
  ListResourcesRequestSchema,
  async () => ({
    resources: [
      {
        uri: 'config://app/settings',
        name: 'App Settings',
        mimeType: 'application/json',
      }
    ]
  })
);

// Read resource content
server.setRequestHandler(
  ReadResourceRequestSchema,
  async (request) => {
    if (request.params.uri === 'config://app/settings') {
      return {
        contents: [{
          uri: request.params.uri,
          mimeType: 'application/json',
          text: JSON.stringify({ theme: 'dark', lang: 'en' })
        }]
      };
    }
    throw new Error('Resource not found');
  }
);

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

Implementing Tools

Tools allow the LLM to perform actions:

server.setRequestHandler(
  ListToolsRequestSchema,
  async () => ({
    tools: [
      {
        name: 'send_email',
        description: 'Send an email message',
        inputSchema: {
          type: 'object',
          properties: {
            to: { type: 'string', description: 'Recipient email' },
            subject: { type: 'string', description: 'Email subject' },
            body: { type: 'string', description: 'Email body' }
          },
          required: ['to', 'subject', 'body']
        }
      }
    ]
  })
);

server.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    if (request.params.name === 'send_email') {
      const { to, subject, body } = request.params.arguments;

      // Call email service
      await sendEmail({ to, subject, body });

      return {
        content: [
          {
            type: 'text',
            text: `Email sent successfully to ${to}`
          }
        ]
      };
    }
  }
);

Real-World Use Cases

1. Database Access

Allow LLMs to query your database:

{
  name: 'postgres-mcp',
  tools: [
    {
      name: 'execute_query',
      description: 'Run SQL queries',
      parameters: {
        query: 'SELECT * FROM users WHERE active = true',
        limit: 100
      }
    }
  ]
}

2. Filesystem Operations

Let LLMs read and write files:

{
  name: 'filesystem-mcp',
  resources: [
    'file:///project/src/**/*.ts',
    'file:///project/docs/**/*.md'
  ],
  tools: [
    'read_file',
    'write_file',
    'list_directory'
  ]
}

3. API Integrations

Connect to external services:

{
  name: 'github-mcp',
  tools: [
    {
      name: 'create_issue',
      description: 'Create a GitHub issue',
      parameters: {
        repo: 'owner/repo',
        title: 'Bug report',
        body: 'Description...'
      }
    },
    {
      name: 'list_pull_requests',
      parameters: { repo: 'owner/repo', state: 'open' }
    }
  ]
}

4. Development Tools

Integrate with developer workflows:

{
  name: 'devtools-mcp',
  tools: [
    {
      name: 'run_tests',
      description: 'Execute test suite',
      parameters: { pattern: '*.test.ts' }
    },
    {
      name: 'lint_code',
      description: 'Run linter',
      parameters: { fix: true }
    }
  ]
}

Security Considerations

Authentication

MCP servers should implement proper authentication:

server.setRequestHandler(
  CallToolRequestSchema,
  async (request, { auth }) => {
    // Verify auth token
    if (!verifyToken(auth.token)) {
      throw new Error('Unauthorized');
    }
    // Execute tool
  }
);

Input Validation

Always validate and sanitize inputs:

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

    // Prevent SQL injection
    if (containsSQLInjection(query)) {
      throw new Error('Invalid query');
    }

    // Limit query scope
    if (!query.includes('WHERE user_id = ?')) {
      throw new Error('Must filter by user');
    }

    return executeQuery(query);
  }
);

Resource Limits

Prevent abuse with rate limiting and quotas:

const rateLimiter = new RateLimiter({
  maxRequests: 100,
  windowMs: 60000, // 1 minute
});

server.setRequestHandler(
  CallToolRequestSchema,
  async (request, context) => {
    await rateLimiter.checkLimit(context.clientId);
    // Process request
  }
);

Deployment Patterns

Standalone Process

Run MCP server as a separate process:

# Start server
node mcp-server.js

# Configure in Claude Desktop
{
  "mcpServers": {
    "my-server": {
      "command": "node",
      "args": ["/path/to/mcp-server.js"]
    }
  }
}

Docker Container

Deploy as a containerized service:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]
# docker-compose.yml
services:
  mcp-server:
    build: .
    environment:
      - DATABASE_URL=postgres://...
      - API_KEY=secret
    ports:
      - "3000:3000"

Cloud Functions

Deploy as serverless functions:

// AWS Lambda handler
export const handler = async (event) => {
  const server = new MCPServer();
  const request = JSON.parse(event.body);
  const response = await server.handleRequest(request);

  return {
    statusCode: 200,
    body: JSON.stringify(response)
  };
};

Testing MCP Servers

Unit Tests

Test individual handlers:

import { describe, it, expect } from 'vitest';

describe('MCP Server', () => {
  it('should list resources', async () => {
    const response = await server.handleRequest({
      method: 'resources/list'
    });

    expect(response.resources).toHaveLength(3);
    expect(response.resources[0].uri).toBe('config://app');
  });

  it('should execute tool', async () => {
    const response = await server.handleRequest({
      method: 'tools/call',
      params: {
        name: 'query_db',
        arguments: { query: 'SELECT * FROM users LIMIT 10' }
      }
    });

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

Integration Tests

Test with actual LLM hosts:

import { MCPClient } from '@modelcontextprotocol/sdk/client';

const client = new MCPClient();
await client.connect({ command: 'node', args: ['server.js'] });

// List tools
const tools = await client.listTools();
expect(tools).toContainEqual({
  name: 'send_email',
  description: expect.any(String)
});

// Call tool
const result = await client.callTool('send_email', {
  to: 'test@example.com',
  subject: 'Test',
  body: 'Hello'
});

expect(result.content[0].text).toContain('sent successfully');

Performance Optimization

Caching

Cache frequently accessed resources:

const cache = new Map();

server.setRequestHandler(
  ReadResourceRequestSchema,
  async (request) => {
    const cached = cache.get(request.params.uri);
    if (cached && Date.now() - cached.timestamp < 60000) {
      return cached.data;
    }

    const data = await fetchResource(request.params.uri);
    cache.set(request.params.uri, {
      data,
      timestamp: Date.now()
    });

    return data;
  }
);

Streaming

Stream large responses:

server.setRequestHandler(
  ReadResourceRequestSchema,
  async function* (request) {
    const stream = await getLargeDataset(request.params.uri);

    for await (const chunk of stream) {
      yield {
        contents: [{
          uri: request.params.uri,
          mimeType: 'application/json',
          text: JSON.stringify(chunk)
        }]
      };
    }
  }
);

Monitoring and Observability

Logging

Implement structured logging:

import { logger } from './logger';

server.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    logger.info('Tool called', {
      tool: request.params.name,
      timestamp: new Date().toISOString()
    });

    try {
      const result = await executeTool(request.params);
      logger.info('Tool succeeded', { tool: request.params.name });
      return result;
    } catch (error) {
      logger.error('Tool failed', {
        tool: request.params.name,
        error: error.message
      });
      throw error;
    }
  }
);

Metrics

Track usage and performance:

const metrics = {
  toolCalls: new Counter('mcp_tool_calls_total'),
  duration: new Histogram('mcp_request_duration_seconds'),
  errors: new Counter('mcp_errors_total')
};

server.setRequestHandler(
  CallToolRequestSchema,
  async (request) => {
    const start = Date.now();

    try {
      metrics.toolCalls.inc({ tool: request.params.name });
      const result = await executeTool(request.params);
      metrics.duration.observe(Date.now() - start);
      return result;
    } catch (error) {
      metrics.errors.inc({ tool: request.params.name });
      throw error;
    }
  }
);

Best Practices

  1. Keep servers focused: One server per logical service or data source
  2. Version your interfaces: Use semantic versioning for tool schemas
  3. Document thoroughly: Provide clear descriptions for all tools and resources
  4. Handle errors gracefully: Return meaningful error messages
  5. Implement timeouts: Prevent long-running operations from hanging
  6. Use typed schemas: Leverage TypeScript or JSON Schema for type safety
  7. Test extensively: Cover normal flows and edge cases
  8. Monitor in production: Track usage, errors, and performance

Future of MCP

MCP is evolving to support:

Conclusion

MCP servers bridge the gap between LLMs and the real world. By standardizing how AI accesses external tools and data, MCP enables:

Whether you’re building database connectors, API integrations, or custom tools, MCP provides a robust foundation for extending LLM capabilities in a standardized, secure way.

Resources