Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP server

uptimepage exposes a Model Context Protocol server so an LLM client — the claude.ai connector, Claude Desktop, an IDE, or MCP Inspector — can answer operational questions about one organization and take a few guarded actions, through typed, authorized, audited tools.

It is another authorized front door to the same stores the web app and /api/v1 use, not a bypass: tenant isolation, scopes, rate limits, and audit all apply. Every tool takes the org from the credential — never from a tool argument — so a connection can only ever see and touch its own org.

  • Transport — Streamable HTTP at POST/GET /mcp, served on its own host (mcp.{DOMAIN} in production).
  • Auth — an org-bound scoped API token (sm_live_…), minted either by hand (Settings → API tokens) or by the one-click OAuth 2.1 connector flow.
  • Surface — 7 read tools (always) + 4 write tools (each scope-gated, confirmed per action, and audited).

The server only mounts when enabled (see Enabling); a deployment that leaves it off never exposes /mcp.

Tools

All tools return typed structuredContent. Customer free text (monitor names, group names, tags, error messages, incident text) is returned as labelled data, never as instructions to the model — the server’s instructions tell the client to treat it that way.

Read tools

Side-effect-free (readOnlyHint). Require targets:read, status_page:read, or incidents:read.

ToolScopeReturns
get_org_healthtargets:readPer-state monitor totals + the worst currently-failing monitors, each with its open incident_id. The one-shot “what is broken right now?” answer — start here.
list_monitorstargets:readMonitors with optional state / type / tag filters, cursor-paginated; each item carries current state + last-checked time.
get_monitortargets:readOne monitor’s config, current state, last error, last HTTP status, and 24h / 30d uptime.
get_monitor_historytargets:readOne monitor’s history over a window (1h / 24h / 7d / 30d): uptime, latency series, failures with error text, incident windows.
list_incidentsincidents:readCurrently-open incidents on the org’s status pages: incident id, affected monitor, severity, latest update phase. Cursor-paginated.
get_incidentincidents:readOne incident: affected monitor, severity, open/resolved times, error sample, and the full operator-update timeline.
get_incident_metricsincidents:readIncident metrics over a trailing window (default 30 days): MTTA/MTTR, total, counts by severity and state, auto- vs human-resolved, and the noisiest monitors.
list_status_pagesstatus_page:readThe org’s status pages: slug, name, public URL, enabled. Cursor-paginated.
get_status_pagestatus_page:readOne status page with its components and each linked monitor’s current state.
get_org_usagetargets:readResource usage against plan limits (monitors, status pages, members, components) + key policy values.

A status-page monitor is down → get_org_health gives the incident_idget_incident shows the timeline → acknowledge_incident posts an update. Incidents (and the incident_id / ack workflow) exist only for monitors that are status-page components; a monitor not on any status page can be failing with incident_id: nullsince still reports how long it’s been down. run_check_now and get_monitor return http_status for HTTP monitors so you can tell “wrong status code” from “no response”.

Write tools

Not read-only. Each requires its scope and an interactive confirmation before it runs, and writes exactly one audit row for every outcome (success, declined, denied, error).

ToolScopeEffect
run_check_nowtargets:executeProbe a monitor immediately and record the result. A down result may fire the org’s normal alerts.
pause_monitortargets:writeStop a monitor’s checks until resumed. Idempotent.
resume_monitortargets:writeRestart a paused monitor’s checks. Idempotent.
acknowledge_incidentincidents:writePost an update to an incident; it appears on the public status page. Optional phase (investigating / identified / monitoring / resolved / postmortem, default investigating) and an explicit notify choice (no default).

Write scopes are never granted unless explicitly requested — the OAuth connector defaults to read-only (see Scopes).

Authentication

The /mcp endpoint is an OAuth 2.1 protected resource. It accepts an Authorization: Bearer sm_live_… token that must be:

  • a live scoped API token,
  • bound to one org (an unbound token is rejected — the connection has no org header to fall back on), held by a current member of that org,
  • carrying the scope each tool requires (else 403 insufficient_scope), and
  • when OAuth is configured, stamped with this endpoint as its audience (RFC 8707) — a token minted for a different audience is refused.

A request with no/invalid token gets 401 with a WWW-Authenticate: Bearer … header pointing at the resource metadata, which kicks off discovery for OAuth clients.

Two ways to get a token

1. By hand (manual connector). Mint an org-bound, read-only, expiring token in the UI (Settings → API tokens; a verified email is required) and paste it into the client. Grant the least scope you need — targets:read + status_page:read + incidents:read for the read tools. This is the simplest path for Claude Desktop / Inspector and needs only UPTIMEPAGE_MCP_ENABLED.

2. One-click OAuth (claude.ai connector). With UPTIMEPAGE_MCP_OAUTH_ENABLED on, the client discovers the authorization server, you log in with your existing session and approve a consent screen, and the server mints the same org-bound expiring token behind the scenes — no copy-paste. This is the only path that mints write scopes, and only when the consent screen’s opt-in boxes are checked.

