Implementing connection health checks and monitoring

Kashish Hora

Kashish Hora

Co-founder of AgentCat

Try out AgentCat

The Quick Answer

Health-check MCP servers at the transport level—an HTTP health endpoint for Streamable HTTP deployments, a process check for stdio—and expose richer status through a custom monitoring tool. Create a health endpoint that tracks connection status, uptime, and resource usage:

server.setRequestHandler(CallToolRequestSchema, async (request) => {
  if (request.params.name === "health_check") {
    const status = {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime: Math.floor((Date.now() - startTime) / 1000),
      connections: activeConnections
    };
    return {
      content: [{ type: "text", text: JSON.stringify(status) }],
      structuredContent: status
    };
  }
});

This enables proactive monitoring and automatic recovery when connections fail.

Prerequisites

  • Node.js 18+ or Python 3.10+ installed
  • MCP SDK for your chosen language (@modelcontextprotocol/sdk for TypeScript, mcp<2 for Python to match the v1 API used below)
  • Basic understanding of MCP server architecture and JSON-RPC protocol
  • Development environment with async/await support

Implementation Strategy

MCP servers require robust health monitoring to ensure reliable operation in production environments. Connection failures, timeouts, and resource exhaustion are common issues that can disrupt service availability. A comprehensive health check system helps detect problems early and enables automatic recovery.

Health monitoring for MCP servers happens at two levels. Liveness is a transport concern: an HTTP health endpoint your process serves alongside the MCP endpoint for Streamable HTTP deployments, the subprocess exit status and stderr for stdio, and SSE comment keep-alives on any long-lived response stream. Application health is yours to define: a custom health check tool exposed over MCP gives clients and operators detailed status. Combining the two creates a resilient monitoring solution that tracks server health, connection stability, and resource utilization.

Anchor your monitoring on the transport layer, because the 2026-07-28 spec revision removed ping from the core protocol—there is no protocol-level liveness request to call anymore. Servers implementing earlier revisions still answer ping, and it still works against them, but don't build new monitoring on it. The same revision also made MCP stateless, which simplifies this work: there is no session to keep warm, so "is the connection healthy" collapses to "can the process serve the next request".

Basic Health Check Implementation

Start with a simple health check tool that exposes server status through the MCP protocol. This approach integrates seamlessly with existing MCP clients and allows monitoring through the same connection channel used for regular operations.

The samples below use the v1 SDK line (@modelcontextprotocol/sdk for TypeScript, mcp 1.x for Python). That's still accurate for TypeScript—@modelcontextprotocol/sdk is still published and still the latest tag on npm—but not for Python: PyPI's latest tag for mcp is now 2.0.0, shipped the same day as the spec revision itself. Pin mcp<2 (currently 1.29.0) if you want the decorator-based API shown below. The 2.0 SDKs rename and re-package things—TypeScript splits the monolith into @modelcontextprotocol/server and @modelcontextprotocol/client, and Python's lowlevel Server moves its handlers from decorators to constructor parameters—but a health check tool has the same shape on either line. Worth knowing either way: most shipping clients still speak pre-2026-07-28 revisions today, so a production server should serve both eras. Most Tier-1 SDKs (TypeScript, Python, Go, and C#) negotiate era automatically, but check your SDK's own opt-in before assuming zero-config dual-era support—the Go SDK, for instance, only accepts 2026-07-28 requests once you set StreamableHTTPOptions.Stateless = true; without it, it negotiates every connection down to 2025-11-25.

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { 
  CallToolRequestSchema, 
  ListToolsRequestSchema 
} from "@modelcontextprotocol/sdk/types.js";

const startTime = Date.now();
let requestCount = 0;

const server = new Server({
  name: "monitored-server",
  version: "1.0.0",
}, {
  capabilities: { tools: {} }
});

// Register health check tool
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [{
      name: "health_check",
      description: "Get server health status",
      inputSchema: {
        type: "object",
        properties: {},
        required: []
      }
    }]
  };
});

