/ guides / monitor-mcp-server

How to monitor an MCP server

Check a remote MCP server with an HTTP POST that sends a real JSON-RPC request, and a local stdio server with a heartbeat behind a scheduled smoke test.

Updated

To monitor an MCP server, send it a real protocol request on a schedule and check the answer, not just the status code. For a remote server that means an HTTP POST with a JSON-RPC body, an expected status of 200 and a keyword only a working reply contains. For a local stdio server it means a scheduled smoke test that pings a heartbeat. Fettle is uptime monitoring for developers that can do both with ordinary checks: an HTTP monitor with a custom method, headers, body and keyword, and a heartbeat for anything with no URL.

Fettle has no MCP-specific check type. Everything below uses the same HTTP check you would point at a REST endpoint, so it works the same for an MCP server written in any language. If you are new to the basics, what uptime monitoring is covers them; the request-and-keyword approach is the same one in the API health check guide.

Why isn't a GET health check enough?

Because an MCP server can answer every HTTP request and still be broken. The protocol is JSON-RPC carried in POST requests, and a protocol error is usually an HTTP 200 with an error object in the body. A GET to /mcp tells you the reverse proxy is up, not that the server can complete a handshake.

A server that does not know a method answers with a JSON-RPC error such as "error":{"code":-32601,…}, and many servers send that error with status 200. A monitor that only expects 200 would call that a success. Adding a keyword that appears only in a successful result closes the gap.

Which request should the monitor send?

It depends on which protocol revision the server speaks. Servers on the 2025 revisions answer an initialize request, and their reply contains "protocolVersion". Servers built for the 2026-07-28 revision drop the handshake and must answer server/discover, whose reply contains "supportedVersions". Not sure which one yours speaks? Send each request once with curl and keep the one that answers.

For a 2025-era server (initialize, as defined in the 2025-11-25 lifecycle), create an HTTP monitor with these fields. This is the body for POST /v1/monitors; the dashboard form has the same method, header, body and keyword fields.

{
  "name": "MCP server",
  "type": "HTTP",
  "target": "https://mcp.example.com/mcp",
  "interval_seconds": 300,
  "timeout_seconds": 10,
  "regions": ["eu-central"],
  "config": {
    "method": "POST",
    "request_headers": {
      "Content-Type": "application/json",
      "Accept": "application/json, text/event-stream"
    },
    "request_body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{},\"clientInfo\":{\"name\":\"uptime-check\",\"version\":\"1.0\"}}}",
    "expected_status_codes": [200],
    "keyword": "\"protocolVersion\""
  }
}

If the server does not support the version you send, the spec has it answer with one it does support, so the reply still contains "protocolVersion" and the check stays green.

For a 2026-era server, the Streamable HTTP transport requires two extra headers that must match the body: MCP-Protocol-Version and Mcp-Method. A missing or mismatched header is a 400, which fails the check, so set them exactly:

{
  "method": "POST",
  "request_headers": {
    "Content-Type": "application/json",
    "Accept": "application/json, text/event-stream",
    "MCP-Protocol-Version": "2026-07-28",
    "Mcp-Method": "server/discover"
  },
  "request_body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"server/discover\",\"params\":{\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientInfo\":{\"name\":\"uptime-check\",\"version\":\"1.0\"},\"io.modelcontextprotocol/clientCapabilities\":{}}}}",
  "expected_status_codes": [200],
  "keyword": "\"supportedVersions\""
}

A server that supports both revisions answers either request.

A few details of how Fettle runs this request matter here. The keyword match is case-sensitive and plain substring, so the quotes around protocolVersion keep it from matching a word in an error message. Fettle does not send an Accept header unless you set one, and the spec requires both content types in it. The status code is checked before the keyword, so a 401 or 400 fails with the status code in the error, not as a missing keyword. On Free the check runs every 3 minutes from 1 region; paid plans add shorter intervals and more regions (pricing).

What if the server answers with a stream?

Then the check works only if the server ends the stream after its reply. A Streamable HTTP server may answer a POST with text/event-stream instead of plain JSON. The keyword still matches inside the data: line, but Fettle reads the body until it ends, up to 1 MiB, so an open stream runs into the timeout.

Both spec revisions say the server should close the stream once the response is sent, and most SDKs do. You can see what yours does with curl, which prints the stream as it arrives:

curl -N https://mcp.example.com/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"uptime-check","version":"1.0"}}}'

If curl returns right after the data: line, the monitor will work. If it hangs, you have three options, best first: configure the server to answer single requests with application/json if your SDK offers a JSON-response mode, make it end the stream after the response, or drop the keyword. Without a keyword Fettle decides on the status code alone and stops caring about the body, which also means a 200 carrying a JSON-RPC error counts as up.