Why OAuth at all?

The manual path works but pushes a long-lived bearer token through copy-paste and client config. OAuth replaces that with a browser consent: the user authenticates against the existing login, the connector receives a short-lived access token plus a rotating refresh token, and the connection lifetime (refresh-token lifetime) is the user’s explicit choice on the consent screen (default 90 days, max 365 — there is deliberately no “never”). Reused refresh tokens revoke the whole family. The connector never sees the user’s password and the access token is bound to this one resource.

OAuth endpoints

Discovery + authorization-server endpoints live on the app host (where the session cookie lives); the protected resource is /mcp on its own host.

EndpointHostPurpose
/.well-known/oauth-protected-resourceresource (mcp.)RFC 9728 resource metadata (resource id, authorization servers, scopes)
/.well-known/oauth-authorization-serverappRFC 8414 AS metadata (PKCE S256 only, public clients, code + refresh grants)
/oauth/registerappRFC 7591 Dynamic Client Registration
/oauth/authorizeappLogin + consent screen (PKCE S256, RFC 8707 resource)
/oauth/tokenappIssue / refresh the audience-bound token

Redirect URIs are restricted to HTTPS hosts (web connectors) and loopback HTTP (local tooling); custom schemes, non-loopback cleartext, userinfo, and fragments are rejected at registration.

GET /oauth/authorize renders the consent screen — the one page the user sees during the OAuth flow. It appears after login, once the client + redirect URI are validated, and only when mcp.oauth_enabled is on. Approving here is what mints the token; nothing is granted until the user clicks Approve.

It shows:

  • Who and what — the client name and the single org it’s connecting to. Access is always scoped to that one org.
  • Granted abilities — one line per scope, in plain language (e.g. “Read your monitors and their current status”, “Pause and resume your monitors”). Write abilities are flagged with a ⚠ marker, and a warning banner appears at the top stating the connection can make changes — each of which still asks for per-action confirmation.
  • Connection expires — a picker (30 / 60 / 90 / 365 days, default 90) that sets the refresh-token (connection) lifetime. There is no “never”.
  • Approve / Deny — Deny aborts the flow; Approve mints the org-bound scoped token and returns the user to the client.

A read-only request shows “wants read-only access” with no warning banner; a request that includes any write scope switches to the “is requesting access” wording plus the banner and ⚠ markers.

Scopes

The connector advertises six grantable scopes. A request with no scope (or only unknown scopes) grants the read-only default; write scopes are opt-in.

ScopeGrantsIn default set?
targets:readall read tools over monitors
status_page:readstatus-page read tools
incidents:readlist_incidents, get_incident
targets:writepause_monitor, resume_monitoropt-in
targets:executerun_check_nowopt-in
incidents:writeacknowledge_incidentopt-in

A granted write scope is necessary but not sufficient — every write tool still asks the user to confirm the specific action at call time.

Confirmations

Before any write tool acts, the server sends an MCP elicitation request describing the exact action (the monitor’s name, the effect, and — for acknowledge_incident — the message and notify choice). The tool proceeds only on an explicit approval; a decline, a dismissal, or a client that can’t elicit all fail closed with not_confirmed. There is no “remember my choice” — each action is confirmed on its own.

Audit

Every write-tool invocation writes one row to mcp_audit, on every path — success, user-declined, scope-denied, bad input, not-found, or server error — recording: actor_type = mcp, the token id, the acting user + org, the tool name, the arguments, the outcome (success / denied / error), and a short detail code. The same event is emitted to tracing. Reads are not audit-logged (they’re side-effect-free and already rate-limited).

Enabling

Off by default. Config keys (TOML under [mcp], or env with the UPTIMEPAGE_ prefix and __ nested separator):

KeyEnvDefaultPurpose
mcp.enabledUPTIMEPAGE_MCP__ENABLEDfalseMount /mcp + the read/write tools.
mcp.oauth_enabledUPTIMEPAGE_MCP__OAUTH_ENABLEDfalseAdd the OAuth 2.1 endpoints that back the one-click connector.
mcp.resource_uriUPTIMEPAGE_MCP__RESOURCE_URIemptyCanonical absolute URI of /mcp — the OAuth resource id + RFC 8707 audience, e.g. https://mcp.uptimepage.dev/mcp. Empty disables audience binding (static-token mode).
mcp.allowed_originsUPTIMEPAGE_MCP__ALLOWED_ORIGINSemptyRFC 6454 Origin allow-list (DNS-rebinding defense). Empty disables the check; a missing Origin header always passes (non-browser clients send none).
mcp.access_token_ttl_secsUPTIMEPAGE_MCP__ACCESS_TOKEN_TTL_SECS3600Access-token lifetime (short; auto-renewed via the rotating refresh token).

When OAuth is on, the app refuses to boot unless mcp.resource_uri and auth.public_base_url are real HTTPS origins — the issuer and audience must be well-formed. Migrations 016 (OAuth) + 017 (audit) must be applied.