// Handle health check requests
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  requestCount++;
  
  if (request.params.name === "health_check") {
    const memUsage = process.memoryUsage();
    const status = {
      status: 'healthy',
      timestamp: new Date().toISOString(),
      uptime_seconds: Math.floor((Date.now() - startTime) / 1000),
      request_count: requestCount,
      memory_mb: Math.round(memUsage.heapUsed / 1024 / 1024)
    };
    return {
      content: [{ type: "text", text: JSON.stringify(status) }],
      structuredContent: status
    };
  }
});

Python servers implement similar functionality using the MCP SDK. The async architecture allows non-blocking health checks that don't interfere with normal operations:

from mcp.server import Server
from mcp.types import Tool
import time
import psutil

app = Server("monitored-server")
start_time = time.time()
request_count = 0

@app.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="health_check",
            description="Get server health status",
            inputSchema={"type": "object", "properties": {}}
        )
    ]

@app.call_tool()
async def call_tool(name: str, arguments: dict) -> dict:
    global request_count
    request_count += 1
    
    if name == "health_check":
        process = psutil.Process()
        return {
            "status": "healthy",
            "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
            "uptime_seconds": int(time.time() - start_time),
            "request_count": request_count,
            "memory_mb": round(process.memory_info().rss / 1024 / 1024)
        }

Connection Monitoring

Tracking active connections provides visibility into server load and helps identify connection leaks. MCP servers should monitor both transport-level connections and logical client sessions to understand usage patterns and detect anomalies.

interface ConnectionInfo {
  id: string;
  connectedAt: Date;
  lastActivity: Date;
  requestCount: number;
  clientInfo?: any;
}

class ConnectionMonitor {
  private connections = new Map<string, ConnectionInfo>();
  private connectionIdCounter = 0;
  
  addConnection(transport: any): string {
    const id = `conn-${++this.connectionIdCounter}`;
    this.connections.set(id, {
      id,
      connectedAt: new Date(),
      lastActivity: new Date(),
      requestCount: 0
    });
    return id;
  }
  
  updateActivity(id: string): void {
    const conn = this.connections.get(id);
    if (conn) {
      conn.lastActivity = new Date();
      conn.requestCount++;
    }
  }
  
  removeConnection(id: string): void {
    this.connections.delete(id);
  }
  
  getMetrics(): object {
    const now = Date.now();
    const active = Array.from(this.connections.values());
    
    return {
      total_connections: active.length,
      oldest_connection_seconds: active.length > 0 
        ? Math.floor((now - Math.min(...active.map(c => c.connectedAt.getTime()))) / 1000)
        : 0,
      total_requests: active.reduce((sum, c) => sum + c.requestCount, 0),
      idle_connections: active.filter(c => 
        now - c.lastActivity.getTime() > 60000
      ).length
    };
  }
}

Implement connection monitoring in your server lifecycle hooks. Track connections from establishment through closure, updating metrics on each request:

const monitor = new ConnectionMonitor();

// Track new connections
transport.onconnect = () => {
  const connId = monitor.addConnection(transport);
  transport.connectionId = connId;
};

// Update on each request
server.setRequestHandler(CallToolRequestSchema, async (request, { transport }) => {
  if (transport.connectionId) {
    monitor.updateActivity(transport.connectionId);
  }
  // ... handle request
});

// Clean up on disconnect
transport.onclose = () => {
  if (transport.connectionId) {
    monitor.removeConnection(transport.connectionId);
  }
};

Timeout and Recovery Patterns

MCP connections can fail due to network issues, client crashes, or server overload. Implementing proper timeout handling and recovery mechanisms ensures your server remains responsive and can recover from transient failures.

The MCP specification recommends establishing timeouts for all requests. What happens on timeout is transport-specific: on stdio there's no per-request stream to close, so the sender sends a notifications/cancelled notification referencing the request ID; on Streamable HTTP, the sender just closes the response stream, and no cancellation notification is sent or expected. The TimeoutManager below targets a stdio server and cleans up local state either way:

class TimeoutManager {
  private pendingRequests = new Map<string, NodeJS.Timeout>();
  private defaultTimeout = 30000; // 30 seconds
  
  trackRequest(requestId: string, timeoutMs?: number): void {
    const timeout = setTimeout(() => {
      this.handleTimeout(requestId);
    }, timeoutMs || this.defaultTimeout);
    
    this.pendingRequests.set(requestId, timeout);
  }
  
