MCP Server Health Checks: Keeping Tool Servers Reliable
Why your MCP server needs a pulse check
Somewhere in production right now, an AI agent is calling a tool through an MCP server, and the server is quietly dying. Not crashing loudly with a stack trace someone will notice in Slack — dying slowly, the way infrastructure usually does. A database connection pool exhausts itself. A downstream API starts timing out. Memory creeps up until garbage collection pauses stretch long enough that requests queue instead of complete. The agent on the other end doesn't get a clean error. It gets silence, or a malformed response, or a tool call that hangs until the client gives up.
This is the part of building with the Model Context Protocol that doesn't show up in the quickstart docs. Everyone writes their first MCP server, wires up a few tools, connects it to Claude or another client, and it works beautifully in a demo. Then it goes into a real workflow — an agent running unattended, chaining ten tool calls in a row, expected to work at 3 AM without a human watching — and the gaps in reliability become obvious fast. A tool server that can't tell you when it's unhealthy is a tool server that fails silently, and silent failures in agentic systems are worse than loud ones because the agent will often just retry, hallucinate a workaround, or return a confidently wrong answer.
Health checks are the unglamorous fix. They're not a new idea — every backend engineer has written a /healthz endpoint for a REST API — but MCP servers have a different failure surface than typical web services, and a health check strategy that's copy-pasted from a load balancer config usually misses the failure modes that actually matter for tool-calling infrastructure. This article walks through what MCP server health checks should actually cover, how to implement them across the transport types MCP supports, and how to wire them into monitoring so you find out about problems before your agent does.
What makes MCP servers different from typical services
A standard web service health check answers one question: is the process up and can it serve a request. For an MCP server, "up" is necessary but nowhere near sufficient, because MCP servers sit in the middle of a chain that an AI model is actively reasoning about.
Consider what an MCP server typically does. It exposes tools, resources, and prompts over a JSON-RPC interface. Behind that interface, it usually holds open connections to something else — a database, a third-party API, a file system, a message queue, another internal service. The MCP layer itself might be perfectly healthy while everything behind it is on fire. A health check that only pings the process misses this entirely.
There's also the transport question. MCP servers run over stdio (a local subprocess talking over standard input and output), over HTTP with Server-Sent Events, or over streamable HTTP. Each transport has its own way of dying. A stdio server can hang because the subprocess is alive but its event loop is blocked. An SSE-based server can have its connection silently drop without either side sending a close frame, leaving the client believing a session is live when it isn't. These are failure modes a generic uptime check doesn't catch.
Finally, MCP servers are stateful in ways plain REST endpoints usually aren't. A client establishes a session, the server may hold context across multiple tool calls, and if that session state gets corrupted — a stale cache, an interrupted initialization handshake, a tool registry that didn't fully load — the server can respond to requests while returning wrong or incomplete tool definitions. That's a health problem no amount of "return 200 OK" will surface.
The three layers of an MCP health check
It helps to think about MCP server health in three layers, each answering a different question, each requiring a different check.
Layer one: process liveness. Is the server process running at all? For stdio-based servers this is the simplest layer — you're checking whether the subprocess exists and hasn't crashed. For HTTP-based servers, it's a plain TCP or HTTP ping to confirm the process is accepting connections. This layer catches the obvious case: the server crashed, was OOM-killed, or never started.
Layer two: protocol readiness. Is the server able to respond correctly to MCP protocol messages? This means confirming that the initialization handshake completes, that a tools/list call returns the expected tool definitions, and that the JSON-RPC framing is correct. A process can be alive at layer one and still fail layer two — for example, if the server started but its tool registration logic threw an exception that got swallowed, leaving it running with zero tools available.
Layer three: dependency health. Can the server actually do its job? This is the layer most teams skip, and it's the one that matters most in practice. If your MCP server wraps a Postgres database, a health check that doesn't attempt a lightweight query against that database is not telling you anything useful about whether tool calls will succeed. If your server proxies calls to a third-party API, you need to know when that API is degraded, rate-limiting you, or returning errors — ideally before an agent's tool call fails mid-task.
Most teams implement layer one, some implement layer two, and very few implement layer three properly. That's exactly backwards from an impact perspective — layer three failures are the ones that actually break agent workflows.
Building a health check endpoint for an HTTP-based MCP server
If your MCP server runs over HTTP (the streamable HTTP transport or SSE), the natural place to add a health check is a dedicated endpoint outside the MCP JSON-RPC path itself. This keeps health checks cheap to call and lets you use standard tooling — load balancers, uptime monitors, Kubernetes liveness probes — without them needing to speak MCP.
Here's a pattern for a Node.js MCP server that layers the three checks described above:
import express from "express";
import { Pool } from "pg";
const app = express();
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
let toolRegistryReady = false;
let lastToolCount = 0;
// Set this flag once your MCP server has finished registering tools
export function markToolsReady(count) {
toolRegistryReady = true;
lastToolCount = count;
}
app.get("/healthz", async (req, res) => {
const checks = {
process: "ok",
toolRegistry: toolRegistryReady ? "ok" : "not_ready",
database: "unknown",
};
try {
const start = Date.now();
await pool.query("SELECT 1");
const latencyMs = Date.now() - start;
checks.database = latencyMs < 500 ? "ok" : "degraded";
checks.databaseLatencyMs = latencyMs;
} catch (err) {
checks.database = "down";
checks.databaseError = err.message;
}
const healthy =
checks.process === "ok" &&
checks.toolRegistry === "ok" &&
checks.database !== "down";
res.status(healthy ? 200 : 503).json({
status: healthy ? "healthy" : "unhealthy",
toolCount: lastToolCount,
checks,
timestamp: new Date().toISOString(),
});
});
app.listen(3000, () => console.log("MCP server health endpoint on :3000"));Notice a few things about this design. It returns a real HTTP status code (200 or 503) so that anything checking it — a load balancer, an orchestrator, a monitoring agent — can act on it without parsing JSON first. It also returns structured detail in the body, so a human or an alerting system can see *which* layer failed rather than just "unhealthy." And it distinguishes "degraded" from "down" for the database check, because a database that's slow but responding is a very different problem from one that's unreachable — you want to know about creeping latency before it becomes an outage.
The tool registry check is worth calling out specifically. It's tempting to skip it because "the server started, so the tools must be registered." In practice, tool registration is often asynchronous — pulling schema from a config file, querying a database for dynamic tool definitions, or negotiating capabilities with an upstream service — and any of those steps can fail after the HTTP server has already started listening. Without an explicit readiness flag, you'll serve 200 OK from a server with an empty tool list, and the first sign of trouble will be an agent reporting "no tools available" mid-session.
Health checks for stdio-based MCP servers
Stdio transport is common for local MCP servers — the ones spawned as a subprocess by a desktop client. These don't have an HTTP port to hit, so the health check pattern looks different. You can't poll an endpoint from outside; instead, you build health awareness into the protocol exchange itself and into how the parent process supervises the child.
The MCP specification includes a ping request in the base protocol specifically for this purpose — it's a no-op request that any healthy server should respond to promptly. A client or supervisor can send periodic pings and treat a timeout as a liveness failure:
import asyncio
import json
async def ping_server(reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
timeout_seconds: float = 5.0) -> bool:
request = {
"jsonrpc": "2.0",
"id": "healthcheck-ping",
"method": "ping",
}
writer.write((json.dumps(request) + "\n").encode())
await writer.drain()
try:
line = await asyncio.wait_for(reader.readline(), timeout=timeout_seconds)
except asyncio.TimeoutError:
return False
if not line:
return False
response = json.loads(line)
return response.get("id") == "healthcheck-ping" and "error" not in response
async def supervise(process, interval_seconds: int = 30):
consecutive_failures = 0
while True:
await asyncio.sleep(interval_seconds)
healthy = await ping_server(process.stdout, process.stdin)
if not healthy:
consecutive_failures += 1
print(f"MCP server ping failed ({consecutive_failures} in a row)")
if consecutive_failures >= 3:
print("Restarting unresponsive MCP server subprocess")
process.terminate()
break
else:
consecutive_failures = 0The key design decision here is the "consecutive failures" threshold rather than restarting on the first missed ping. Subprocess pipes can have transient hiccups — a garbage collection pause, a momentary scheduling delay — and restarting an MCP server mid-task because of one slow response is more disruptive than tolerating a brief blip. Three consecutive failures at a reasonable interval gives you a signal that's actually a stuck process, not noise.
For stdio servers you're building rather than just supervising, make sure your ping handler responds even while other work is in flight. A common bug is implementing tool handlers as blocking calls on a single-threaded event loop, so a slow tool call (say, one waiting on a third-party API) also blocks the ping response, making the server look unhealthy exactly when it's just busy. Health check responsiveness under load is itself a design requirement, not an afterthought.
Dependency checks: the layer everyone underinvests in
Since most MCP servers are thin wrappers around something else — a SaaS API, a database, an internal microservice — the most valuable health signal is almost never about the MCP layer itself. It's about whether the thing behind it is working.
A practical approach is to define a dependency check for each external system your tools rely on, and to run those checks on a schedule separate from the request path — not on every single tool call, since that adds latency to every request, but frequently enough (every 15 to 30 seconds is typical) that a cached health status is never far from reality.
import time
import httpx
class DependencyMonitor:
def __init__(self, check_interval_seconds: int = 20):
self.check_interval = check_interval_seconds
self.status = {}
self.last_checked = {}
async def check_rest_api(self, name: str, url: str, timeout: float = 3.0):
now = time.time()
if now - self.last_checked.get(name, 0) < self.check_interval:
return self.status.get(name, "unknown")
try:
async with httpx.AsyncClient(timeout=timeout) as client:
start = time.time()
resp = await client.get(url)
latency = time.time() - start
if resp.status_code >= 500:
self.status[name] = "down"
elif latency > 1.5:
self.status[name] = "degraded"
else:
self.status[name] = "ok"
except (httpx.TimeoutException, httpx.ConnectError):
self.status[name] = "down"
self.last_checked[name] = now
return self.status[name]
def snapshot(self):
return dict(self.status)Wire this into your health endpoint's response, and now the health check tells you something an agent actually cares about: not just "the server responded" but "tool X, which depends on the billing API, is currently degraded, expect elevated latency or failures." Some teams go a step further and expose this per-tool status through the MCP resources interface, so a well-behaved client or agent can check dependency health before attempting a tool call that's likely to fail, and either wait, retry with backoff, or tell the user directly instead of burning a tool call on a doomed request.
This is also where you catch the class of failure that hurts the most in agentic workflows: partial degradation. A server that's fully down produces an obvious error. A server where one tool out of ten is silently failing because its upstream dependency is flaky produces intermittent, hard-to-reproduce agent failures that look like model reasoning errors until someone finally checks the logs. Per-dependency health checks turn that into a visible, monitored signal instead of a mystery.
Timeouts, retries, and circuit breakers around tool calls
Health checks tell you something is wrong. What you do about it in the moment — while a tool call is actually in flight — is a related but separate concern, and it's worth getting right because a hung tool call is often worse for an agent than a fast, clean failure.
Every tool handler should have an explicit timeout, shorter than whatever timeout the MCP client or the agent orchestration layer is using. If your agent framework gives up on a tool call after 30 seconds, your tool handler should time out at 20 seconds and return a clear error, not let the client's timeout be the only thing standing between a hung request and an infinite wait.
import asyncio
async def call_tool_with_timeout(tool_fn, args, timeout_seconds: float = 20.0):
try:
return await asyncio.wait_for(tool_fn(**args), timeout=timeout_seconds)
except asyncio.TimeoutError:
return {
"isError": True,
"content": [{
"type": "text",
"text": f"Tool call timed out after {timeout_seconds}s. "
f"The upstream service may be degraded — check server health.",
}],
}Layer a simple circuit breaker on top of that for dependencies that fail repeatedly. If a downstream API has failed the last five calls in a row, don't send a sixth — fail fast with a clear message, and let your dependency monitor's health check reflect "down" so the pattern is visible. This does two things: it stops piling load onto a struggling downstream service, and it gives the agent a fast, unambiguous signal instead of another slow timeout. Agents handle "this tool is currently unavailable" far better than they handle a 20-second hang followed by a generic error.
Wiring health checks into monitoring and alerting
A health check that nobody looks at is not a health check, it's a formality. Once the endpoint or ping mechanism exists, connect it to whatever your team already uses for alerting rather than inventing a new dashboard nobody opens.
A few practical patterns that work well for MCP servers specifically:
- Poll the
/healthzendpoint (or run the stdio ping loop) on a schedule and alert on state transitions, not on every failed check — alert when status goes from healthy to unhealthy, and again when it recovers, rather than paging on every 503. - Track tool-call error rates per tool, not just per server. A server-level health check can look green while one specific tool has a 40% failure rate because of a downstream quirk that only affects that one code path.
- Log every health check result with a timestamp, even the healthy ones, so you can reconstruct a timeline after an incident instead of only having data from the moment things broke.
- Set a shorter alert threshold for dependency checks than for process liveness — a crashed process is unambiguous and should page immediately, while a single slow database response might just be a blip worth a five-minute confirmation window before anyone gets paged.
- If your MCP server runs in a container orchestrator, map layer one (process liveness) to the liveness probe and layer two plus three (protocol readiness and dependency health) to the readiness probe, so a degraded-but-alive server gets pulled from rotation without being restarted unnecessarily.
The goal is a monitoring setup where the first sign of trouble is a Slack alert or a dashboard change, not a user or an agent reporting that something felt wrong.
A minimal checklist before you ship an MCP server
If you're building or hardening an MCP server for anything beyond a local demo, here's a compact list to work through:
- Confirm the server responds to
ping(stdio) or has a/healthzendpoint (HTTP) that returns within a few hundred milliseconds under normal load. - Add an explicit tool-registry readiness flag rather than assuming "process started" means "tools are registered."
- Add a dependency check for every external system a tool touches — database, API, queue, file system — cached on a short interval, not run inline on every request.
- Set explicit timeouts on every tool handler, shorter than the client's timeout.
- Add a circuit breaker for dependencies that fail repeatedly, so you fail fast instead of piling up hung requests.
- Distinguish "down" from "degraded" in your health output — latency creep is an earlier and often more actionable signal than a hard outage.
- Wire the health signal into real alerting, and alert on state transitions rather than every check.
- Log health check history so incidents are reconstructable after the fact.
None of this is exotic engineering. It's the same discipline that's been applied to web services for years, adapted to the specific ways MCP servers fail: stateful sessions, protocol handshakes, subprocess transports, and the fact that an AI agent — not a human refreshing a dashboard — is usually the first one to notice when something's off.
Closing thoughts
MCP is still young enough that a lot of teams are shipping servers the way people shipped early REST APIs: get the happy path working, ship it, and patch the reliability story in later once something breaks in production. That's a reasonable way to get started, but tool-calling infrastructure for autonomous agents has less room for silent failure than a typical API does, because there's no human in the loop to notice a weird response and shrug it off. An agent will act on bad data unless the server tells it, clearly and quickly, that something is wrong.
Health checks are the cheapest insurance you can buy against that. A few hours spent on a layered health endpoint, a sensible ping loop, and real dependency monitoring will save you the far more expensive debugging session where you're trying to figure out, after the fact, why an agent made a strange decision three tool calls into a workflow that nobody was watching.
If you want to go deeper on the design decisions behind production-grade MCP servers — transport selection, session management, authentication, and the operational patterns that go beyond health checks — that's exactly what we cover in Building & Integrating MCP Servers, part of the hands-on AI-engineering curriculum at teachyou.ai.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.