# How to health check your MCP server connections

> Probe an MCP server three ways, see which probe notices an upstream credential that expired, and set one reconnect and backoff policy for stdio and remote servers.

Source: https://voxgig.com/howto/health-check-mcp-server-connections

- Audience: platform-team
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#security-warning)
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](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) | 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](https://kubernetes.io/docs/concepts/workloads/pods/probes/) | 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping) | 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](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)
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.

```js title="probes.mjs"
// 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.

```js title="server.mjs"
    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.

```bash
node demo.mjs
```

```text output
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](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
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.

```js title="monitor.mjs"

// 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.

```bash
node reconnect.mjs
```

```text output
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`](https://docs.python.org/3/library/urllib.request.html): 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.

```py title="canary.py"
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:

```bash
python3 canary.py http://127.0.0.1:PORT/mcp get_rate '{"currency":"EUR"}'
```

With the SDK installed, the [`mcp` package](https://pypi.org/project/mcp/) 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.

```bash
node --test probes.test.mjs
```

```text output
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.

```bash
node pitfall.mjs
```

```text output
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](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)
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`](https://modelcontextprotocol.io/specification/2026-07-28/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

- [Connect a remote MCP server to Claude Desktop](/howto/connect-a-remote-mcp-server-to-claude-desktop)

- [Add a health endpoint agents can poll](/howto/add-a-health-endpoint-for-agents)

- [Set an SLO for a third party API you depend on](/howto/set-slos-for-a-third-party-api)

- [Add a circuit breaker to an outbound HTTP client](/howto/add-a-circuit-breaker-to-an-http-client)

## 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.

[^1]: Kubernetes [documents](https://kubernetes.io/docs/concepts/workloads/pods/probes/)
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.

[^2]: The 2025-11-25 [lifecycle page](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)
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.

[^3]: The 2026-07-28 [changelog](https://modelcontextprotocol.io/specification/2026-07-28/changelog)
removes `ping`, `logging/setLevel`, and `notifications/roots/list_changed` in one line, a
few lines after removing sessions and the `Mcp-Session-Id` header. 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.