Skip to main content

docs MCP server

MCP server

uptimepage exposes a Model Context Protocol server so an LLM client — the claude.ai connector, Grok, 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 — 19 read tools (17 of them under the default grant; list_notification_channels needs channels:read and list_variables needs variables:read) + 20 write tools (each scope-gated, confirmed per action where the client can ask, and audited); see Confirmations.

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). Each requires the scope named in its row: targets:read, status_page:read, incidents:read and maintenance:read are in the default grant; channels:read and variables:read are not.

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 full config — everything the check asserts (expected status, body match, which request headers are sent, timeout, redirect policy, TLS options, per-kind thresholds) plus the regions it probes from and the ids of the channels it alerts — with its current state, last error, last HTTP status, structured edge-access diagnosis when present, and 24h / 30d uptime. Every field update_monitor can change is readable here, so a retune never guesses at what it is changing: alert_channel_ids is the read half of channel_ids (which replaces the whole set), and alert_confirmations, notify_recovery, renotify_interval_secs, recovery_period_secs and region_policy come back individually, region_policy in the same {mode, count} object the write tools take. Send those fields back, not the whole monitor: update_monitor rejects anything outside its allowlist. managed_externally says up front that the monitor is Terraform-declared and every write will refuse it. Credentials are never carried: HTTP basic-auth and bearer tokens report only has_basic_auth / has_bearer_token, header values and the request body come back as ***, a heartbeat's ping token is withheld, and a flow step's fill value is reported as value_withheld. The address is reported as configured, so a credential an operator put in the URL itself is visible there, exactly as in /api/v1.
get_monitor_historytargets:readOne monitor's history over a window (1h / 24h / 7d / 30d): uptime, latency series, a per-region split of the same window, failures with error text, incident windows. region narrows uptime, latency, and the split to one probe region, which is how "down everywhere or only from Singapore?" gets answered.
list_regionstargets:readThe fleet's probe regions: id, display name, city, country, continent, and default_selected — whether a new monitor probes from there unless told otherwise. Reports max_regions alongside, but only where the plan reaches fewer regions than the catalog lists, so a set too large to be accepted is visible before it is sent and a plan that reaches everything advertises no ceiling to fill. The id is what get_monitor.regions lists and what get_monitor_history takes as region.
list_tagstargets:readEvery tag in use across the org's monitors, most-used first, with a monitor count each. list_monitors filters by an exact tag; this is how to learn which ones exist. Says so when the inventory is truncated.
get_flow_runstargets:readA browser flow monitor's recent runs over a window: every declared step with its outcome and duration, the step a failure stopped on, and the page the browser saw. Answers why a login check failed.
get_flow_step_trendtargets:readPer step of a browser flow monitor, over a window: earliest and latest mean duration, the ratio between them, and how many runs passed or failed it. Answers which step is getting slower while the monitor still reports up.
list_incidentsincidents:readIncidents with their id, affected monitor, severity, open/resolved times, and latest update phase. Defaults to the currently-open ones; state: "all" adds resolved history inside a from/to window (default: the last 30 days, capped at a year), and monitor_id narrows to one monitor. The response repeats the window it actually read, so a clamped request is never reported as the span that was asked for. An incident still running is listed however long ago it opened. 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_maintenancemaintenance:readMaintenance windows with their title, start and end, phase (upcoming / active / completed / cancelled), the monitors each covers, and whether it holds their paging. status narrows to active, upcoming or past (ended or cancelled); the default is all, latest start first. Cursor-paginated.
get_maintenancemaintenance:readOne maintenance window, with its description and the updates posted to it.
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, and access: who may open it, or null for a page never made private.
list_status_page_viewersstatus_page:readWho signed in to a private page: address, first and last visit, saved passkeys, whether they subscribe, and whether they are blocked; and for the page password, which names nobody, the devices signed in with it, its last sign-in and the wrong tries in 24 hours. Blocking stays in the app and the REST API.
get_org_usagetargets:readWhich org the connector is bound to (slug and name), and resource usage against plan limits (monitors, status pages, members, components) + key policy values. A token carries one org and cannot switch, so this is the one-call answer to "where am I connected?" — see Org binding.
list_variablesvariables:readThe org's variables: key and whether it is a secret. Values are never returned, and a secret's is never even read. This is how a caller finds the key to write as {{ key }} when building an authenticated check. Variables are created and edited in the app, never here — variables:write is not grantable to a connector.
list_notification_channelschannels:readThe org's notification channels: id, operator-set name, kind, enabled, plus two flags for a channel that is not working even where it reads as ready: awaiting_verification for an email address that was never confirmed, and not_delivering for an enabled channel whose recent alerts all failed to arrive. The settings that make a channel work (webhook URLs, bot tokens, addresses) are withheld. Channels are created in the app, never here. channels:read is not one of the default connector scopes: a client that never touches alerting is not offered the inventory.

