Key Principle: Only use MCP servers from trusted sources. Third-party servers have the same access as any code you run locally.
Master the best practices for deploying, securing, and optimizing MCP servers in production environments. Learn from real-world experiences and avoid common pitfalls.
Key Principle: Only use MCP servers from trusted sources. Third-party servers have the same access as any code you run locally.
Review Source Code
Analyze Permissions
Test in Isolation
Component | Security Measure | Priority |
---|---|---|
Authentication | Use environment variables for secrets | Critical |
Network | Restrict to localhost for sensitive data | High |
Permissions | Run with minimal required privileges | Critical |
Data Access | Use read-only credentials when possible | High |
Logging | Audit all operations without exposing secrets | Medium |
Updates | Keep servers and dependencies updated | High |
Best Practice: Never hardcode credentials
# .env.local (add to .gitignore)DATABASE_URL=postgresql://readonly:pass@localhost/dbAPI_KEY=sk-proj-...OAUTH_CLIENT_SECRET=...
# Load in shellexport $(cat .env.local | xargs)
# Use in MCP configclaude mcp add db -e DATABASE_URL=$DATABASE_URL -- \ npx -y @modelcontextprotocol/server-postgres
For Production: Use proper secret management
// Using AWS Secrets Managerimport { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
async function getSecret(secretName: string) { const client = new SecretsManagerClient({ region: "us-east-1" }); const command = new GetSecretValueCommand({ SecretId: secretName }); const response = await client.send(command); return JSON.parse(response.SecretString);}
// Initialize MCP with secure credentialsconst secrets = await getSecret("mcp-server-credentials");process.env.API_KEY = secrets.apiKey;
For Remote Servers: Implement proper OAuth flow
// Secure token storageimport { encrypt, decrypt } from './crypto';
class TokenStore { async saveToken(userId: string, token: string) { const encrypted = await encrypt(token); await db.saveUserToken(userId, encrypted); }
async getToken(userId: string) { const encrypted = await db.getUserToken(userId); return decrypt(encrypted); }
async refreshIfNeeded(userId: string) { const token = await this.getToken(userId); if (isExpired(token)) { const newToken = await refreshOAuthToken(token); await this.saveToken(userId, newToken); return newToken; } return token; }}
Local Servers (stdio)
Most Secure Option
Remote Servers (SSE/HTTP)
Requires Extra Care
# Example: Restrict MCP server to local access only# iptables rule for Linuxsudo iptables -A INPUT -p tcp --dport 3845 -s 127.0.0.1 -j ACCEPTsudo iptables -A INPUT -p tcp --dport 3845 -j DROP
# macOS pf ruleecho "block in proto tcp to any port 3845" | sudo pfctl -ef -
# Windows Firewall PowerShellNew-NetFirewallRule -DisplayName "Block MCP External" ` -Direction Inbound -LocalPort 3845 -Protocol TCP ` -Action Block -RemoteAddress !127.0.0.1
// Example: Sanitizing external contentserver.tool( 'fetch_webpage', 'Safely fetch and process webpage content', { url: z.string().url() }, async ({ url }) => { // Validate URL domain const allowedDomains = ['docs.company.com', 'api.company.com']; const urlObj = new URL(url);
if (!allowedDomains.includes(urlObj.hostname)) { throw new Error('URL domain not allowed'); }
// Fetch with timeout and size limit const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000);
try { const response = await fetch(url, { signal: controller.signal, headers: { 'User-Agent': 'MCP-Server/1.0' }, });
// Limit response size const contentLength = response.headers.get('content-length'); if (contentLength && parseInt(contentLength) > 1_000_000) { throw new Error('Response too large'); }
const text = await response.text();
// Sanitize content const sanitized = text .replace(/<script[^>]*>.*?<\/script>/gi, '') // Remove scripts .replace(/\bignore previous instructions\b/gi, '[REDACTED]') // Basic injection filter .substring(0, 50000); // Truncate to reasonable size
return { content: [{ type: 'text', text: `Content from ${url}:\n\n${sanitized}`, }], }; } finally { clearTimeout(timeout); } });
The Challenge: Large responses can fill AI context windows, reducing effectiveness and increasing costs.
server.tool( 'search_large_dataset', 'Search with automatic pagination', { query: z.string(), page: z.number().optional().default(1), pageSize: z.number().optional().default(20), }, async ({ query, page, pageSize }) => { const results = await searchDatabase(query, { offset: (page - 1) * pageSize, limit: pageSize, });
const hasMore = results.total > page * pageSize;
return { content: [{ type: 'text', text: JSON.stringify({ results: results.items, page, pageSize, total: results.total, hasMore, nextPage: hasMore ? page + 1 : null, }, null, 2), }], }; });
server.tool( 'analyze_logs', 'Analyze logs with automatic summarization', { timeRange: z.string(), includeRaw: z.boolean().optional().default(false), }, async ({ timeRange, includeRaw }) => { const logs = await fetchLogs(timeRange);
// Always provide summary const summary = { totalEntries: logs.length, errorCount: logs.filter(l => l.level === 'ERROR').length, warningCount: logs.filter(l => l.level === 'WARN').length, topErrors: groupByFrequency(logs.filter(l => l.level === 'ERROR')), timeDistribution: groupByHour(logs), };
let response = `Log Analysis Summary:\n${JSON.stringify(summary, null, 2)}`;
// Only include raw logs if specifically requested and reasonable size if (includeRaw && logs.length < 100) { response += `\n\nRaw Logs:\n${JSON.stringify(logs, null, 2)}`; } else if (includeRaw) { response += `\n\nNote: ${logs.length} logs found. Too many to display. Use filters to narrow down.`; }
return { content: [{ type: 'text', text: response }], }; });
// Return most relevant data firstserver.tool( 'get_user_profile', 'Fetch user profile with progressive detail', { userId: z.string(), detail: z.enum(['basic', 'full', 'complete']).optional().default('basic'), }, async ({ userId, detail }) => { const basicInfo = await getUserBasicInfo(userId);
if (detail === 'basic') { return { content: [{ type: 'text', text: JSON.stringify(basicInfo) }], }; }
const activitySummary = await getUserActivitySummary(userId);
if (detail === 'full') { return { content: [{ type: 'text', text: JSON.stringify({ ...basicInfo, activitySummary }), }], }; }
// Complete includes everything const fullHistory = await getUserFullHistory(userId); return { content: [{ type: 'text', text: JSON.stringify({ ...basicInfo, activitySummary, fullHistory }), }], }; });
class MCPCache { private cache = new Map<string, { data: any; expires: number }>(); private readonly DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
set(key: string, data: any, ttl = this.DEFAULT_TTL) { this.cache.set(key, { data, expires: Date.now() + ttl, }); }
get(key: string) { const entry = this.cache.get(key); if (!entry) return null;
if (Date.now() > entry.expires) { this.cache.delete(key); return null; }
return entry.data; }
// Implement cache key generation static key(tool: string, params: any): string { return `${tool}:${JSON.stringify(params, Object.keys(params).sort())}`; }}
const cache = new MCPCache();
server.tool( 'expensive_operation', 'Cached expensive operation', { input: z.string() }, async ({ input }) => { const cacheKey = MCPCache.key('expensive_operation', { input }); const cached = cache.get(cacheKey);
if (cached) { return { content: [{ type: 'text', text: `[Cached] ${cached}`, }], }; }
const result = await performExpensiveOperation(input); cache.set(cacheKey, result);
return { content: [{ type: 'text', text: result }], }; });
// Redis-based caching for shared stateimport Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
server.tool( 'get_team_metrics', 'Fetch team metrics with distributed cache', { teamId: z.string() }, async ({ teamId }) => { const cacheKey = `metrics:${teamId}`;
// Try cache first const cached = await redis.get(cacheKey); if (cached) { return { content: [{ type: 'text', text: cached, }], }; }
// Compute metrics const metrics = await computeTeamMetrics(teamId); const result = JSON.stringify(metrics, null, 2);
// Cache with expiration await redis.setex(cacheKey, 300, result); // 5 minute TTL
return { content: [{ type: 'text', text: result }], }; });
// Efficient database connection managementimport { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 20, // Maximum connections idleTimeoutMillis: 30000, // Close idle connections after 30s connectionTimeoutMillis: 2000, // Fail fast on connection});
// Health checksetInterval(async () => { try { await pool.query('SELECT 1'); } catch (error) { console.error('Database health check failed:', error); }}, 30000);
// Graceful shutdownprocess.on('SIGTERM', async () => { await pool.end(); process.exit(0);});
// Monitor and limit memory usageconst v8 = require('v8');
function getMemoryUsage() { const heap = v8.getHeapStatistics(); return { used: Math.round(heap.used_heap_size / 1024 / 1024), total: Math.round(heap.total_heap_size / 1024 / 1024), limit: Math.round(heap.heap_size_limit / 1024 / 1024), };}
// Memory pressure detectionserver.tool( 'process_large_file', 'Process file with memory management', { filePath: z.string() }, async ({ filePath }) => { const memory = getMemoryUsage();
// Check memory before processing if (memory.used / memory.limit > 0.8) { // Force garbage collection if available if (global.gc) { global.gc(); }
// If still high, refuse operation const newMemory = getMemoryUsage(); if (newMemory.used / newMemory.limit > 0.8) { return { content: [{ type: 'text', text: 'Server under memory pressure. Please try again later.', }], }; } }
// Process with streaming to minimize memory const stream = createReadStream(filePath); const chunks = [];
for await (const chunk of stream) { // Process chunk by chunk chunks.push(processChunk(chunk));
// Yield control periodically if (chunks.length % 100 === 0) { await new Promise(resolve => setImmediate(resolve)); } }
return { content: [{ type: 'text', text: `Processed ${chunks.length} chunks successfully`, }], }; });
Structured Logging
import winston from 'winston';
const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), transports: [ new winston.transports.Console(), new winston.transports.File({ filename: 'mcp-server.log' }), ],});
// Log tool usageserver.use((tool, params, next) => { const start = Date.now(); logger.info('Tool called', { tool, params: sanitizeParams(params) });
next().then(result => { logger.info('Tool completed', { tool, duration: Date.now() - start, success: true, }); }).catch(error => { logger.error('Tool failed', { tool, duration: Date.now() - start, error: error.message, }); });});
Metrics Collection
import { Counter, Histogram, register } from 'prom-client';
const toolCallCounter = new Counter({ name: 'mcp_tool_calls_total', help: 'Total number of tool calls', labelNames: ['tool', 'status'],});
const toolDuration = new Histogram({ name: 'mcp_tool_duration_seconds', help: 'Tool execution duration', labelNames: ['tool'], buckets: [0.1, 0.5, 1, 2, 5, 10],});
// Expose metrics endpoint for Prometheusapp.get('/metrics', (req, res) => { res.set('Content-Type', register.contentType); res.end(register.metrics());});
Health Checks
server.tool( 'health_check', 'System health status', {}, async () => { const checks = await Promise.allSettled([ checkDatabase(), checkExternalAPI(), checkDiskSpace(), checkMemory(), ]);
const results = checks.map((check, index) => ({ component: ['database', 'api', 'disk', 'memory'][index], status: check.status === 'fulfilled' ? 'healthy' : 'unhealthy', details: check.status === 'fulfilled' ? check.value : check.reason, }));
const allHealthy = results.every(r => r.status === 'healthy');
return { content: [{ type: 'text', text: JSON.stringify({ status: allHealthy ? 'healthy' : 'degraded', timestamp: new Date().toISOString(), checks: results, }, null, 2), }], }; });
// Fallback strategies for external dependenciesserver.tool( 'get_weather', 'Get weather with fallback providers', { location: z.string() }, async ({ location }) => { const providers = [ { name: 'primary', fn: () => getPrimaryWeather(location) }, { name: 'secondary', fn: () => getSecondaryWeather(location) }, { name: 'cache', fn: () => getCachedWeather(location) }, ];
for (const provider of providers) { try { const result = await withTimeout(provider.fn(), 3000); if (result) { return { content: [{ type: 'text', text: `Weather from ${provider.name}: ${JSON.stringify(result)}`, }], }; } } catch (error) { logger.warn(`Weather provider ${provider.name} failed`, { error }); continue; } }
// All providers failed return { content: [{ type: 'text', text: 'Weather data temporarily unavailable. Please try again later.', }], }; });
class CircuitBreaker { private failures = 0; private lastFailTime = 0; private state: 'closed' | 'open' | 'half-open' = 'closed';
constructor( private threshold = 5, private timeout = 60000 // 1 minute ) {}
async call<T>(fn: () => Promise<T>): Promise<T> { if (this.state === 'open') { if (Date.now() - this.lastFailTime > this.timeout) { this.state = 'half-open'; } else { throw new Error('Circuit breaker is open'); } }
try { const result = await fn(); if (this.state === 'half-open') { this.state = 'closed'; this.failures = 0; } return result; } catch (error) { this.failures++; this.lastFailTime = Date.now();
if (this.failures >= this.threshold) { this.state = 'open'; }
throw error; } }}
const apiBreaker = new CircuitBreaker();
server.tool( 'call_external_api', 'Call external API with circuit breaker', { endpoint: z.string() }, async ({ endpoint }) => { try { const result = await apiBreaker.call(() => fetch(endpoint).then(r => r.json()) );
return { content: [{ type: 'text', text: JSON.stringify(result) }], }; } catch (error) { if (error.message === 'Circuit breaker is open') { return { content: [{ type: 'text', text: 'Service temporarily unavailable due to repeated failures', }], }; } throw error; } });
// .mcp.json - Version controlled team configuration{ "mcpServers": { "team-database": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-postgres"], "env": { "DATABASE_URL": "${DATABASE_URL}", // From environment "POOL_SIZE": "10", "STATEMENT_TIMEOUT": "30s" } }, "team-apis": { "command": "npx", "args": ["-y", "@ourcompany/mcp-internal-apis"], "env": { "API_BASE_URL": "${INTERNAL_API_URL}", "API_KEY": "${INTERNAL_API_KEY}" } } }}
# MCP Server: Internal Tools
## OverviewProvides access to internal company tools and services.
## Available Tools
### `search_knowledge_base`Search internal documentation and wikis.
**Parameters:**- `query` (string, required): Search query- `category` (string, optional): Filter by category- `limit` (number, optional): Max results (default: 10)
**Example:**
“Search for deployment procedures for the payment service”
### `get_service_status`Check health and status of internal services.
**Parameters:**- `service` (string, optional): Specific service name
**Example:**
“Check the status of the authentication service”
## Security Notes- Requires VPN connection- Uses service account with read-only access- Logs all queries for audit purposes
## Troubleshooting- Error "Connection refused": Ensure VPN is connected- Error "Unauthorized": Check API key expiration
Compress Responses
Remove unnecessary whitespace and formatting from JSON responses when appropriate.
Use Summaries
Provide summaries by default, with options for detailed data only when needed.
Implement Filters
Allow filtering at the source to reduce data transfer and processing.
Cache Aggressively
Cache frequently requested data to avoid redundant processing.
import rateLimit from 'express-rate-limit';
// Per-user rate limiting for remote serversconst limiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 100, // Limit each user to 100 requests per window message: 'Too many requests, please try again later', standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.user?.id || req.ip,});
// Tool-specific rate limitingconst toolLimiters = new Map();
function getToolLimiter(tool: string, maxRequests = 10) { if (!toolLimiters.has(tool)) { toolLimiters.set(tool, new Map()); }
return (userId: string) => { const userLimiters = toolLimiters.get(tool); const now = Date.now(); const windowStart = now - 60000; // 1 minute window
const requests = userLimiters.get(userId) || []; const recentRequests = requests.filter(t => t > windowStart);
if (recentRequests.length >= maxRequests) { throw new Error(`Rate limit exceeded for ${tool}`); }
recentRequests.push(now); userLimiters.set(userId, recentRequests); };}
// Support multiple API versionsserver.tool( 'api_call', 'Versioned API endpoint', { endpoint: z.string(), version: z.enum(['v1', 'v2']).optional().default('v2'), }, async ({ endpoint, version }) => { const handlers = { v1: () => callV1API(endpoint), v2: () => callV2API(endpoint), };
const result = await handlers[version]();
// Add deprecation warning for old versions if (version === 'v1') { return { content: [{ type: 'text', text: `[DEPRECATION WARNING] API v1 will be removed on 2025-12-31. Please use v2.\n\n${JSON.stringify(result)}`, }], }; }
return { content: [{ type: 'text', text: JSON.stringify(result) }], }; });
// Feature flags for gradual rolloutconst features = { enhancedSearch: process.env.FEATURE_ENHANCED_SEARCH === 'true', aiSummaries: process.env.FEATURE_AI_SUMMARIES === 'true', caching: process.env.FEATURE_CACHING !== 'false', // Default on};
server.tool( 'search', 'Search with feature flags', { query: z.string() }, async ({ query }) => { let results;
if (features.enhancedSearch) { results = await enhancedSearch(query); } else { results = await basicSearch(query); }
if (features.aiSummaries && results.length > 0) { results = await addAISummaries(results); }
return { content: [{ type: 'text', text: JSON.stringify({ results, features: Object.entries(features) .filter(([_, enabled]) => enabled) .map(([name]) => name), }), }], }; });
Remember: MCP servers are powerful tools that require the same operational excellence as any production service. Treat them with the same care you would any critical infrastructure component.