The 2025-11-25 transport also lets a server close the connection early and have the client reconnect for the result. A single-request check cannot follow that; it sees no protocolVersion and reports the keyword missing. A server built that way needs the stdio-style smoke test below instead.

Can the monitor call tools/list?

Only if the server is stateless. On 2025-era servers that keep sessions, initialize returns an MCP-Session-Id header that every later request must carry. A monitor sends one request and cannot pass a header from one answer to the next, so a bare tools/list gets a 400 from those servers.

A stateless server answers tools/list without a handshake, and a check on it is a stronger test: it proves the tool registry loads, not just the transport. Use the 2025-era body with "method":"tools/list","params":{} and a keyword such as the name of one tool you expect to be there. Keep the body under 8 KB and make sure the full reply is not larger than 1 MiB, or the keyword may sit past the part Fettle reads.

For session-keeping servers, the initialize probe has a side effect worth knowing: each check opens a session the monitor never closes. At a five-minute interval that is 288 a day for each region, so make sure your server expires idle sessions.

How do you monitor an MCP server behind authentication?

Add the credential as a header, usually Authorization: Bearer …, using a token issued for monitoring only. Fettle stores request headers and never returns their values: reading the monitor back shows a mask instead of the token. Without a credential, a protected server answers 401 and the check fails.

"request_headers": {
  "Content-Type": "application/json",
  "Accept": "application/json, text/event-stream",
  "Authorization": "Bearer YOUR_MONITORING_TOKEN"
}

Servers that accept only OAuth tokens are harder, because those tokens expire and a monitor cannot refresh one. Check whether the server lets you mint a long-lived service token or API key for monitoring. If it does not, you have two weaker fallbacks. Some servers answer certain calls anonymously; Fettle's own endpoint, for instance, answers tools/list and ping without a credential but returns 401 with a WWW-Authenticate header to an anonymous initialize. Or set expected_status_codes to [401] with no keyword: that proves the server is up and rejecting strangers, nothing more.

How do you monitor a stdio MCP server?

With a heartbeat. A stdio server runs as a child process of the client, so there is no URL to probe. Instead, start it on a schedule from cron or CI, ask it for its tools, and ping a Fettle heartbeat when that works. If the ping stops coming or the job reports a failure, you get an alert.

The MCP Inspector CLI does the client half: it launches the server, runs one method and exits non-zero if anything fails. A wrapper script for cron:

#!/bin/sh
# Smoke test for a stdio MCP server; run it every hour from cron.
PING=https://api.fettle.sh/v1/heartbeat/YOUR_TOKEN

if npx -y @modelcontextprotocol/inspector --cli -e API_KEY="$API_KEY" \
     node /srv/my-mcp-server/build/index.js --method tools/list > /dev/null; then
  curl -fsS --retry 3 "$PING" > /dev/null
else
  curl -fsS --retry 3 "$PING/fail" > /dev/null
fi

The Inspector starts the server with a minimal environment, so pass each variable it needs with -e. Create the heartbeat with a period that matches the schedule (3600 seconds for hourly) and a grace period that covers a slow run. A ping to /fail marks it down at once; silence raises the alert once the period and grace run out. A heartbeat is never late before its first successful ping, so a job that never ran at all stays pending rather than down. Free includes 3 heartbeats; the details are in the heartbeat docs.

The same script works as a scheduled CI job, which also catches a broken release of a server you publish to npm: point the command at npx -y your-package instead of a local build. The GitHub Actions cron guide shows the workflow side. The Inspector CLI can also reach a remote server (--cli https://mcp.example.com/mcp --method tools/list), which is the route for a server whose transport the HTTP check cannot follow.

If you run Fettle from Claude Code or Codex, the agent can set all of this up for you: see uptime monitoring from Claude Code or Codex. More guides are under guides.

Questions

Is a GET request to /mcp enough to know an MCP server is up?
No. A GET only proves the web server answers. MCP runs over JSON-RPC in POST requests, and many servers return HTTP 200 even when the request failed, with the error inside the body. Send a real JSON-RPC request and require a field that only a working answer contains.
Can an uptime monitor call tools/list on an MCP server?
Only on a stateless server. A server that keeps sessions expects an initialize request first and a session header on everything after it, which a single-request monitor cannot carry. Such servers usually answer a bare tools/list with HTTP 400, so probe initialize or server/discover instead.
How do you monitor an MCP server that only runs over stdio?
A stdio server has no URL to probe, so run a smoke test on a schedule instead: start the server with the MCP Inspector CLI, list its tools, and ping a heartbeat URL when that succeeds or its /fail URL when it does not. Silence or a failure ping raises the alert.

Start monitoring in a minute

The free plan checks up to 20 monitors as often as every 3 minutes from 1 region, and emails you when one breaks. See pricing for the paid intervals, or follow the quickstart to do it from the API.