  completeRequest(requestId: string): void {
    const timeout = this.pendingRequests.get(requestId);
    if (timeout) {
      clearTimeout(timeout);
      this.pendingRequests.delete(requestId);
    }
  }
  
  private async handleTimeout(requestId: string): Promise<void> {
    console.error(`Request ${requestId} timed out`);
    
    // stdio only: send notifications/cancelled referencing the request ID.
    // On Streamable HTTP, closing the response stream is the cancellation
    // signal instead — no notification is sent.
    await server.notification({
      method: "notifications/cancelled",
      params: { requestId, reason: "Request timed out" }
    });
    
    // Clean up resources
    this.pendingRequests.delete(requestId);
  }
}

Python implementation using asyncio for timeout management:

import asyncio
from typing import Dict, Optional

class TimeoutManager:
    def __init__(self, default_timeout: float = 30.0):
        self.default_timeout = default_timeout
        self.pending_tasks: Dict[str, asyncio.Task] = {}
    
    async def with_timeout(self, request_id: str, coro, timeout: Optional[float] = None):
        """Execute coroutine with timeout"""
        timeout_value = timeout or self.default_timeout
        
        try:
            task = asyncio.create_task(coro)
            self.pending_tasks[request_id] = task
            
            result = await asyncio.wait_for(task, timeout=timeout_value)
            return result
            
        except asyncio.TimeoutError:
            # Send cancellation notification
            await self.send_cancel_notification(request_id)
            raise
            
        finally:
            self.pending_tasks.pop(request_id, None)
    
    async def send_cancel_notification(self, request_id: str):
        """stdio only: send notifications/cancelled. On Streamable HTTP,
        closing the response stream is the cancellation signal instead."""
        await server.send_notification(
            method="notifications/cancelled",
            params={"requestId": request_id, "reason": "Request timed out"}
        )

Common Issues

Error: Connection closed unexpectedly

Long-lived SSE response streams may close after periods of inactivity. The root cause is often intermediate proxies or load balancers that terminate idle connections. The fix the spec recommends is an SSE comment keep-alive: any line beginning with a colon carries no event data and clients must ignore it, but it keeps bytes flowing. This matters most on a subscriptions/listen stream, which can sit quiet for a long time between change notifications:

// `res` is the HTTP response carrying the SSE stream
// A line starting with ':' is an SSE comment: no event data, ignored by clients
const keepAlive = setInterval(() => {
  res.write(':\r\n');
}, 30000); // Every 30 seconds

res.on('close', () => clearInterval(keepAlive));

Send the X-Accel-Buffering: no header when you open the stream so reverse proxies such as nginx deliver events immediately instead of buffering them, and raise the idle timeout on any proxy sitting in front of the server.

If a stream does break, it is gone: the 2026-07-28 revision removed SSE resumability, so there is no Last-Event-ID to reconnect with and the client must re-issue the request with a new request ID. Servers on earlier revisions supported Last-Event-ID resumption and answered a protocol-level ping request instead; both still work when you're talking to one.

Error: Request timeout after 30 seconds

Long-running operations may exceed default timeout values. MCP servers should handle this gracefully by implementing progress notifications and chunked responses. For operations that legitimately take longer:

// Report progress to reset client timeout
async function longOperation(request) {
  // Only send progress notifications if the client opted in with a
  // progressToken on the original request — the spec requires that.
  const progressToken = request.params?._meta?.progressToken;
  const steps = 10;
  for (let i = 0; i < steps; i++) {
    if (progressToken !== undefined) {
      await server.notification({
        method: "notifications/progress",
        params: {
          progressToken,
          progress: i,
          total: steps
        }
      });
    }
    
    // Do work...
    await processStep(i);
  }
}

Configure appropriate timeouts based on your use case and implement progress reporting for long operations.

Error: Too many connections

Resource exhaustion occurs when servers accept unlimited connections. Implement connection limits and queueing to prevent overload:

class ConnectionLimiter {
  private maxConnections = 100;
  private queue: Array<() => void> = [];
  
