Problem
El Model Context Protocol (MCP) revolucionó cómo los agentes IA interactúan con herramientas y servicios. Durante el desarrollo, MCP es increíble: plug-and-play, autodescubrimiento de herramientas, y una experiencia de desarrollo fluida. Pero cuando intentas llevar ese prototipo a producción, la realidad te golpea duro.
Los desafíos que enfrentamos al integrar MCP en producción no son triviales:
- Seguridad: ¿Cómo autenticas y autorizas requests entre agentes y tools sin exponer credenciales?
- Error Handling: ¿Qué pasa cuando un MCP tool falla mid-conversation? ¿El agent se bloquea?
- Rate Limiting: ¿Cómo evitas que un agent mal configurado DDoS tus servicios subyacentes?
- Observabilidad: ¿Cómo tracingas el flow completo desde el agent hasta el tool y de vuelta?
- Versioning: ¿Cómo manejas cambios en la API de un MCP tool sin romper agents dependientes?
En Equifax LATAM, aprendimos esto de la manera difícil. Tuvimos un MCP server de herramientas financieras que, en producción, causó timeouts en cascada cuando el servicio de credit scoring tuvo un incidente. Los agents no tenían timeouts, no tenían retries, y no había circuit breakers. El resultado: conversaciones colgadas, usuarios frustrados, y un incidente que tardó 3 horas en mitigarse.
Core Concept
MCP es esencialmente un contrato entre agentes y herramientas. Define cómo los agents descubben, invocan y reciben respuestas de tools. Pero en producción, ese contrato no es suficiente. Necesitas una capa de infraestructura alrededor de MCP que proporcione:
- Wrappers: Middleware que intercepta cada request/response
- Retries con backoff exponencial: Para manejar fallos transitorios
- Caching inteligente: Reducir latencia y carga en servicios
- Monitoring real-time: Detectar anomalías antes de que se conviertan en incidentes
- Rate limiting: Proteger servicios subyacentes
- Timeout enforcement: Prevenir conversaciones colgadas
MCP alone is not production-ready. It's the contract layer, not the infrastructure layer. Think of MCP as HTTP and your production patterns as the reverse proxy/load balancer sitting in front of it.
Implementation
Arquitectura MCP en Producción
La arquitectura que ha funcionado para nosotros sigue un patrón de three-tier:
[Agent Orchestrator]
↓
[MCP Gateway] ← JWT Auth, Rate Limiting, Telemetry
↓
[MCP Servers] ← Circuit Breakers, Retries, Caching
↓
[Tools/Services] ← APIs, databases, external services
El MCP Gateway es el punto de entrada unificado. Todos los agents se conectan al gateway, que maneja autenticación, rate limiting, y routing al MCP server apropiado. Esto desacopla los agents de la infraestructura MCP subyacente.
Los MCP Servers son instancias específicas que implementan tools para dominios concretos (financial, database, API calls, etc.). Cada server tiene su propio middleware de retries, circuit breakers, y caching.
Veamos cómo implementar esto en código.
MCP Server con Middleware (Go)
Este ejemplo muestra un MCP server production-ready en Go con middleware completo:
package main
import (
"context"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/jaeger"
"go.opentelemetry.io/otel/sdk/resource"
tracesdk "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
"go.uber.org/ratelimit"
"github.com/sony/gobreaker"
)
// MCP Tool Definition
type MCPTool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]interface{} `json:"input_schema"`
Handler ToolHandler
}
type ToolHandler func(ctx context.Context, params map[string]interface{}) (interface{}, error)
// MCP Server Structure
type MCPServer struct {
tools map[string]MCPTool
jwtKey *rsa.PrivateKey
rateLimiter ratelimit.Limiter
circuitBreaker *gobreaker.CircuitBreaker
cache *ToolCache
metrics *MCPMetrics
tracer tracesdk.Tracer
}
type MCPMetrics struct {
requestsTotal *prometheus.CounterVec
requestDuration *prometheus.HistogramVec
errorsTotal *prometheus.CounterVec
circuitState *prometheus.GaugeVec
}
type ToolCache struct {
store sync.Map
ttl time.Duration
}
// Initialize MCP Server
func NewMCPServer() (*MCPServer, error) {
// Initialize OpenTelemetry
tracerProvider, err := initTracer()
if err != nil {
return nil, fmt.Errorf("failed to initialize tracer: %w", err)
}
otel.SetTracerProvider(tracerProvider)
// Initialize metrics
metrics := &MCPMetrics{
requestsTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "mcp_requests_total",
Help: "Total number of MCP tool requests",
},
[]string{"tool", "status"},
),
requestDuration: prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "mcp_request_duration_seconds",
Help: "Duration of MCP tool requests",
Buckets: prometheus.DefBuckets,
},
[]string{"tool"},
),
errorsTotal: prometheus.NewCounterVec(
prometheus.CounterOpts{
Name: "mcp_errors_total",
Help: "Total number of MCP tool errors",
},
[]string{"tool", "error_type"},
),
circuitState: prometheus.NewGaugeVec(
prometheus.GaugeOpts{
Name: "mcp_circuit_breaker_state",
Help: "State of circuit breaker (0=closed, 1=open, 2=half-open)",
},
[]string{"tool"},
),
}
prometheus.MustRegister(
metrics.requestsTotal,
metrics.requestDuration,
metrics.errorsTotal,
metrics.circuitState,
)
// Initialize circuit breaker
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "mcp_tools",
MaxRequests: 5,
Interval: 30 * time.Second,
Timeout: 60 * time.Second,
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
metrics.circuitState.WithLabelValues(name).Set(float64(to))
},
})
server := &MCPServer{
tools: make(map[string]MCPTool),
rateLimiter: ratelimit.New(100), // 100 requests/sec
circuitBreaker: cb,
cache: &ToolCache{ttl: 5 * time.Minute},
metrics: metrics,
tracer: otel.Tracer("mcp-server"),
}
return server, nil
}
// Register Tool
func (s *MCPServer) RegisterTool(tool MCPTool) {
s.tools[tool.Name] = tool
}
// Middleware Chain
func (s *MCPServer) middleware(next http.Handler) http.Handler {
chain := []func(http.Handler) http.Handler{
s.authMiddleware,
s.rateLimitMiddleware,
s.telemetryMiddleware,
}
for i := len(chain) - 1; i >= 0; i-- {
next = chain[i](next)
}
return next
}
// JWT Authentication Middleware
func (s *MCPServer) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Authorization header required", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return &s.jwtKey.PublicKey, nil
})
if err != nil || !token.Valid {
http.Error(w, "Invalid token", http.StatusUnauthorized)
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
http.Error(w, "Invalid claims", http.StatusUnauthorized)
return
}
// Add claims to context
ctx := context.WithValue(r.Context(), "claims", claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Rate Limiting Middleware
func (s *MCPServer) rateLimitMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.rateLimiter.Take()
next.ServeHTTP(w, r)
})
}
// Telemetry Middleware
func (s *MCPServer) telemetryMiddleware(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "mcp-request")
}
// Handle Tool Invocation
func (s *MCPServer) handleToolInvocation(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
span := s.tracer.Start(ctx, "handle_tool_invocation")
defer span.End()
var req struct {
ToolName string `json:"tool_name"`
Params map[string]interface{} `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.metrics.errorsTotal.WithLabelValues("", "decode_error").Inc()
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
tool, exists := s.tools[req.ToolName]
if !exists {
s.metrics.errorsTotal.WithLabelValues(req.ToolName, "not_found").Inc()
http.Error(w, "Tool not found", http.StatusNotFound)
return
}
span.SetAttributes(
attribute.String("tool.name", tool.Name),
attribute.String("tool.params", fmt.Sprintf("%v", req.Params)),
)
start := time.Now()
// Check cache first
cacheKey := fmt.Sprintf("%s:%v", req.ToolName, req.Params)
if cached, ok := s.cache.get(cacheKey); ok {
s.metrics.requestsTotal.WithLabelValues(tool.Name, "cache_hit").Inc()
json.NewEncoder(w).Encode(map[string]interface{}{
"result": cached,
"cached": true,
})
return
}
// Execute with circuit breaker
result, err := s.circuitBreaker.Execute(func() (interface{}, error) {
return tool.Handler(ctx, req.Params)
})
duration := time.Since(start).Seconds()
s.metrics.requestDuration.WithLabelValues(tool.Name).Observe(duration)
if err != nil {
s.metrics.errorsTotal.WithLabelValues(tool.Name, "execution_error").Inc()
s.metrics.requestsTotal.WithLabelValues(tool.Name, "error").Inc()
if errors.Is(err, gobreaker.ErrOpenState) {
span.SetAttributes(attribute.Bool("circuit.open", true))
http.Error(w, "Service unavailable (circuit open)", http.StatusServiceUnavailable)
return
}
span.RecordError(err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Cache result
s.cache.set(cacheKey, result)
s.metrics.requestsTotal.WithLabelValues(tool.Name, "success").Inc()
json.NewEncoder(w).Encode(map[string]interface{}{
"result": result,
"cached": false,
})
}
// Tool Cache Implementation
func (c *ToolCache) get(key string) (interface{}, bool) {
val, ok := c.store.Load(key)
if !ok {
return nil, false
}
entry := val.(*cacheEntry)
if time.Since(entry.timestamp) > c.ttl {
c.store.Delete(key)
return nil, false
}
return entry.value, true
}
func (c *ToolCache) set(key string, value interface{}) {
c.store.Store(key, &cacheEntry{
value: value,
timestamp: time.Now(),
})
}
type cacheEntry struct {
value interface{}
timestamp time.Time
}
// Initialize OpenTelemetry Tracer
func initTracer() (*tracesdk.TracerProvider, error) {
exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint("http://localhost:14268/api/traces")))
if err != nil {
return nil, err
}
tp := tracesdk.NewTracerProvider(
tracesdk.WithBatcher(exp),
tracesdk.WithResource(resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String("mcp-server"),
)),
)
return tp, nil
}
func main() {
server, err := NewMCPServer()
if err != nil {
log.Fatalf("Failed to create MCP server: %v", err)
}
// Register example tools
server.RegisterTool(MCPTool{
Name: "get_credit_score",
Description: "Retrieve credit score for a user",
InputSchema: map[string]interface{}{
"user_id": map[string]interface{}{
"type": "string",
"description": "User identifier",
},
},
Handler: func(ctx context.Context, params map[string]interface{}) (interface{}, error) {
// Simulate tool execution
userID := params["user_id"].(string)
time.Sleep(100 * time.Millisecond) // Simulate latency
return map[string]interface{}{
"user_id": userID,
"credit_score": 720,
"factors": []string{"payment_history", "credit_utilization"},
}, nil
},
})
// Setup HTTP routes
mux := http.NewServeMux()
mux.Handle("/mcp/tools", otelhttp.NewHandler(server.listTools(), "list_tools"))
mux.Handle("/mcp/invoke", otelhttp.NewHandler(server.handleToolInvocation, "invoke_tool"))
mux.Handle("/metrics", promhttp.Handler())
wrapped := server.middleware(mux)
log.Println("MCP Server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", wrapped))
}
// List available tools
func (s *MCPServer) listTools() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tools := make([]map[string]interface{}, 0, len(s.tools))
for name, tool := range s.tools {
tools = append(tools, map[string]interface{}{
"name": name,
"description": tool.Description,
"input_schema": tool.InputSchema,
})
}
json.NewEncoder(w).Encode(map[string]interface{}{
"tools": tools,
})
})
}
Este server implementa todos los patrones de producción que discutiremos. Nota cómo cada request pasa por el middleware chain antes de ejecutar el tool.
Patrón Circuit Breaker
El circuit breaker es crucial para evitar cascadas de fallos. Cuando un tool comienza a fallar consistentemente, el circuit breaker se abre y retorna errores rápidamente sin intentar ejecutar el tool. Esto permite que el service se recupere sin ser saturado por retries.
Estado Closed
Normal operation. Requests pasan al tool. Si hay fallos, se trackean el ratio de éxito.
Umbral excedido
Si el ratio de fallos excede el umbral (ej. 60% de requests fallan), el circuit breaker transiciona a Open.
Estado Open
Los requests fallan inmediatamente sin alcanzar el tool. El service tiene tiempo para recuperarse.
Estado Half-Open
Después de un timeout, el circuit breaker permite requests limitados para probar si el tool se ha recuperado.
Observabilidad con OpenTelemetry
Tracing es esencial para entender el flow completo de una conversación de agent. Con OpenTelemetry, puedes tracear desde el agent hasta el MCP Gateway, al MCP Server, y a los servicios subyacentes.
# Python example of MCP client with tracing
import time
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.jaeger import JaegerExporter
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
import httpx
# Initialize tracing
trace.set_tracer_provider(TracerProvider())
jaeger_exporter = JaegerExporter(
agent_host_name="localhost",
agent_port=6831,
)
trace.get_tracer_provider().add_span_processor(
BatchSpanProcessor(jaeger_exporter)
)
tracer = trace.get_tracer("mcp-client")
HTTPXClientInstrumentor().instrument()
class MCPClient:
def __init__(self, gateway_url: str, jwt_token: str):
self.gateway_url = gateway_url
self.jwt_token = jwt_token
self.client = httpx.Client()
async def invoke_tool(self, tool_name: str, params: dict) -> dict:
with tracer.start_as_current_span("mcp_tool_invocation") as span:
span.set_attribute("tool.name", tool_name)
span.set_attribute("tool.params", str(params))
start_time = time.time()
try:
response = self.client.post(
f"{self.gateway_url}/mcp/invoke",
json={
"tool_name": tool_name,
"params": params
},
headers={
"Authorization": f"Bearer {self.jwt_token}"
},
timeout=30.0
)
duration = time.time() - start_time
span.set_attribute("http.status_code", response.status_code)
span.set_attribute("mcp.duration_ms", duration * 1000)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
span.record_exception(e)
span.set_attribute("error.type", "http_error")
raise
except httpx.TimeoutException as e:
span.record_exception(e)
span.set_attribute("error.type", "timeout")
raise
except Exception as e:
span.record_exception(e)
span.set_attribute("error.type", "unknown")
raise
# Usage
async def agent_tool_call():
client = MCPClient(
gateway_url="http://mcp-gateway:8080",
jwt_token="your-jwt-token"
)
result = await client.invoke_tool(
tool_name="get_credit_score",
params={"user_id": "user-123"}
)
print(f"Result: {result}")
Este tracing permite que veas el complete flow en Jaeger o cualquier backend de tracing compatible.
Security: JWT y Tool Whitelisting
La autenticación JWT protege tu MCP server de accesos no autorizados. Cada request debe incluir un JWT válido con claims que incluyen:
sub: Agent identifieraud: MCP server identifierscopes: List of tools the agent is allowed to invokeexp: Expiration time
// JWT Claims structure
type MCPClaims struct {
AgentID string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scopes"`
jwt.RegisteredClaims
}
// Tool whitelisting in middleware
func (s *MCPServer) checkToolAccess(claims *MCPClaims, toolName string) bool {
for _, scope := range claims.Scopes {
if scope == "*" || scope == fmt.Sprintf("tool:%s", toolName) {
return true
}
}
return false
}
Escalado de MCP Servers
Para escalar MCP servers horizontalmente, seguimos estos patrones:
-
Stateless design: MCP servers no maintain state entre requests. Todo el state está en el cache o en servicios externos.
-
Load balancing: Usamos HAProxy o NGINX como load balancer con consistent hashing para mantener cache locality.
-
Auto-scaling: Kubernetes HPA scale up/down MCP servers basado en CPU/memory usage y request rate.
-
Blue-green deployments: Zero-downtime deployments de nuevas versiones de MCP servers.
# Kubernetes HPA configuration
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: mcp-server-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mcp-server
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 0
policies:
- type: Percent
value: 100
periodSeconds: 30
- type: Pods
value: 4
periodSeconds: 30
selectPolicy: Max
Lessons Learned
Después de 18 meses running MCP en producción con miles de agents y millones de requests por día, aquí está lo que aprendimos:
Patrones que Funcionan
Timeout + Backoff Exponencial Nunca ejecutes un MCP tool sin timeout. Usa backoff exponencial con jitter para retries. Esto reduce la carga en servicios subyacentes cuando hay fallos.
// Retry with exponential backoff and jitter
func (s *MCPServer) retryWithBackoff(ctx context.Context, operation func() (interface{}, error), maxRetries int) (interface{}, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
result, err := operation()
if err == nil {
return result, nil
}
lastErr = err
if attempt == maxRetraits-1 {
break
}
// Exponential backoff with jitter
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
jitter := time.Duration(rand.Float64() * float64(backoff) * 0.1)
wait := backoff + jitter
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
continue
}
}
return nil, lastErr
}
Bulk Operations Cuando tengas múltiples calls al mismo tool, agrégalos en una sola request. Esto reduce latency y overhead.
Request Validation Valida los parámetros de entrada antes de ejecutar el tool. Esto previene errores downstream y reduce superficie de ataque.
Anti-Patrones a Evitar
Sin Retries
Nunca asumas que un tool funcionará siempre. Network issues, timeouts, y service restarts son normales en producción. Sin retries, un fallo transitorio se convierte en un incidente.
Sync Blocking Calls Evita blocking calls en el main thread del agent. Usa async/await o goroutines para concurrent tool invocations.
Monolithic MCP Servers No pongas todos tus tools en un solo MCP server. Separa por dominio (financial, database, api) para escalar independientemente y reducir blast radius de fallos.
Hard-coded Endpoints Usa service discovery o config management para endpoints. Never hardcode URLs en código.
Tradeoffs: MCP vs Integraciones Directas
| ✓Aspecto | MCP en Producción | Integración Directa |
|---|---|---|
| Setup inicial | Más complejo (gateway, middleware) | Más simple (direct API call) |
| Flexibilidad | Alta (plug-and-play tools) | Baja (hard-coded integrations) |
| Observabilidad | Excelente (unified tracing) | Variable (por implementar) |
| Overhead | Bajo-medio (gateway + proxies) | Cero (directo) |
| Escalado | Fácil (stateless servers) | Depende (service específico) |
| Testing | Fácil (mock MCP tools) | Más complejo (mock services) |
| Versioning | Automático (MCP schema) | Manual (API versioning) |
| Use case ideal | Sistemas multi-agent heterogéneos | Servicios monolíticos simples |
Conclusion
MCP en producción no es plug-and-play como en desarrollo. Requiere una capa de infraestructura que provea seguridad, observabilidad, resiliencia, y escalado. Los patrones que cubrimos en este post — middleware chains, circuit breakers, OpenTelemetry tracing, JWT auth, y horizontal scaling — son esenciales para systems de agents a escala real.
Pero la inversión vale la pena. MCP te permite:
- Desacoplamento: Agents y tools evolucionan independientemente
- Reusabilidad: Tools pueden ser compartidos entre múltiples agents
- Observabilidad: Unified tracing del complete flow
- Flexibilidad: Easy addition/removal de tools sin cambios en agents
¿Cuándo deberías usar MCP en producción?
- Usa MCP cuando: Tienes múltiples agents que necesitan los mismos tools, tu sistema es heterogéneo (lenguajes, frameworks), o necesitas flexibilidad para agregar/remover tools dinámicamente.
- Usa integraciones directas cuando: Tienes un single monolithic agent, tu stack es homogéneo, o el overhead de MCP no justifica los beneficios.
Para deep dive en arquitecturas multi-agent, check Orchestrator-Worker Multi-Agente en Producción. Para deployment y scaling strategies, lee Agentes IA en Kubernetes: Deploy y Escalado. Y si quieres entender mejor el contrato MCP, revisa MCP como Contrato entre Agents.
En el próximo post de esta serie, cubriremos Testing de Agents IA — estrategias para testing determinístico, evaluation de outputs, y maintaining test suites para sistemas que son inherentemente non-deterministic.
Hasta la próxima, happy coding en producción.
¿Qué patrones has usado para MCP en producción? ¿Cuál fue tu mayor challenge? Déjame un comment y discutamos.