A window is a request, not a promise: every history tool clamps it to what your plan retains at per-check detail, and one clamp covers the whole response so its fields never describe different spans. See Quotas and limits.

get_monitor_history measures uptime the way the rest of the product does: unfiltered, it counts confirmed incident time, weighted by impact as the status page weights it, so a flap that never breached the alert threshold does not dent it, and neither does an incident an operator declared by hand unless they asked for it to count. Each entry in incidents carries counts_as_downtime, so a window listed there can be told apart from one that explains the uptime gap, and its impact, which says how much of the gap a counted one explains: a major_outage in full, a partial_outage for 30% of its length, a degraded one none. Its recovered stretches, where the monitor was back up before the failure returned inside the recovery period, are left out of the gap. Under a region filter there is no such thing as a confirmed regional incident, so uptime becomes that region's raw pass rate — the two numbers answer different questions and should not be compared. failures and incidents always describe the monitor as a whole, since an incident is raised for the monitor rather than per region; the regions split is what shows a partial outage. That split is empty for a monitor that runs in one region, because one region cannot disagree with itself.

A status-page monitor is down → get_org_health gives the incident_id → get_incident shows the timeline → acknowledge_incident takes ownership → publish_incident puts it on the status page → post_incident_update tells your customers. 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: null — since 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, from a client that can show one, an interactive confirmation before it runs, and writes exactly one audit row for every outcome (success, declined, denied, error).

