How-to › Use AI to do the integration

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.

Audience
Platform team
Level
intermediate
Topic
Connect MCP clients to servers
Languages
TypeScript and Python
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
A synthetic tool-call canaryThe server fronts an API with credentials, quotas, or data that can break behind a healthy processA tool that is cheap and safe to call every minute, a check you write for its answer, and steady load on the upstreamNo tool is both read-only and cheap, so every probe would spend money or change data
An HTTP endpoint probeA load balancer or orchestrator decides whether to route to the process or restart itIt proves only that a socket answers, so a server whose tools are broken stays in rotationThe question is whether agents can get work done rather than whether the process is alive
The MCP ping requestA long-lived session a client keeps open and wants to know is still thereA session to hold open and re-establish, and a request the 2026-07-28 revision removes along with sessionsThe 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.

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

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

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

  3. The 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.