---
title: "How to monitor an MCP server: remote and stdio · Fettle"
description: "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."
url: "https://fettle.sh/guides/monitor-mcp-server"
---

/ 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 2026-10-31

[Start free](https://fettle.sh/register)

Free plan: 20 monitors every 3 minutes from 1 region. No card.

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](https://fettle.sh/uptime-monitoring) covers them; the request-and-keyword approach is the same one in the [API health check guide](https://fettle.sh/guides/api-health-check-monitoring).

## 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](https://modelcontextprotocol.io/specification/2026-07-28/server/discover) 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](https://modelcontextprotocol.io/specification/2025-11-25/basic/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](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) 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](https://fettle.sh/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](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) 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](https://github.com/modelcontextprotocol/inspector/blob/main/clients/cli/README.md) 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](https://fettle.sh/docs/heartbeats).

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](https://fettle.sh/use-cases/github-actions-cron-monitoring) 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](https://fettle.sh/guides/uptime-monitoring-with-ai-agents). More guides are under [guides](https://fettle.sh/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.

## Related guides

- [What is uptime monitoring? A developer's guide Uptime monitoring checks your sites, APIs and jobs from outside on a schedule and alerts you when they stop answering. How checks, regions and alerts work.](https://fettle.sh/uptime-monitoring)
- [Set up uptime monitoring from Claude Code or Codex Connect Claude Code or Codex to Fettle over MCP, then let the agent create monitors and heartbeats, silence a deploy and triage an outage in the terminal.](https://fettle.sh/guides/uptime-monitoring-with-ai-agents)
- [Multi-region uptime checks: how regional consensus stops false alerts How multi-region uptime monitoring decides a site is down: why Fettle needs every fresh region to agree, what a partial failure shows, and the trade-off.](https://fettle.sh/guides/multi-region-monitoring)
- [API health check monitoring: design a /health endpoint worth watching How to design an API health check endpoint worth monitoring, from shallow vs deep checks to status codes, then watch it with status and keyword checks.](https://fettle.sh/guides/api-health-check-monitoring)
- [Uptime alerts in Slack, Discord or a webhook Send downtime alerts to Slack, Discord, Telegram or your own signed webhook: what triggers an alert, what stays quiet, and how repeats and recovery work.](https://fettle.sh/guides/uptime-alerts-slack-discord-webhook)
- [Maintenance windows: silence alerts, keep checking How maintenance windows stop deploy alerts while checks keep running: one-off or recurring, timezones, uptime, and opening one from CI or an agent.](https://fettle.sh/guides/maintenance-windows)
- [TCP port and DNS monitoring: checks below HTTP Watch databases, mail servers and SSH with TCP port checks, catch a database port left open to the internet, and alert when a DNS record changes.](https://fettle.sh/guides/tcp-port-and-dns-monitoring)
- [What 99.9% uptime actually allows 99.9% uptime allows 43m 12s of downtime in a 30-day month. Budgets for every common target, and why your check interval limits what you can measure.](https://fettle.sh/guides/what-does-99-9-uptime-mean)

## Use cases

- [How to monitor a cron job with a heartbeat A cron job that stops running fails no request and throws no error. Here is how a heartbeat monitor catches the silence and alerts you before users notice.](https://fettle.sh/use-cases/cron-job-monitoring)
- [How to catch SSL certificate expiry before it hurts Renewal is automated and mostly works, which is exactly why nobody notices when it quietly stops. Here is what to monitor and how to get warned early.](https://fettle.sh/use-cases/ssl-expiry-monitoring)

## 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](https://fettle.sh/pricing) for the paid intervals, or follow the [quickstart](https://fettle.sh/docs/quickstart) to do it from the API.

[Start free](https://fettle.sh/register)
