Enterprise·
advanced
·18 min read·Oct 7, 2026·Updated Oct 10, 2026
By Rad Tome·Lead AI Systems Architect·How we verify this

MCP in Production: 7 Hard Lessons from Running MCP Servers at Scale

Real war stories from running MCP servers in production at scale. Covers connection storms, token blowouts, cascade failures, debugging nightmares, and the architectural patterns that actually work.

productionscalingdebuggingobservabilityreliabilitycase-studyenterprise
Interactive Tool
1-Click Export

Generate & Validate Multi-Client MCP Config

One-click export with environment variables & path locators for Claude Desktop, Cursor, Windsurf, and OpenAI Codex CLI.

Open in Generator

MCP in Production: 7 Hard Lessons from Running MCP Servers at Scale

We've been running MCP servers in production since early 2024—powering AI agents for 500+ developers across multiple teams. The path from "it works on my machine" to "it handles 50,000 tool calls/day without paging" was paved with incidents, 3 AM pages, and hard-won architectural insights.

This isn't theory. These are the specific failures we hit, the metrics that mattered, and the patterns that saved us.


Lesson 1: Connection Storms Will Kill Your stdio Servers

The Failure

We launched our first PostgreSQL MCP server with a naive connection model: one database connection per MCP client. When 50 developers opened Cursor simultaneously at 9 AM, we hit PostgreSQL's max_connections limit in seconds. The database became unresponsive, taking down not just MCP but production workloads.

diagram
PostgreSQL PrimaryPostgreSQL MCP ServerCursor/Claude50 DevelopersPostgreSQL PrimaryPostgreSQL MCP ServerCursor/Claude50 DevelopersOpen IDE at 9:00 AMSpawn stdio process per windowCREATE CONNECTION (x50)FATAL: too many connectionsConnection refused"MCP server unavailable"

The Fix: Connection Pooling with PgBouncer

We moved to a pooler sidecar pattern. The MCP server doesn't hold connections—it borrows from PgBouncer in transaction mode.

typescript
// Before: Direct connection (DANGEROUS)
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 100 });

// After: PgBouncer transaction pooling
const pool = new Pool({
  host: 'localhost',
  port: 6432,  // PgBouncer port
  max: 20,     // Far fewer actual connections
  idleTimeoutMillis: 5000,
});

PgBouncer config (pgbouncer.ini):

ini
[databases]
production = host=prod-db.internal port=5432 dbname=analytics

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
min_pool_size = 5
reserve_pool_size = 5

Result: 500+ concurrent developers, 25 actual database connections, zero connection errors.


Lesson 2: Token Blowouts from Verbose Tool Responses

The Failure

Our filesystem MCP server returned full file contents for every read_file call. A single read_file on a 2,000-line TypeScript file consumed ~8,000 tokens. An agent debugging a 10-file issue blew through a 128K context window in 3 turns.

json
// What we returned (WASTEFUL)
{
  "content": [{
    "type": "text",
    "text": "// File: src/utils/auth.ts\nimport { ... }\n// ... 1,997 more lines"
  }]
}

The Fix: Structured Summaries + On-Demand Detail

We implemented progressive disclosure: return a summary by default, let the agent request specifics.

typescript
// tools/read_file.ts
interface ReadFileArgs {
  path: string;
  mode?: 'summary' | 'full' | 'range';
  start_line?: number;
  end_line?: number;
}

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === 'read_file') {
    const { path, mode = 'summary', start_line, end_line } = request.params.arguments;
    const content = await fs.readFile(path, 'utf-8');
    const lines = content.split('\n');
    
    switch (mode) {
      case 'summary':
        // Return structure + first/last 20 lines + symbol index
        return {
          content: [{
            type: 'text',
            text: formatSummary(path, lines)
          }]
        };
      case 'range':
        return {
          content: [{
            type: 'text',
            text: lines.slice(start_line - 1, end_line).join('\n')
          }]
        };
      case 'full':
      default:
        return {
          content: [{ type: 'text', text: content }]
        };
    }
  }
});

function formatSummary(path: string, lines: string[]) {
  const symbols = extractSymbols(lines); // Tree-sitter extraction
  const preview = lines.slice(0, 20).join('\n');
  const tail = lines.slice(-20).join('\n');
  
  return `\`\`\`text
${path} (${lines.length} lines)
Symbols: ${symbols.join(', ')}

--- HEAD ---
${preview}

--- TAIL ---
${tail}
\`\`\``;
}

Token reduction: 94% average savings. Agents now request mode: 'range' only when needed.


Lesson 3: Cascade Failures from Missing Timeouts

The Failure