Production (GitHub-managed)

The deploy pipeline upserts the two switches from repo variables (Settings → Secrets and variables → Actions → Variables):

  • MCP_ENABLED=true
  • MCP_OAUTH_ENABLED=true

deploy.yml writes the corresponding UPTIMEPAGE_MCP_* keys into the server .env on each deploy. The resource URI defaults to https://mcp.{UPTIMEPAGE_DOMAIN}/mcp; mcp.{DOMAIN} rides the existing *.{DOMAIN} wildcard cert + Caddy route (no new DNS). See deployment/.env.example and Deployment.

Connecting a client

claude.ai connector (OAuth)

Settings → Connectors → Add custom connector → URL https://mcp.{DOMAIN}/mcp → Connect. You’ll be sent to the login + consent screen; approve, and the tools appear. This exercises the full OAuth path and is the recommended end-user flow.

Claude Desktop / IDE (manual token via mcp-remote)

mcp-remote bridges a local stdio client to the remote Streamable HTTP endpoint. Add to your client config:

{
  "mcpServers": {
    "uptimepage": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote",
        "https://mcp.uptimepage.dev/mcp",
        "--header", "Authorization: Bearer sm_live_YOUR_TOKEN"
      ]
    }
  }
}

For a local dev server over plain HTTP, add --allow-http to the args.

MCP Inspector (testing)

npx @modelcontextprotocol/inspector

Set transport Streamable HTTP, URL https://mcp.uptimepage.dev/mcp, and an Authorization: Bearer sm_live_… header. Inspector lists every tool with its schema and lets you exercise the elicitation approve/deny flow.

Examples

Raw protocol (curl)

The transport is JSON-RPC over Streamable HTTP. initialize returns a session id the client echoes on later calls.

# initialize → 200 + Mcp-Session-Id response header
curl -sD- https://mcp.uptimepage.dev/mcp \
  -H 'Authorization: Bearer sm_live_YOUR_TOKEN' \
  -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":"curl","version":"0"}}}'

# list tools (reuse the session id from the initialize response)
curl -s https://mcp.uptimepage.dev/mcp \
  -H 'Authorization: Bearer sm_live_YOUR_TOKEN' \
  -H 'Mcp-Session-Id: THE_SESSION_ID' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

# call a tool: open incidents on your status pages
curl -s https://mcp.uptimepage.dev/mcp \
  -H 'Authorization: Bearer sm_live_YOUR_TOKEN' \
  -H 'Mcp-Session-Id: THE_SESSION_ID' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call",
       "params":{"name":"list_incidents","arguments":{}}}'

# read one incident's timeline (id from list_incidents or get_org_health)
curl -s https://mcp.uptimepage.dev/mcp \
  -H 'Authorization: Bearer sm_live_YOUR_TOKEN' \
  -H 'Mcp-Session-Id: THE_SESSION_ID' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":4,"method":"tools/call",
       "params":{"name":"get_incident","arguments":{"id":"INCIDENT_ID"}}}'

Write tools (acknowledge_incident, pause_monitor, …) follow the same tools/call shape but the client must support elicitation — curl can’t approve the confirmation, so they’re driven from a real MCP client.

A missing/invalid token returns 401 with WWW-Authenticate: Bearer …; a wrong Host returns 403; a missing MCP-Protocol-Version on a non-initialize call returns 400; notifications get 202.

Asking an LLM

Once connected, drive it in natural language — the client picks the tool:

  • “What’s broken in my org right now?” → get_org_health
  • “Show me every DNS monitor that’s degraded.” → list_monitors(type=dns, state=degraded)
  • “How has the checkout API done over the last 7 days?” → get_monitor_history(window=7d)
  • “What incidents are open, and what’s been posted on them?” → list_incidentsget_incident
  • “Acknowledge the payments incident — we’re investigating.” → acknowledge_incident(phase=investigating) (asks you to confirm)
  • “Am I near any plan limits?” → get_org_usage
  • “Run a check on the payments monitor now.” → run_check_now (asks you to confirm; may alert)
  • “Pause the staging monitor.” → pause_monitor (asks you to confirm)

Security model

  • Org isolation. Org comes from the token, never an argument; the token must be org-bound and the holder a live member. The cross-tenant guarantees in Multi-tenancy apply unchanged.
  • Least privilege. Read-only by default; write scopes are opt-in and each write is separately confirmed and audited.
  • Audience binding. With OAuth on, tokens are pinned to this /mcp resource (RFC 8707), so a token leaked from elsewhere can’t be replayed here.
  • DNS-rebinding defense. The transport enforces a Host allow-list (the configured resource host) and an optional Origin allow-list.
  • Prompt-injection posture. Customer-supplied text is returned as labelled data and the server instructions tell the client not to treat it as commands — but the ultimate guard is that the dangerous tools are scope-gated and human-confirmed.