  async acquireSlot(): Promise<void> {
    if (this.connections.size >= this.maxConnections) {
      // Queue the connection
      return new Promise(resolve => {
        this.queue.push(resolve);
      });
    }
  }
  
  releaseSlot(): void {
    const next = this.queue.shift();
    if (next) next();
  }
}

Set reasonable limits based on your server capacity and implement graceful degradation when limits are reached.

Examples

Production Health Monitoring System

This example demonstrates a complete health monitoring implementation for a production MCP server. It includes detailed metrics collection, alerting thresholds, and integration with monitoring systems:

import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { EventEmitter } from 'events';

class HealthMonitor extends EventEmitter {
  private metrics = {
    uptime: 0,
    requests: { total: 0, failed: 0, duration: [] },
    connections: { active: 0, total: 0 },
    resources: { cpu: 0, memory: 0 },
    errors: new Map<string, number>()
  };
  
  private thresholds = {
    errorRate: 0.05,      // 5% error rate
    responseTime: 1000,   // 1 second
    memory: 500,          // 500MB
    connections: 100      // Max connections
  };
  
  recordRequest(duration: number, success: boolean): void {
    this.metrics.requests.total++;
    if (!success) this.metrics.requests.failed++;
    
    this.metrics.requests.duration.push(duration);
    if (this.metrics.requests.duration.length > 100) {
      this.metrics.requests.duration.shift();
    }
    
    this.checkThresholds();
  }
  
  private checkThresholds(): void {
    const errorRate = this.metrics.requests.failed / this.metrics.requests.total;
    if (errorRate > this.thresholds.errorRate) {
      this.emit('alert', { 
        type: 'high_error_rate', 
        value: errorRate,
        threshold: this.thresholds.errorRate 
      });
    }
    
    const avgResponse = this.metrics.requests.duration.reduce((a, b) => a + b, 0) 
      / this.metrics.requests.duration.length;
    if (avgResponse > this.thresholds.responseTime) {
      this.emit('alert', { 
        type: 'slow_response', 
        value: avgResponse,
        threshold: this.thresholds.responseTime 
      });
    }
  }
  
  getHealthStatus(): object {
    const errorRate = this.metrics.requests.total > 0 
      ? this.metrics.requests.failed / this.metrics.requests.total 
      : 0;
      
    const avgResponse = this.metrics.requests.duration.length > 0
      ? this.metrics.requests.duration.reduce((a, b) => a + b, 0) / this.metrics.requests.duration.length
      : 0;
    
    const status = errorRate < 0.01 && avgResponse < 500 
      ? 'healthy' 
      : errorRate < 0.05 && avgResponse < 1000 
        ? 'degraded' 
        : 'unhealthy';
    
    return {
      status,
      metrics: {
        uptime_seconds: this.metrics.uptime,
        error_rate: errorRate,
        avg_response_ms: Math.round(avgResponse),
        active_connections: this.metrics.connections.active,
        memory_mb: this.metrics.resources.memory
      },
      thresholds: this.thresholds
    };
  }
}

// ... Integration with MCP server ...

Production deployments benefit from comprehensive monitoring that tracks multiple health indicators. This implementation provides real-time alerts when thresholds are exceeded, enabling rapid response to issues. The health status categorization (healthy/degraded/unhealthy) helps operators quickly assess system state.

Auto-Recovery Implementation

Automatic recovery mechanisms help MCP servers self-heal from transient failures. This example shows how to implement connection retry logic with exponential backoff:

import asyncio
from typing import Optional
import random