An upstream API (third-party CRM) slowed to 30-second responses. Our MCP server had no timeout. The agent kept the stdio connection open, blocking the client's event loop. 200 agents waiting on one slow tool = total platform freeze.

diagram

tools/call

tools/call

tools/call

HTTP GET /contacts

30s timeout

Blocked

Blocked

Agent 1

MCP Server

Agent 2

Agent N...

Slow CRM API

The Fix: Multi-Layer Timeout Strategy

typescript
// Layer 1: HTTP client timeout (enforced at fetch level)
const httpClient = axios.create({
  timeout: 5000,           // 5s hard limit
  timeoutErrorMessage: 'Upstream timeout',
});

// Layer 2: MCP tool wrapper with deadline
async function withDeadline<T>(promise: Promise<T>, ms: number): Promise<T> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), ms);
  try {
    return await promise;
  } finally {
    clearTimeout(timeout);
  }
}

// Layer 3: Circuit breaker (prevent hammering failing upstreams)
const breaker = new CircuitBreaker(httpClient.get, {
  timeout: 5000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

// Usage in tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === 'search_contacts') {
    return withDeadline(
      breaker.fire(request.params.arguments.query),
      8000  // Total budget: 8s
    );
  }
});

Key timeouts by operation type:

OperationTimeoutRationale
Local FS read2sShould be instant
Database query5sPgBouncer + query limit
External API8sIncludes retries
LLM sampling30sModel inference time

Lesson 4: The "Silent Data Corruption" of Stdio Transport

The Failure

A developer's MCP server logged debug output to stdout instead of stderr. This corrupted the JSON-RPC framing. The client received:

json
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"DEBUG: Connecting to DB..."}]}}
{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"actual response"}]}}

The client parsed the first line as a complete response, then choked on the second. Errors appeared as "invalid JSON-RPC" with no actionable info.

The Fix: Stdout Hygiene + Framing Validation

Rule 1: Never write to stdout except valid JSON-RPC messages.

typescript
// logger.ts - SAFE logging for stdio servers
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);

process.stdout.write = (chunk: any, encoding?: any, callback?: any) => {
  // In production, ONLY allow JSON-RPC messages through stdout
  if (process.env.NODE_ENV === 'production') {
    try {
      const parsed = JSON.parse(chunk.toString());
      if (!parsed.jsonrpc || !parsed.id) {
        throw new Error('Not JSON-RPC');
      }
    } catch {
      // Redirect to stderr in production
      return originalStderrWrite(`[STDOUT BLOCKED] ${chunk}`, encoding, callback);
    }
  }
  return originalStdoutWrite(chunk, encoding, callback);
};

// Structured logging to stderr
export const logger = {
  debug: (msg: string, meta?: object) => 
    originalStderrWrite(JSON.stringify({ level: 'debug', msg, meta, ts: Date.now() }) + '\n'),
  error: (msg: string, err?: Error) =>
    originalStderrWrite(JSON.stringify({ level: 'error', msg, err: err?.message, ts: Date.now() }) + '\n'),
};

Rule 2: Validate framing on the client side.

typescript
// Client-side framing validator
function parseJsonRpcStream(stream: Readable): AsyncGenerator<JsonRpcMessage> {
  let buffer = '';
  for await (const chunk of stream) {
    buffer += chunk.toString();
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';
    
    for (const line of lines) {
      if (!line.trim()) continue;
      try {
        yield JSON.parse(line);
      } catch (e) {
        throw new JsonRpcParseError(`Invalid JSON-RPC: ${line.slice(0, 100)}`);
      }
    }
  }
}

Lesson 5: Observability Gaps Make Debugging Impossible

The Failure

"MCP is slow" tickets with zero actionable data. No distributed traces, no correlation IDs, no way to know if the bottleneck was the agent, the transport, the server, or the upstream API.

The Fix: OpenTelemetry Everywhere

typescript
// instrumentation.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
import { Resource } from '@opentelemetry/resources';
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: 'mcp-postgres-server',
    [SemanticResourceAttributes.SERVICE_VERSION]: '2.3.1',
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV,
  }),
  traceExporter: new JaegerExporter({
    endpoint: process.env.JAEGER_ENDPOINT,
  }),
  instrumentations: [
    getNodeAutoInstrumentations({
      '@opentelemetry/instrumentation-http': {
        enabled: true,
        ignoreIncomingPaths: ['/health'],
      },
    }),
  ],
});

sdk.start();

// Custom MCP span attributes
import { trace, SpanStatusCode } from '@opentelemetry/api';

