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:
- Access current data from databases
- Retrieve files from your filesystem
- Query external APIs
- Execute commands or scripts
- Interact with specialized tools
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:
- Database tables and queries
- File contents
- API responses
- Configuration data
{
"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:
- Running database queries
- Creating/editing files
- Calling external APIs
- Executing scripts
{
"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
- Keep servers focused: One server per logical service or data source
- Version your interfaces: Use semantic versioning for tool schemas
- Document thoroughly: Provide clear descriptions for all tools and resources
- Handle errors gracefully: Return meaningful error messages
- Implement timeouts: Prevent long-running operations from hanging
- Use typed schemas: Leverage TypeScript or JSON Schema for type safety
- Test extensively: Cover normal flows and edge cases
- Monitor in production: Track usage, errors, and performance
Future of MCP
MCP is evolving to support:
- Streaming protocols: For real-time data flows
- Bidirectional communication: Servers pushing updates to clients
- Enhanced security: OAuth2, mTLS, and fine-grained permissions
- Discovery mechanisms: Automatic server registration and lookup
- Standard server library: Pre-built servers for common use cases
Conclusion
MCP servers bridge the gap between LLMs and the real world. By standardizing how AI accesses external tools and data, MCP enables:
- Reusable integrations across different LLM hosts
- Secure, controlled access to sensitive resources
- Extensible AI capabilities without model retraining
- Community-driven ecosystem of shared servers
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
- MCP Specification: Official protocol documentation
- SDK Documentation: TypeScript, Python, and other language SDKs
- Example Servers: Reference implementations for common use cases
- Community Servers: Catalog of third-party MCP servers
- Claude Desktop Integration: Setup guides and configuration