ToolScopeEffect
create_monitortargets:write + targets:execute (not for a heartbeat or manual monitor, which has nothing to probe) (+ channels:read to bind channels)Create an http, tcp, ping, dns, tls_cert, domain_expiry, heartbeat or manual monitor, optionally naming the regions it probes from. See How creation is guarded.
create_monitorssame as create_monitorCreate several monitors under one confirmation. Every check is trial-run first and all the results are shown together; an item that fails validation or its probe is reported in the results and the rest are still created. Same per-monitor fields and same guards as create_monitor. Prefer it whenever the user names more than one thing to watch — ten monitors otherwise costs ten prompts.
create_status_pagestatus_page:write + org ownerCreate a status page from a slug and name. Created unpublished unless enabled is passed, so its components can be curated before anyone can read it. The slug is the page's public address, first-come across the platform, and moving it later breaks every existing link. access makes it private from the start.
update_status_pagestatus_page:write + org ownerRename a page, move it to a new slug, publish and unpublish it, or change who may open it. An omitted field is left alone; access, the same shape as in the REST API, replaces the page's access settings and list whole. It turns a stored page password on or off but never takes one: a password.value is refused, since a credential is never a tool argument, and turning the password on with none stored asks for it to be set in the app, the API or Terraform. Idempotent.
add_status_page_componentsstatus_page:write + org ownerPut monitors on a page as public components, in one confirmation. Each takes a public_name (the monitor's own name is operator-facing), an optional public_group to file related components together, and detail_link_enabled to publish a per-monitor detail view. A monitor already on the page is reported as such, never duplicated.
update_status_page_componentstatus_page:write + org ownerChange how one monitor is presented on a page: public name, description, group, sort order. An omitted field is left alone. A detail link is set when the component is added. Idempotent.
run_check_nowtargets:executeProbe a monitor immediately and record the result. A down result may fire the org's normal alerts. A heartbeat monitor has nothing to probe and is refused as invalid_argument, not as something to retry.
update_monitortargets:write (+ channels:read to rebind channels)Change how loudly a monitor is watched: interval_secs, alert_confirmations, notify_recovery, renotify_interval_secs, recovery_period_secs, tags, group_name, region_policy, channel_ids. Nothing else — see What it will not change. tags replaces the whole list and takes at most 50, each at most 50 characters, with no blank and no invisible characters. The confirmation names the monitor and states old → new for every field, and a request whose values already match writes nothing and never prompts. If the monitor moves between the prompt and the approval, the write is refused as conflict instead of landing on top of the newer value. Idempotent.
pause_monitortargets:writeStop a monitor's checks until resumed. Idempotent.
resume_monitortargets:writeRestart a paused monitor's checks. Idempotent.
set_monitor_statetargets:writeSet a manual monitor up, degraded or down, with an optional one-line note that becomes the incident's cause. Down or degraded opens an incident and up closes it, through the same pipeline as any other monitor. Refused as invalid_argument for any other kind. Allowed on a Terraform-managed monitor, since the state is not config. Idempotent.
create_maintenancemaintenance:writeSchedule a maintenance window: title, optional description, starts_at and ends_at (RFC 3339; the end in the future, at most 30 days after the start), the monitor_ids it covers, and suppress_alerts (default true), the same rules as /api/v1/maintenance. The confirmation names the covered monitors, says whether their paging is held, and says whether the window is announced: it shows on every published status page carrying one of them, whose subscribers are notified when it is scheduled and when it ends. The gentler alternative to pause_monitor for planned work, since checks keep running.
update_maintenancemaintenance:writeEdit an upcoming or running window: title, description, starts_at, ends_at, monitor_ids (replaces the whole set) or suppress_alerts. end_now ends a running window at the server's clock and keeps it as completed work. A completed or cancelled window is history and is refused before anything is asked. Like update_monitor, the confirmation states old → new per field, a request that changes nothing writes nothing, and a window that moves while the prompt is open is refused as conflict.
cancel_maintenancemaintenance:deleteCancel a window that has not ended. It leaves the status pages and stops holding paging, and stays listed under past as a record; subscribers already told about it are not sent a cancellation.
post_maintenance_updatemaintenance:writePost an update to an upcoming, running or completed window: that the work runs late, that it finished, or what changed. It shows on the status pages that show the window and in their history, and goes to their email and webhook subscribers. A completed window is otherwise read-only, so this is how a closing note is added. Refused on a cancelled window, and past 50 updates.
acknowledge_incidentincidents:writeTake ownership of an incident and halt escalation. Internal only: it posts nothing to the public status page. Idempotent.
resolve_incidentincidents:writeMark the incident resolved. Internal only, same as acknowledge: the public page is untouched. Idempotent.
publish_incidentincidents:writePut an incident on every status page carrying the affected monitor, optionally seeding public_title and public_description. Subscribers may be notified. Idempotent.
unpublish_incidentincidents:writeTake a published incident back off the public pages. The operator timeline is untouched. Idempotent.
post_incident_updateincidents:writePost the customer-facing update to the incident's status-page timeline: a message plus optional phase (investigating / identified / monitoring / resolved / postmortem, default investigating). Requires a published incident.

Incidents start internal, so the customer-facing sequence is publish_incident then post_incident_update; posting to an unpublished incident is refused rather than written somewhere nobody reads.

How creation is guarded

create_monitor is the one tool that brings something into existence, so it carries three constraints the others don't need.

The check runs before anything is saved. The trial result — the region it ran from, passed or not, HTTP status, duration, or the error text — is part of the confirmation the operator reads, so a check that asserts the wrong thing is visible while it can still be declined rather than after it starts paging. That is also why the tool needs targets:execute alongside targets:write: it dispatches a real probe at a caller-supplied address, and that probe is metered against the same test_now budget as POST /targets/test. If no agent is serving the region, creation is refused as probe_unavailable rather than persisted untried, since a monitor nothing can check is not worth having. If every agent there is busy with other checks it is refused as probe_busy, which is worth retrying shortly; in create_monitors that fails only the one monitor, not the batch.

Two consequences worth stating plainly. The probe necessarily happens before the human answers, so declining the prompt still means one request was made to that address — the confirmation decides whether the monitor is created, not whether the check was tried. And a client that cannot show the prompt never sees the trial result before the monitor exists; it gets the same result back from the tool, after the fact.

The confirmation lists every setting, not just the address: interval, probe regions, tags, group, how many failing checks it alerts after, whether recovery is announced, the reminder cadence, and the multi-region quorum, resolved against the regions the monitor is being given rather than left as a label a narrower assignment would clamp. A field the prompt omitted would be approved unread.

A credential is referenced, never pasted. Request headers and a request body can be set, which is what makes an authenticated check — a POST /v1/chat/completions carrying an API key — expressible here at all. What cannot be done is spelling the secret out. A header whose name carries a credential (authorization, proxy-authorization, x-api-key, api-key, cookie) must hold exactly a variable reference, optionally behind a scheme word: Bearer {{ openai_key }}, or a bare {{ session_cookie }}. Anything looser is refused, pointing at list_variables for the keys the org has.

The reason is where the value would come to rest. A pasted key is echoed back in the tool result and lives on in the chat transcript, and it is stored in the monitor's check_spec as plaintext, where a variable would have been sealed at rest — so it also has to be re-pasted into every monitor that needs it instead of being rotated in one place. Referencing costs nothing at probe time: the reference resolves just before the check runs, exactly as it does for a monitor built in the app.

This is a hygiene rule, not a security boundary: it gates the five header names a credential normally travels under, and a caller determined to paste a key under some other name can. What it stops is the accident, which is the common case. Basic auth, bearer tokens and browser flows are still app-only — a flow's fill values are withheld from every other MCP tool, and update_monitor refuses to change a URL, header or body on a monitor that already exists.

Channels are bound by id, never created. channel_ids on create_monitor (and on update_monitor, which replaces the whole set) binds channels that already exist; list_notification_channels is how their ids are found, and channels:read is required to use them, since naming and validating them means reading the inventory. A channel id from another org is refused, as is one that does not exist. The channel itself — its webhook URL, bot token or address — is only ever set up in the app. Omit channel_ids and the monitor alerts nobody, which is the failure this tool is likeliest to leave behind: monitors that look right in the console and page no one at 3am. The tool description and the server instructions both push the caller to bind as it creates, and to say plainly that nothing is bound when the org has no channel yet or the token lacks channels:read.

The confirmation names the channels rather than listing ids, and says outright when one is disabled or is an email address that was never verified, since either delivers nothing.

The connecting user owns what it creates. A monitor made here carries the user behind the token as its owner, the same default the console form applies, so the owner column says who added it.

A vague ask gets a named starter set. "Add monitoring on my SaaS" carries one URL and no check list, so the server instructions name the set worth creating from it: an http check on the public URL, a tls_cert check on its host, and a domain_expiry check on its domain. The last two are there because a certificate or a registration lapsing is the outage that arrives with no warning and no bad deploy to blame, and because they are the two a caller working from a single URL would not think to ask for. Anything past that waits for an endpoint the user names.

The interval defaults to where the app's own picker opens a monitor of that kind, held up to the plan's floor — not to the hard minimum, which is legal but would probe a certificate twelve times more often than any other front door does. For a heartbeat it is the cadence its window calls for, a tenth of period + grace held between 60 s and 300 s, since a coarser tick only delays the alarm.

The default region set is the answer, not a fallback. regions takes ids from list_regions and pins where the check runs from, and omitting it takes the operator's default-on regions, capped at the plan's max_regions and falling back to the control plane's own region when nothing is flagged. list_regions reports default_selected against that resolved set rather than the raw column, so a plan whose cap bites is never told it will get coverage creation would trim away. Its max_regions is reported only when the plan reaches fewer regions than the catalog holds: on every plan but free the cap is effectively unbounded, and a ceiling that happens to equal the number of rows returned reads as leave to name all of them. A caller reading the catalog sees a list of places and a cap, not a form to fill: naming all of them is the wrong instinct, and on a plan whose max_regions is smaller it fails outright rather than trimming to fit. A vantage point can be published without being ticked by default, so a monitor that needs one has to name it. The set is vetted before the trial probe, so an unknown or disabled id, or one the plan's region cap will not pay for, is refused without spending a probe. A heartbeat is pinged rather than probed and rejects the argument outright. The result reports the regions the monitor was actually assigned, so the default set never has to be guessed at.

Beyond that, a monitor created here gets the same heartbeat ping row and immediate first check that POST /targets gives it, because both doors go through one creation path. Without that a heartbeat would have no ping URL to call, and a multi-region plan would quietly get single-region monitors.

What update_monitor will not change

The line is drawn on one rule: how loudly we watch is editable, what we watch is not. Name, address or URL, assertions, expected status, request headers and body, probe regions and owner are all refused, and passing one is an error rather than a silent no-op. Which channels a monitor alerts is editable, by id: that changes who hears about an outage, not whether one is detected.

This is not squeamishness about writes. Changing the interval or the confirmation count can only make alerting noisier or quieter, shows up on the next check, and is trivially reversible — that belongs in a conversation held during an incident. Changing what a check asserts can make a broken service report healthy, and that failure is silent and open-ended: nobody gets paged, and the monitor keeps saying everything is fine. That belongs in config-as-code, where it gets a diff and a review.

Terraform-managed monitors

A monitor whose write_source is terraform is refused by update_monitor, pause_monitor and resume_monitor with managed_externally, naming the monitor and pointing at the .tf that declares it. There is no override argument. Without the guard the edit lands and the next terraform apply reverts it, which no confirmation prompt can warn about because the prompt is answered long before the revert. set_monitor_state is allowed on one, because a manual monitor's state is not in its config.

MCP writes also leave write_source as they found it, rather than restamping it. Otherwise the first MCP write would erase the terraform marker and the guard would protect exactly one edit. Attribution for MCP writes lives in the audit log, which records the token, the user, and the tool for every outcome.

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 here, and a token minted here is refused by the REST API (401 TOKEN_AUDIENCE).

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.

Either way, the token decides what the connection may do; whether a person is asked before each write depends on the client. See Confirmations.

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 the ones the client asked for: the consent screen lists exactly what is about to be granted and approval covers that whole set, so a client asking for more than it needs is declined as a whole rather than trimmed.

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

The authorization-server endpoints live on the app host (where the session cookie lives); the protected resource is /mcp on its own host. Every /.well-known/* document is served on both hosts, because a client starts discovery at whichever origin it was given; the MCP host serves nothing else besides /mcp.

EndpointHostPurpose
/.well-known/oauth-protected-resourceresource (mcp.) and appRFC 9728 resource metadata (resource id, authorization servers, scopes)
/.well-known/oauth-authorization-serverapp and resourceRFC 8414 AS metadata (PKCE S256 only, public clients, code + refresh grants)
/oauth/registerappRFC 7591 Dynamic Client Registration (open; a client nobody approves, and nobody has opened the consent screen for in a week, is dropped)
/oauth/authorizeappLogin + consent screen (PKCE S256, RFC 8707 resource)
/oauth/tokenappIssue / refresh the audience-bound token

Redirect URIs may be HTTPS hosts (web connectors), loopback HTTP including [::1] (mcp-remote, VS Code), or a native app's own scheme: the reverse-DNS form RFC 8252 asks for (com.example.app:/cb) or cursor://, the one editor scheme admitted by name. Everything else is rejected at registration: non-loopback cleartext, any other protocol handler, userinfo, fragments. The list is an allowlist because the authorize error path redirects to the registered URI before login. PKCE is mandatory, which is what makes a native scheme safe once admitted: a program that hijacks the scheme on the user's machine receives the code but cannot redeem it.

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 where — the client name, what it asked for, and where the browser goes after Approve or Deny: the redirect host, or for a loopback or native-scheme callback a note that it stays on this computer. The client registers both its name and that URI, but the code is delivered to the URI, so the page cannot show one destination and send the code to another; a name that does not match the host is the tell.
  • Organization — a picker over the orgs the user belongs to, preselecting the one active in the app. The token binds to the org chosen here and to nothing else.
  • Access level — three choices: Read only (every read scope, including channel names and variable keys), Manage monitors (adds targets:write, targets:execute, maintenance:write, maintenance:delete) and Full access (adds incidents:write, status_page:write). The smallest level that covers the client's scope request is preselected, so a connector that asked for nothing lands on read only and one that asked for everything lands on full; the user can move either way. Each level expands to the exact abilities it grants, write abilities marked ⚠, and a warning banner appears whenever the selected level can make changes, stating that a client which cannot show a confirmation prompt will make them without asking each time.
  • 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 token for the chosen org at the chosen level and returns the user to the client.

The level the user picks is the grant. The client's request only decides which level is preselected; OAuth allows the resource owner to grant more or less than was asked, and the token response carries the granted scope so a conforming client sees what it got. That is what makes a write-capable claude.ai connection possible at all, since that connector never asks for write scopes itself.

Scopes

The connector advertises twelve grantable scopes. A request with no scope (or only unknown scopes) preselects the read-only default on the consent screen; the write scopes are granted only through the Manage monitors or Full access level the user picks there.

ScopeGrantsIn default set?
targets:readall read tools over monitors✅
status_page:readstatus-page read tools✅
incidents:readlist_incidents, get_incident, get_incident_metrics✅
maintenance:readlist_maintenance, get_maintenance✅
channels:readlist_notification_channels, and binding channels on create_monitor / update_monitoropt-in
targets:writecreate_monitor, create_monitors, update_monitor, pause_monitor, resume_monitor, set_monitor_stateopt-in
targets:executerun_check_now, and the trial probes create_monitor / create_monitors runopt-in
maintenance:writecreate_maintenance, update_maintenance, post_maintenance_updateopt-in
maintenance:deletecancel_maintenanceopt-in
incidents:writeacknowledge_incident, resolve_incident, publish_incident, unpublish_incident, post_incident_updateopt-in
status_page:writecreate_status_page, update_status_page, add_status_page_components, update_status_page_component — and the caller must be an owner of the org, the same bar /api/v1 holds the public brand surface toopt-in
variables:readlist_variables — variable keys only, never a valueopt-in

A granted write scope is what authorises a write. Where the client can show a prompt, every write tool also asks the user to confirm the specific action at call time; that prompt guards against a model acting on its own, not against a client the user should never have approved. The consent screen is where a hostile client, or an over-broad grant to an honest one, is stopped: an aggregator such as Composio requests all twelve scopes because that is what the metadata advertises, and the access level picker is where that becomes "manage monitors" instead.

variables:write is deliberately absent. Creating or rotating a variable means carrying its value, which is the one thing this surface will not do; variables are managed in the app or over /api/v1.

Org binding

A token is bound to one org, and there is no tool argument that switches it. The binding is set when the token is minted, from the org picked on the consent screen; the picker preselects whichever org is active in the app, so check it before approving.

Creating a new org in the app does not make it visible to an existing connection; reconnect and pick the new org. get_org_usage reports the bound org, which is the fastest way to check before wondering why a monitor is missing.

Confirmations

Before any write tool acts, the server sends an MCP elicitation request describing the exact action: which monitor or incident it lands on, the effect, and for a public update the text customers will read. Nothing is ever confirmed unnamed, so an approval can't be steered onto a different incident than the one under discussion. The tool proceeds only on an explicit approval. A decline or a dismissal fails closed with not_confirmed. If the client never answers at all — it times out, sends nothing, or sends something unreadable — that fails closed too, under the separate code confirmation_failed, so not_confirmed always means a person decided. There is no "remember my choice", so each action is confirmed on its own.

A client that cannot ask writes on the token's scope alone. One that never negotiated elicitation, one that supports only url-mode elicitation (the confirmation is a form), and one that declares elicitation and then answers the server's prompt with JSON-RPC "method not found" (an aggregator or proxy that forwards tool calls but never implemented prompts) are all clients through which no person can be asked. Refusing them would only send the same write to the REST API, which trusts the same scope with no prompt at all, so the tool proceeds and the audit row records that nobody answered: unconfirmed:no_elicitation, or unconfirmed:client_error:-32601 with the client's error code. The write tools are listed to every client. What still fails closed is a prompt that was shown and went unanswered — a timeout, a dropped connection, an unreadable form, any other error code, since a dialog handler that throws on dismissal reports one — because a person may be looking at that dialog; those come back as confirmation_failed with the way out spelled out, so a model reading the error does not retry a prompt nobody can answer.

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 client software as name/version from its initialize handshake, prefixed by the OAuth client's registered name when the token came from OAuth (Composio (mcp/0.1.0), since an aggregator reports only its SDK's default name), the tool name, what it acted on, the outcome (success / denied / error), and a detail that leads with the refusal code and adds its reason where there is one (not_confirmed:declined, confirmation_failed:timed_out, and for invalid_argument the rejection text the caller was shown). When a write went ahead without a person answering a prompt, the row says so: unconfirmed:no_elicitation or unconfirmed:client_error:-32601 on a success row, and the same appended after ; on an error or denied row that got past the gate unasked and then failed, so "did I approve this?" has an answer either way. "What it acted on" is the id for most tools, the created monitor's name, address, interval and bound channels for create_monitor, the old → new pairs for update_monitor, the window's times, monitors and paging for create_maintenance, and the names of the changed fields for update_maintenance; a refused call records what identifies the attempt plus, for invalid_argument, the rejection text, which may name the value that was refused (a host, a resolver, a region) but never a credential. Customer-facing incident text is never recorded — not a public title, a description, an update message, nor an ack/resolve note. The same event is emitted to tracing. Reads are not audit-logged (they're side-effect-free and already rate-limited). Rows are kept for retention.mcp_audit_days (2 years by default), then deleted by the daily retention job.

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} resolves through the existing *.{DOMAIN} wildcard record (no new DNS) and has its own Caddy site block with a per-host cert. The app serves only /mcp and /.well-known/* on that host; the operator UI, API and OAuth endpoints answer 404 there. 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.

Aggregators: Composio, Zapier MCP, Pipedream

These sit between the model and this server: the platform registers itself as the OAuth client, holds the token, and forwards each tools/call. Add the server by URL in the platform's custom MCP screen, choose OAuth, and approve on the consent screen as with any client. Two things differ:

  • No confirmation prompt. The platform declares no elicitation, so every write runs on the scopes granted at consent and is audited as unconfirmed. Pick the access level with that in mind; Manage monitors is enough for an agent that creates and tunes monitors.
  • The tool list is cached. A connection made before this server gained a tool keeps the old list until the platform re-syncs it (Composio: the app's page → Sync). A missing write tool after connecting is usually this.

Composio in particular needs an auth config and a connected account per project, and its own tool search will suggest its Better Stack toolkit when this server is not in the session's toolkit scope.

Cursor, VS Code (OAuth)

Add the server by URL, https://mcp.{DOMAIN}/mcp, in the editor's MCP settings. The editor registers itself, opens the login + consent screen in your browser, and returns to the editor once you approve (Cursor on its cursor:// scheme, VS Code on a loopback port). No token to paste.

Grok (OAuth, shared client id)

Grok's custom connector (grok.com → Connectors → New Connector → Custom) does not register itself: its form asks for a client id. Paste the shared one below. Publishing it is safe because it is a public client with no secret, the only redirect on record is grok.com so the code can go nowhere else, and PKCE lets only the Grok session that started the flow redeem it. As with any client, approve only a connection you started yourself. Consent, scopes, org binding and the expiry picker are the same as for any other client.

FieldValue
MCP server URLhttps://mcp.uptimepage.dev/mcp
Client IDump_DWhz9cACC1T6Tr-5oEw2qw
Authorization endpointhttps://app.uptimepage.dev/oauth/authorize
Token endpointhttps://app.uptimepage.dev/oauth/token
Token auth methodNone (PKCE)
Scopestargets:read status_page:read incidents:read, plus a write scope only if Grok should act

Save, log in, approve the consent screen (it names grok.com as where you return to), and the tools appear in the chat.

Self-hosted: register your own client once and paste the returned client_id into the same form, with your own hosts for the two endpoints. Approve it once right after: a client nobody has approved within a week of registering is dropped.

curl -X POST https://app.{DOMAIN}/oauth/register \
  -H 'Content-Type: application/json' \
  -d '{"client_name":"Grok","redirect_uris":["https://grok.com/connectors-oauth-exchange-code/"]}'

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. curl declares no elicitation capability, so a write called this way runs on the token's scope with no prompt and is audited as unconfirmed:no_elicitation; the same as calling the REST API with that token.

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) (state is folded across probe regions under the monitor's region policy, so degraded covers both a degraded check result and a failure in too few regions to count as down)
  • "How has the checkout API done over the last 7 days?" → get_monitor_history(window=7d)
  • "Is it down everywhere or only from Singapore?" → get_monitor_history(window=24h), then region=apac-sg
  • "Why does this check treat a 301 as a failure?" → get_monitor (reads follow_redirects + expected_status)
  • "Which tags am I using?" → list_tags → list_monitors(tag=…)
  • "Why did the login check fail last night?" → get_flow_runs(window=24h)
  • "Is any step of the sign-in getting slower?" → get_flow_step_trend(window=30d)
  • "What incidents are open, and what's been posted on them?" → list_incidents → get_incident
  • "What broke last week?" → list_incidents(state=all, from=…, to=…)
  • "How often did checkout fail this month?" → list_incidents(state=all, monitor_id=…)
  • "Acknowledge the payments incident — we're investigating." → acknowledge_incident(phase=investigating) (asks you to confirm)
  • "Put the payments outage on our status page." → publish_incident (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)
  • "We upgrade the database tonight from 22:00 to 23:00 UTC; don't page anyone for it." → create_maintenance (asks you to confirm, naming the monitors and the status pages it shows on)
  • "The upgrade is done, end the maintenance." → update_maintenance(end_now=true) (asks you to confirm)
  • "Checkout is flapping — make it wait for three failures before paging." → update_monitor(alert_confirmations=3) (asks you to confirm, showing 2 → 3)
  • "Webmail keeps bouncing — show it as one incident until it has been up ten minutes." → update_monitor(recovery_period_secs=600)
  • "Stop paging until two regions agree it's down." → update_monitor(region_policy={mode:"count",count:2}) (asks you to confirm)
  • "Watch https://shop.example.com and page me if it stops returning 200." → create_monitor (runs the check once, shows you the result, asks you to confirm)
  • "Set up monitors for the endpoints in this repo." → the model reads the project, then calls create_monitor per endpoint, each with its own trial run and confirmation

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 come only from the access level the user picks at consent, and each write is confirmed where the client can ask and audited either way.
  • 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.