export function createMcpSpan(toolName: string, args: any) {
  return trace.getTracer('mcp-server').startSpan(`mcp.tool.${toolName}`, {
    attributes: {
      'mcp.tool.name': toolName,
      'mcp.tool.args': JSON.stringify(args).slice(0, 1000),
      'mcp.transport': process.env.MCP_TRANSPORT || 'stdio',
    },
  });
}

// Usage in tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const span = createMcpSpan(request.params.name, request.params.arguments);
  try {
    const result = await handleTool(request.params.name, request.params.arguments);
    span.setStatus({ code: SpanStatusCode.OK });
    span.setAttribute('mcp.result.size', JSON.stringify(result).length);
    return result;
  } catch (err) {
    span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
    span.recordException(err);
    throw err;
  } finally {
    span.end();
  }
});

Dashboards we built (Grafana + Prometheus):

  • ▸Tool latency p50/p95/p99 by tool name
  • ▸Error rate by error type (validation, upstream, timeout, auth)
  • ▸Active connections (stdio processes, SSE clients)
  • ▸Token throughput (input/output tokens per tool call)
  • ▸Circuit breaker state (open/closed/half-open)

Lesson 6: Agent-Driven Load Is Unpredictable—Plan for Burst

The Failure

A single agent in a loop called search_code 500 times in 30 seconds. Our rate limiter (100 req/min per IP) didn't apply because stdio transports don't have IPs. The database CPU hit 100%.

The Fix: Token-Bucket Per Session + Adaptive Concurrency

typescript
// rate-limiter.ts
interface SessionBucket {
  tokens: number;
  lastRefill: number;
  burstUsed: number;
}

const sessions = new Map<string, SessionBucket>();
const REFILL_RATE = 10;      // tokens/second
const BURST_LIMIT = 50;      // max burst
const MAX_CONCURRENT = 3;    // parallel tool calls per session

export function checkRateLimit(sessionId: string): { allowed: boolean; retryAfter?: number } {
  const now = Date.now();
  let bucket = sessions.get(sessionId);
  
  if (!bucket) {
    bucket = { tokens: BURST_LIMIT, lastRefill: now, burstUsed: 0 };
    sessions.set(sessionId, bucket);
  }
  
  // Refill tokens
  const elapsed = (now - bucket.lastRefill) / 1000;
  bucket.tokens = Math.min(BURST_LIMIT, bucket.tokens + elapsed * REFILL_RATE);
  bucket.lastRefill = now;
  
  if (bucket.tokens >= 1) {
    bucket.tokens -= 1;
    bucket.burstUsed += 1;
    return { allowed: true };
  }
  
  return { allowed: false, retryAfter: Math.ceil((1 - bucket.tokens) / REFILL_RATE * 1000) };
}

// Concurrency limiter per session
const activeCalls = new Map<string, number>();

export function acquireConcurrencySlot(sessionId: string): boolean {
  const current = activeCalls.get(sessionId) || 0;
  if (current >= MAX_CONCURRENT) return false;
  activeCalls.set(sessionId, current + 1);
  return true;
}

export function releaseConcurrencySlot(sessionId: string) {
  const current = activeCalls.get(sessionId) || 1;
  activeCalls.set(sessionId, Math.max(0, current - 1));
}

Integration:

typescript
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const sessionId = request.meta?.sessionId || 'anonymous';
  
  // Rate limit
  const { allowed, retryAfter } = checkRateLimit(sessionId);
  if (!allowed) {
    throw new McpError(ErrorCode.RateLimited, `Rate limited. Retry in ${retryAfter}ms`);
  }
  
  // Concurrency limit
  if (!acquireConcurrencySlot(sessionId)) {
    throw new McpError(ErrorCode.InternalError, 'Too many concurrent calls');
  }
  
  try {
    return await handleTool(request.params.name, request.params.arguments);
  } finally {
    releaseConcurrencySlot(sessionId);
  }
});

Lesson 7: Schema Drift Between Client and Server Breaks Silently

The Failure

We added a required parameter to a tool (search_code now needed language). Old clients (Cursor 0.42, Claude Desktop 0.8) didn't send it. The server returned a cryptic validation error. Users saw "tool failed" with no upgrade guidance.

The Fix: Versioned Tool Schemas + Client Capability Detection

typescript
// tool-versions.ts
export const TOOL_VERSIONS = {
  search_code: {
    '1.0': {
      inputSchema: {
        type: 'object',
        properties: { query: { type: 'string' } },
        required: ['query'],
      },
    },
    '2.0': {
      inputSchema: {
        type: 'object',
        properties: { 
          query: { type: 'string' },
          language: { type: 'string', enum: ['typescript', 'python', 'go', 'rust'] },
        },
        required: ['query', 'language'],
      },
    },
  },
} as const;

