Monitoring a Remote Streamable HTTP MCP Server: What Actually Needs Watching
Most people monitor an HTTP service by pinging it. For an MCP server exposed over Streamable HTTP, a plain ping tells you almost nothing useful — the endpoint can return 200 OK on a bare GET while the actual protocol underneath is completely broken. If you run or depend on a remote MCP server, it's worth understanding what a real health check looks like for this transport, and where things quietly break.
Why a plain HTTP check isn't enough
A Streamable HTTP MCP server exposes a single endpoint (something like https://example.com/mcp) that accepts both POST and GET. A POST without a valid JSON-RPC body, or a request with the wrong Accept header, gets rejected for reasons that have nothing to do with whether the protocol itself is healthy. Conversely, a load balancer or reverse proxy can return 200 from an unrelated route while the real MCP endpoint behind it is down, misrouted, or serving a broken tool list.
The only check that means anything is one that actually speaks MCP: send an initialize request, follow it with tools/list, and inspect what comes back.
What the transport requires you to get right
A few details of the spec matter more than they first appear:
- The client must send an
Acceptheader listing bothapplication/jsonandtext/event-stream. Servers are allowed to respond with either a single JSON object or an SSE stream for any given request, and a checker that only accepts one content type will get spurious failures. - Servers must validate the
Originheader on incoming requests (to prevent DNS-rebinding attacks), which means a health checker hitting the endpoint from an unexpected host can get a403that has nothing to do with server health. - If the server assigns a session, it returns an
MCP-Session-Idheader on theinitializeresponse. Every subsequent request in that session must echo it back. Miss this and you'll get400s that look like an outage but are really a client bug. - Clients should send
MCP-Protocol-Versionon every request after negotiation. Omit it and some servers fall back to assuming an old protocol version, which can mask real drift.
None of this is exotic, but it's exactly the kind of detail that a generic uptime monitor has no reason to know about.
A minimal handshake check
Here's roughly what a correct check looks like, stripped down to the essentials:
import httpx
url = "https://example.com/mcp"
headers = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
}
init_req = {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {},
"clientInfo": {"name": "health-check", "version": "1.0"},
},
}
resp = httpx.post(url, json=init_req, headers=headers, timeout=10)
session_id = resp.headers.get("MCP-Session-Id")
if session_id:
headers["MCP-Session-Id"] = session_id
# required notification before normal requests
httpx.post(url, json={"jsonrpc": "2.0", "method": "notifications/initialized"}, headers=headers)
tools_req = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
tools = httpx.post(url, json=tools_req, headers=headers, timeout=10).json()
That's the shape of the check. What you do with the tools response is the interesting part.
What to actually watch for
Latency of the round trip, not just the connection. A server can accept the TCP connection instantly and still take seconds to produce an InitializeResult because it's doing auth lookups or cold-starting a backing service.
Schema drift in tools/list. Each tool in the response has a name, description, an inputSchema (JSON Schema), and optionally an outputSchema. Deployments change these more often than teams expect — a renamed parameter, a field that silently became required, a type that widened or narrowed. None of this trips a normal uptime check, but it breaks every client that built against the old shape. This is the failure mode that's easy to miss because the server is technically "up."
SSE streams that open but never resolve. If the server responds with text/event-stream, the spec says it should eventually deliver a JSON-RPC response for the request on that stream. A check that only confirms the stream opened, without waiting for the actual response event, will report healthy on a server that's hung mid-request.
Session churn. Watch for how often MCP-Session-Id changes across otherwise-identical requests, or for a spike in 404s (the code servers must return once a session has expired) — it usually means something upstream is recycling server instances more aggressively than clients expect.
One thing worth flagging early
A release candidate circulating for the next spec revision proposes removing the initialize handshake and session IDs entirely, making Streamable HTTP fully stateless. It hasn't shipped as final yet, but if you're building your own checks by hand, it's worth writing them so the handshake logic is isolated in one place — you'll want to swap it out without rewriting your whole monitor.
Putting it on a schedule
Doing this by hand once is easy. Doing it every five minutes, from an independent network path, with alerting on both handshake failures and schema changes, is the part that's tedious to build and maintain yourself. That's the specific gap MCP Vitals fills: it runs the real initialize → tools/list handshake against your server on a schedule, diffs the tool schemas between runs to catch drift before your clients do, and gives you a public status badge you can drop in your README.
If you're currently monitoring an MCP server with a generic uptime tool, the gap above — schema drift, stalled SSE streams, silent session churn — is exactly what it won't catch. Worth checking whether your current setup actually exercises the protocol, not just the port.