class ResilientMCPClient:
    def __init__(self, server_params):
        self.server_params = server_params
        self.session: Optional[ClientSession] = None
        self.reconnect_attempts = 0
        self.max_reconnect_attempts = 5
        self.base_delay = 1.0
        
    async def connect(self):
        """Connect with automatic retry on failure"""
        while self.reconnect_attempts < self.max_reconnect_attempts:
            try:
                self.session = await create_client_session(self.server_params)
                self.reconnect_attempts = 0
                
                # Start health monitoring
                asyncio.create_task(self.monitor_health())
                return
                
            except Exception as e:
                self.reconnect_attempts += 1
                delay = self.calculate_backoff()
                
                print(f"Connection failed (attempt {self.reconnect_attempts}): {e}")
                print(f"Retrying in {delay:.1f} seconds...")
                
                await asyncio.sleep(delay)
        
        raise Exception("Max reconnection attempts exceeded")
    
    def calculate_backoff(self) -> float:
        """Calculate exponential backoff with jitter"""
        delay = self.base_delay * (2 ** (self.reconnect_attempts - 1))
        jitter = random.uniform(0, delay * 0.1)
        return min(delay + jitter, 60.0)  # Cap at 60 seconds
    
    async def monitor_health(self):
        """Continuously monitor connection health"""
        consecutive_failures = 0
        
        while self.session:
            try:
                # Perform health check
                result = await self.session.call_tool(
                    name="health_check", 
                    arguments={}
                )
                
                if result.get('status') != 'healthy':
                    consecutive_failures += 1
                else:
                    consecutive_failures = 0
                
                # Trigger reconnect if multiple failures
                if consecutive_failures >= 3:
                    print("Multiple health check failures, reconnecting...")
                    await self.reconnect()
                    
            except Exception as e:
                print(f"Health check error: {e}")
                await self.reconnect()
                
            await asyncio.sleep(30)
    
    async def reconnect(self):
        """Handle reconnection"""
        if self.session:
            await self.session.close()
            self.session = None
        
        await self.connect()

Production systems require resilient connection handling that can recover from network interruptions, server restarts, and transient failures. The exponential backoff strategy prevents overwhelming the server during recovery while jitter helps avoid thundering herd problems when multiple clients reconnect simultaneously.

Distributed Health Aggregation

Large deployments often run multiple MCP server instances. This example shows how to aggregate health data across a server fleet:

interface ServerHealth {
  id: string;
  endpoint: string;
  status: 'healthy' | 'degraded' | 'unhealthy';
  lastCheck: Date;
  metrics: any;
}

class FleetHealthMonitor {
  private servers: Map<string, ServerHealth> = new Map();
  private checkInterval = 10000; // 10 seconds
  
  addServer(id: string, endpoint: string): void {
    this.servers.set(id, {
      id,
      endpoint,
      status: 'healthy',
      lastCheck: new Date(),
      metrics: {}
    });
    
    // Start monitoring
    this.monitorServer(id);
  }
  
  private async monitorServer(id: string): Promise<void> {
    const server = this.servers.get(id);
    if (!server) return;
    
    try {
      // Create temporary connection for health check
      const client = await createClient({ endpoint: server.endpoint });
      const health = await client.call('health_check', {});
      
      server.status = health.status;
      server.lastCheck = new Date();
      server.metrics = health.metrics;
      
      await client.close();
      
    } catch (error) {
      server.status = 'unhealthy';
      server.lastCheck = new Date();
      console.error(`Health check failed for ${id}:`, error);
    }
    
    // Schedule next check
    setTimeout(() => this.monitorServer(id), this.checkInterval);
  }
  
  getFleetHealth(): object {
    const servers = Array.from(this.servers.values());
    const healthy = servers.filter(s => s.status === 'healthy').length;
    const degraded = servers.filter(s => s.status === 'degraded').length;
    const unhealthy = servers.filter(s => s.status === 'unhealthy').length;
    
    return {
      summary: {
        total: servers.length,
        healthy,
        degraded,
        unhealthy,
        health_percentage: (healthy / servers.length) * 100
      },
      servers: servers.map(s => ({
        id: s.id,
        status: s.status,
        last_check: s.lastCheck.toISOString(),
        response_time: s.metrics.avg_response_ms
      }))
    };
  }
}

// Usage
const fleet = new FleetHealthMonitor();
fleet.addServer('server-1', 'http://mcp1.internal:3000');
fleet.addServer('server-2', 'http://mcp2.internal:3000');

// Expose fleet health via HTTP endpoint
app.get('/health/fleet', (req, res) => {
  res.json(fleet.getFleetHealth());
});

Distributed monitoring provides visibility across your entire MCP infrastructure. By aggregating health data from multiple servers, operators can identify patterns, detect partial outages, and make informed decisions about traffic routing and capacity planning.