// Capability negotiation during initialize
server.setRequestHandler(InitializeRequestSchema, async (request) => {
  const clientVersion = request.params.clientInfo?.version || 'unknown';
  const supportedTools = negotiateToolVersions(request.params.capabilities);
  
  return {
    protocolVersion: '2024-11-05',
    capabilities: { tools: { listChanged: true } },
    serverInfo: { name: 'code-search-mcp', version: '2.0.0' },
    // Custom: tell client which tool versions we support
    _meta: { toolVersions: supportedTools },
  };
});

function negotiateToolVersions(clientCaps: any) {
  // Client can declare supported versions
  const clientToolVersions = clientCaps?.tools?.versions || {};
  
  return Object.fromEntries(
    Object.entries(TOOL_VERSIONS).map(([tool, versions]) => [
      tool,
      // Default to latest version client supports, or latest we have
      clientToolVersions[tool] || Object.keys(versions).pop()!
    ])
  );
}

// Tool handler uses negotiated version
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const toolVersion = negotiatedVersions[request.params.name] || '1.0';
  const schema = TOOL_VERSIONS[request.params.name]?.[toolVersion];
  
  if (!schema) {
    throw new McpError(ErrorCode.MethodNotFound, `Tool ${request.params.name} not available`);
  }
  
  // Validate against negotiated schema
  const validation = validateAgainstSchema(request.params.arguments, schema.inputSchema);
  if (!validation.valid) {
    return {
      isError: true,
      content: [{
        type: 'text',
        text: `Invalid arguments for ${request.params.name} v${toolVersion}: ${validation.errors.join(', ')}. ` +
              `Please update your MCP client to support tool version ${toolVersion}.`
      }]
    };
  }
  
  return handleTool(request.params.name, request.params.arguments);
});

Client upgrade prompt (what users see):

The search_code tool now requires a language parameter (v2.0). Your client (Cursor 0.42) sends v1.0. Update Cursor to ≥0.45 or configure toolVersions: { search_code: "1.0" } in your MCP config to use legacy mode.


Summary: The Production Checklist

LessonPatternImplementation Effort
1. Connection StormsPgBouncer sidecar + transaction poolingMedium
2. Token BlowoutsProgressive disclosure (summary → range → full)Low
3. Cascade FailuresMulti-layer timeouts + circuit breakersMedium
4. Stdio CorruptionStdout hygiene + framing validationLow
5. Observability GapsOpenTelemetry + custom MCP span attributesHigh
6. Unpredictable BurstToken bucket per session + concurrency slotsMedium
7. Schema DriftVersioned tool schemas + capability negotiationMedium

Related Guides

Technical Changelog

Weekly Protocol Diffs & Verified Drops

New server guides, benchmarks, and security audits. No spam. Unsubscribe anytime.

Zero marketing spam. 1-click unsubscribe anytime.

Ready to Deploy?

Build your full agent toolstack in the Visual Generator

Combine MCP in Production: 7 Hard Lessons from Running with databases, search APIs, and memory graphs in a single configuration file.

Customize in Generator
Developer Verification & Feedback
0 reports

Did this setup guide work with your AI host?

Help test this configuration across Claude, Cursor, and Codex CLI. Be the first to report:

MCP in Production: 7 Hard Lessons from Running MCP Servers at Scale FAQ

What is the MCP in Production: 7 Hard Lessons from Running MCP Servers at Scale?

Real war stories from running MCP servers in production at scale. Covers connection storms, token blowouts, cascade failures, debugging nightmares, and the architectural patterns that actually work.

How do I configure MCP in Production: 7 Hard Lessons from Running MCP Servers at Scale in Claude Desktop or Cursor?

You can copy the configuration JSON from our guide or launch the interactive MCP Codex Config Generator at https://mcp-codex.com/generator to export valid configs in 1 click.

Can I use MCP in Production: 7 Hard Lessons from Running MCP Servers at Scale with the OpenAI Codex CLI?

Yes, OpenAI Codex CLI supports Model Context Protocol. You can add it directly to ~/.codex/config.toml or pass arguments to codex mcp add.

RT

Written by Rad Tome

Lead AI Systems Architect & Maintainer, MCP Codex

@RadTome

Specializing in Model Context Protocol (MCP) integrations, autonomous AI agent orchestration, and distributed developer toolchains. Researches and benchmarks production MCP client-server architectures across OpenAI Codex, Claude, and Cursor.

Editorial Integrity: Package names are checked against the npm registry and repositories against the GitHub API. Where a package could not be verified, the guide says so instead of giving you a command to run.

Related Guides