Your dashboard says the MCP server is up. A load balancer polls its health path every ten seconds and gets 200 every time. An agent calls a tool and gets an error, because the API key the server uses upstream expired an hour ago. The first person to notice is the one whose task failed. The probe measured the wrong layer.
What you get
You will end up with three probes against one MCP server, a transcript of which one notices an expired upstream credential, and a reconnect policy for three kinds of failure. This is for you if you run MCP servers that agents depend on.
Short answer
Run three probes against the MCP server and read them together. An HTTP check of a health
path proves a process is listening. The protocol’s ping request on an open session proves
the MCP layer answers. A canary that calls one cheap read-only tool and judges the answer is
the only probe that reaches the API behind the tool. Page on the canary, re-initialize on a
404, and back off on a refused connection.
You will need
Node 22 or later, the MCP TypeScript SDK, and Zod, its peer dependency. Python 3.12 or later
for the canary, which uses the standard library only. An MCP server over Streamable HTTP
with at least one cheap read-only tool. Verified 2026-10-21 against Node
22.22.0, @modelcontextprotocol/sdk 1.32.1, zod 4.6.7, and Python 3.13.16. The sample
runs the MCP server, the API behind it, and the probes in one process on 127.0.0.1, with a
clock the monitor never waits on. The server answers 403 to an Origin or Host header that
names another site, as a page using DNS rebinding would send; the
transport specification
requires the Origin check. The Python canary ran against a stand-in server, for a reason
the last section gives.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A synthetic tool-call canary | The server fronts an API with credentials, quotas, or data that can break behind a healthy process | A tool that is cheap and safe to call every minute, a check you write for its answer, and steady load on the upstream | No tool is both read-only and cheap, so every probe would spend money or change data |
| An HTTP endpoint probe | A load balancer or orchestrator decides whether to route to the process or restart it | It proves only that a socket answers, so a server whose tools are broken stays in rotation | The question is whether agents can get work done rather than whether the process is alive |
| The MCP ping request | A long-lived session a client keeps open and wants to know is still there | A session to hold open and re-establish, and a request the 2026-07-28 revision removes along with sessions | The server speaks 2026-07-28, which has no ping and no session, so ask server/discover |
An HTTP probe is the cheapest and answers the narrowest question, whether something is listening, which is what an orchestrator needs before it routes traffic. A ping costs a session and says the MCP layer is awake. The canary costs a tool call on every tick and a check you have to write. It is the only one of the three that fails when the API behind the tool does.
Give the three probes one shape
Each probe returns ok and one line of detail, so a monitor can treat them alike. The canary
opens a session, checks that tools/list names the tool, calls it, and judges the text that
comes back. The
tools specification
separates two kinds of failure. A protocol error, such as an unknown tool, arrives as a
JSON-RPC error; a tool execution error, such as an API failure, arrives as a result with
isError set. The canary reads both.
// Level 3: the tool a user depends on returns an answer that passes a check you wrote. This
// is the only probe that reaches past the MCP layer to whatever the tool calls.
export async function canaryProbe(mcpUrl, { tool, args, expect }) {
let client
try {
client = await openSession(mcpUrl, 'canary')
const { tools } = await client.listTools({}, { timeout: TIMEOUT_MS })
if (!tools.some((t) => t.name === tool)) {
return { ok: false, detail: `tools/list has no ${tool}: ${tools.map((t) => t.name).join(', ') || 'no tools'}` }
}
const result = await client.callTool({ name: tool, arguments: args }, undefined, { timeout: TIMEOUT_MS })
const text = result.content.filter((c) => c.type === 'text').map((c) => c.text).join(' ')
if (result.isError) return { ok: false, detail: `${tool} returned isError: ${text}` }
if (!expect(text)) return { ok: false, detail: `${tool} answered "${text}", which fails the check` }
return { ok: true, detail: `${tool} -> ${text}` }
} catch (err) {
return { ok: false, detail: reason(err) }
} finally {
await client?.close()
}
The server’s tool is where the second kind comes from. When the upstream API refuses the key, the tool returns a result the model can read instead of throwing, as the specification asks. That is also why the MCP layer looks healthy to every probe that stops short of calling the tool.
async ({ currency }) => {
const res = await fetchImpl(`${upstreamUrl}/rates/${encodeURIComponent(currency)}`, { headers: { 'x-api-key': apiKey } })
const body = await res.json().catch(() => ({}))
// A failed upstream call is a tool result the model can read, not a protocol error.
// The MCP layer is healthy; the thing behind it is not.
if (!res.ok) {
return { isError: true, content: [{ type: 'text', text: `upstream answered ${res.status}: ${body.error ?? res.statusText}` }] }
}
return { content: [{ type: 'text', text: `${body.base}/${body.quote} ${body.rate} as of ${body.asOf}` }] }
},
The demo runs the three probes in three states: a healthy server, the same server after the upstream API key is revoked, and the server stopped.
node demo.mjs
state: healthy
http ok HTTP 200
ping ok pong from rates 1.0.0
canary ok get_rate -> EUR/USD 1.08 as of 2026-01-15
state: upstream API key revoked
http ok HTTP 200
ping ok pong from rates 1.0.0
canary FAIL get_rate returned isError: upstream answered 401: invalid api key
state: server stopped
http FAIL ECONNREFUSED
ping FAIL ECONNREFUSED
canary FAIL ECONNREFUSED
Only the canary moves between the first two states. The health path answers 200 because the
process is fine, and ping answers because the MCP layer is fine. The tool call carries the
upstream’s 401 as a result, and the canary reads it and fails. In the third state everything
fails with the same ECONNREFUSED, the one condition the HTTP probe was built for.1
Write the reconnect policy down once
A probe that fails needs a next step, and the right one depends on why. The
transport specification
says a server may end a session at any time and must then answer requests carrying that
session id with 404. A client that receives the 404 must start a new session with a fresh
initialize. The SDK’s client transport does not do that on its own: the 404 surfaces as an
error with a code of 404. So the monitor’s session does it, and treats the case as
housekeeping rather than an outage.
// A long-lived session for a ping monitor. A 404 means the server forgot the session, after
// a restart or an eviction, and the transport specification's answer is a fresh initialize
// with no session id. That is not an outage, so it earns no backoff and no alert.
export class RemoteSession {
#client = null
constructor(mcpUrl) {
this.mcpUrl = mcpUrl
}
async ping() {
// Only a session that was open can have been forgotten. A 404 to a fresh initialize is a
// wrong URL or a missing route, and it is a failure like any other.
const hadSession = this.#client !== null
try {
if (!this.#client) this.#client = await openSession(this.mcpUrl, 'monitor')
await this.#client.ping({ timeout: TIMEOUT_MS })
return { ok: true, detail: 'pong' }
} catch (err) {
await this.#client?.close().catch(() => {})
this.#client = null
if (err?.code !== 404 || !hadSession) return { ok: false, detail: reason(err) }
}
try {
this.#client = await openSession(this.mcpUrl, 'monitor')
await this.#client.ping({ timeout: TIMEOUT_MS })
return { ok: true, detail: 'session dropped (HTTP 404), re-initialized with no delay' }
} catch (err) {
await this.#client?.close().catch(() => {})
this.#client = null
return { ok: false, detail: reason(err) }
}
}
close() {
return this.#client?.close()
}
}
A refused connection is an outage, so it gets a growing delay, 500 milliseconds doubling to
a cap, and a page on the third consecutive failure. A stdio server is a child process whose
exit takes the session with it. The SDK rejects every pending request with
Connection closed, and the supervisor restarts the process with the same growing delay,
twice, then pages. The clock in this run is injected and records each delay instead of
waiting for it.
node reconnect.mjs
remote ping monitor: the server drops its sessions
ping ok pong
ping ok session dropped (HTTP 404), re-initialized with no delay
remote ping monitor: the server stops
ping FAIL ECONNREFUSED retry in 500 ms (failure 1 of 3)
ping FAIL ECONNREFUSED retry in 1000 ms (failure 2 of 3)
ping FAIL ECONNREFUSED alert after 3 consecutive failures
PAGE ECONNREFUSED
stdio supervisor: the server exits in the middle of every second call
call ok EUR/USD 1.08 as of 2026-01-15
call FAIL MCP error -32000: Connection closed restart 1 of 2 in 500 ms
call ok EUR/USD 1.08 as of 2026-01-15
call FAIL MCP error -32000: Connection closed restart 2 of 2 in 1000 ms
call ok EUR/USD 1.08 as of 2026-01-15
call FAIL MCP error -32000: Connection closed alert: 2 restarts used up
PAGE MCP error -32000: Connection closed
The second line is the one to check in your own monitor: the server forgot the session, the monitor opened a new one, and the failure counter never moved. A monitor that counted it would page after the third server restart.
Run the same canary with nothing installed
A monitoring host often has Python and no package manager. The canary
below speaks the 2025-11-25 Streamable HTTP transport with
urllib.request: five POSTs, for
initialize, the initialized notification, ping, tools/list, and tools/call. It
carries the Mcp-Session-Id header from the first response to the rest, and lists both
application/json and text/event-stream in Accept, which the server insists on. The server
answers in whichever of the two it chooses, so the canary reads both.
def check(url, tool, args):
params = {"protocolVersion": PROTOCOL, "capabilities": {}, "clientInfo": {"name": "canary.py", "version": "1.0.0"}}
session, reply = post(url, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": params})
server = reply["result"]["serverInfo"]
print(f"initialize ok protocol {reply['result']['protocolVersion']}, server {server['name']} {server['version']}")
post(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, session)
post(url, {"jsonrpc": "2.0", "id": 2, "method": "ping"}, session)
print("ping ok")
_, listed = post(url, {"jsonrpc": "2.0", "id": 3, "method": "tools/list"}, session)
names = [t["name"] for t in listed["result"]["tools"]]
if tool not in names:
print(f"tools/list FAIL no {tool} among {names}")
return 1
print(f"tools/list ok {', '.join(names)}")
_, called = post(url, {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": tool, "arguments": args}}, session)
result = called["result"]
text = " ".join(c["text"] for c in result.get("content", []) if c["type"] == "text")
if result.get("isError"):
print(f"tools/call FAIL {tool} returned isError: {text}")
return 1
print(f"tools/call ok {tool} -> {text}")
return 0
Run it with the server’s URL, the tool, and the arguments as JSON, and read the exit status:
python3 canary.py http://127.0.0.1:PORT/mcp get_rate '{"currency":"EUR"}'
With the SDK installed, the mcp package does the same
through ClientSession and its send_ping, list_tools, and call_tool methods.
Check it worked
Eleven tests cover the three probes in each state, a hostile currency argument, and a
request from another site. They also cover the dropped session, a 404 with no session behind
it, the backoff schedule, and the stdio restarts. Four more run canary.py against the
stand-in. It exits 0 on a healthy server, whether the reply
comes as JSON or as an event stream. It exits 1 when the tool returns isError and when a
request gets a JSON-RPC error.
node --test probes.test.mjs
1..15
# tests 15
# suites 0
# pass 15
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2026.446335
The assertion to copy is the second test’s: after the key is revoked, the HTTP probe and the
ping both still report ok: true, and the canary’s detail is exactly
get_rate returned isError: upstream answered 401: invalid api key.
When it goes wrong
The dashboard is green, but agents are failing. The upstream credential expired, and every check short of a tool call passes, because the MCP layer is healthy and the thing behind it is what broke. The pitfall script runs each check in turn against a server whose key was revoked.
node pitfall.mjs
upstream API key revoked; what each check reports
GET /healthz 200 {"status":"ok"}
initialize ok rates 1.0.0
ping ok
tools/list ok get_rate
tools/call get_rate isError upstream answered 401: invalid api key
the last check is the only one that reaches the upstream API
Add a canary on a tool that reaches the upstream, and alert on it, not on the health path.
The monitor pages after every deploy. It counted the 404 for a dropped session as a failure,
backed off, and reached its alert threshold while the server was fine. Re-initialize on a
404 without touching the failure counter, as RemoteSession does.
A probe hangs and the next tick starts before it returns. No timeout was set, and the
lifecycle specification
says implementations should set one on every request. The SDK’s default is sixty seconds,
longer than most probe intervals, so pass a timeout on each request as the probes here
do.2
When not to do this
Do not point a canary at a tool that writes, bills, or sends. The probe runs every minute for as long as the server exists, and a tool that creates a record or spends a quota turns monitoring into load with a bill attached. Pick a read the upstream rate limit can absorb, and if no such tool exists, build one for the purpose.
Do not build a ping monitor for a server on the 2026-07-28 revision. That revision has no
sessions and no ping; its server/discover
request, which every server must implement, is the one to ask whether anyone is there.3
Do not let the health path stand in for the canary when agents depend on the server. It exists so an orchestrator can decide about restarts and routing.
Do not restart a stdio server forever. A server that exits every time it starts has a missing binary, a bad config, or a dead upstream, and the second restart tells you as much as the hundredth. Cap the restarts and page.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @modelcontextprotocol/sdk 1.32.1, zod 4.6.7,
and Python 3.13.16. Every output block is what the command preceding it printed. The MCP
server, the API behind it, and the probes ran in one process on 127.0.0.1, and the monitor’s
delays were recorded by an injected clock rather than waited for. In the tests, canary.py
runs against stand_in.py, which answers its five requests with the shapes the SDK server
produces, either as JSON or as an event stream. The sandbox this page was written in does not always
deliver a connection from a Python process to a Node listener. One run against the SDK server
itself, in its default event-stream mode, was made by hand and exited 0.
Footnotes
-
Kubernetes documents three probes, with a default period of ten seconds and a default failure threshold of three, which is where many teams’ idea of a health check comes from. A liveness probe exists to restart a container, a readiness probe to take it out of rotation, and a startup probe to hold the other two off while the process boots. None of them was designed to say whether a tool behind the process can reach its upstream. ↩︎ Back to text
-
The 2025-11-25 lifecycle page says implementations should establish timeouts for all sent requests and should allow them to be configured per request. The TypeScript SDK’s default lives in a constant named
DEFAULT_REQUEST_TIMEOUT_MSEC, set to sixty seconds. A monitor that ticks every thirty seconds with that default can have two probes in flight against a server that has stopped answering, which is how a health check becomes load. ↩︎ Back to text -
The 2026-07-28 changelog removes
ping,logging/setLevel, andnotifications/roots/list_changedin one line, a few lines after removing sessions and theMcp-Session-Idheader. The 2025-11-25 ping page describes a way for either party to verify that the connection is alive, and a stateless protocol has no connection for it to verify.server/discover, which the same revision makes mandatory for servers, inherits the job. ↩︎ Back to text