vitalcheck-mcp
v0.12.0
Published
VitalCheck MCP server — lets AI agents drive the platform (status, monitors, host agents, alerts) over a scoped API key.
Readme
vitalcheck-mcp
MCP server for VitalCheck — lets AI agents (Claude Code, Claude Desktop, any MCP client) drive your monitoring: read org health, manage monitors, configure host agents over the air, and manage alerting. Authorization is enforced server-side by your API key's scopes, so the AI can only do what the key allows.
Setup — one command
Create an API key in VitalCheck → Security → API keys and tick the scopes the AI may use — the key-created screen shows this command ready to copy, key baked in:
claude mcp add vitalcheck \
-e VITALCHECK_URL=https://app.vitalcheck.io \
-e VITALCHECK_API_KEY=vck_… \
-- npx -y vitalcheck-mcpWorks on every OS. The key lives only in your MCP config after this — revoke it in VitalCheck anytime to cut the AI off.
Any other MCP client works with the equivalent config:
{
"mcpServers": {
"vitalcheck": {
"command": "npx",
"args": ["-y", "vitalcheck-mcp"],
"env": {
"VITALCHECK_URL": "https://app.vitalcheck.io",
"VITALCHECK_API_KEY": "vck_…"
}
}
}
}Scopes
Pick per key, least-privilege:
| Scope | Grants |
| --- | --- |
| read:status | read monitors, agents, events, org summary (most tools need this) |
| manage:monitors | create / edit / delete monitors, trigger checks |
| manage:agents | enroll hosts, edit OTA metric configs, rename |
| manage:alerts | notification channels + alert rules |
| manage:secrets | list / create / delete probe secrets (never read a value) |
| ingest:events | record deploy / custom events with record_event |
A tool the key can't use returns a clear 403 … lacks scope … the AI can relay.
Tools
Read: org_status, list_monitors, monitor_history, monitor_forecast,
list_dynamic_alerts, list_agents, list_events, list_captures,
explore_factors, explore_pivot, list_alert_rules, list_channels,
org_settings, list_secrets, preview_expression, summary_panels,
list_summary_dashboards
Manage — create_monitor, create_derived_monitor, update_monitor,
delete_monitor, bulk_tag_monitors, run_check, enroll_agent,
set_agent_metrics, rename_agent, test_agent_metric,
capture_host_evidence, create_alert_rule, set_dynamic_alert,
update_org_settings, create_secret, delete_secret,
create_summary_dashboard, update_summary_dashboard,
delete_summary_dashboard
Record: record_event
Host evidence
list_captures returns what the agent recorded on the host when a cpu, memory,
disk or load metric breached: top processes, memory, disk, and a diff against the
last healthy snapshot. Ask by monitorId when that metric is the one that
breached; ask by agentId with a from/to bracket for anything else, since an
availability or custom monitor never captures and only the host timeline can say
what was running. top holds executable names only, never arguments.
capture_host_evidence asks an agent monitor's host for one now: it collects on
its next check-in and is then readable with list_captures.
Deploy markers
record_event puts a release on the timeline, which is what lets you read a
spike against the deploy that caused it. The event becomes a row in Activity and
a marker tick on every chart of any investigation covering that moment.
record_event
type: "deploy" title: "checkout: switch to the pooled client"
sha: "b861e7d" version: "v1.4.0" ref: "main"
env: "production" actor: "ci" outcome: "success"
ts: "2026-09-02T10:11:12Z"
url: "https://github.com/acme/app/actions/runs/1234"Call it at the end of your own deploy pipeline, or right after the AI ships something. Events are append-only, so a wrong title cannot be corrected later.
Explore
explore_factors answers what the things that break have in common. For every
tag value it compares that value's share of the incidents against its share of
the fleet, and lift is the ratio: a lift of 4 means four times more incidents
than the group's size predicts. Rank by lift, never by raw count.
explore_factors
window: "7d" count: "breach"
→ { "incidents": 61, "subjects": 44,
"factors": [{ "key":"team", "value":"payments", "incidents":22,
"incidentShare":0.36, "fleetShare":0.09, "lift":4 }],
"anchors": [{ "id":"deploy", "label":"Deploys", "count":9 }] }anchors is what the window actually has to anchor against, so call again with
one of those ids to test whether the incidents cluster in the lag minutes
after the event: the anchor block then carries after, afterShare,
timeShare and its own lift. Ids are none (the default), deploy, agent
(agent restarts), custom:<title> and monitor:<uuid>. The deploys
record_event puts on the timeline are what make the first of those useful.
explore_pivot crosses any two dimensions against a measure. A dimension is a
tag key or hour (hour of day in tz), and the measure is incidents,
minutes, monitors or after:
explore_pivot
window: "30d" rows: "team" cols: "hour"
measure: "incidents" tz: "Europe/Athens"
→ { "cells": [{ "r":"payments", "c":"03", "value":7, "subjects":0 }],
"rowTotals": {…}, "colTotals": {…}, "total": 61 }Hours are zero-padded strings ("03"), so they sort like every other axis
value. subjects is the fleet population behind a cell, and it is 0 on an hour
axis: a monitor does not live in an hour.
The empty-string value means untagged. Margins come back computed in rowTotals
and colTotals, so don't add the cells up yourself. Each axis is capped at its
first 40 values, so on a wider tag key total counts only the cells on the grid
and can be lower than the incidents in the window. rows and cols must differ
(refused before the request), and an unknown tag key is a 400.
Alert rules
create_alert_rule matches events on the stream and delivers to channels. Scope
by tagFilters rather than monitorId wherever you can, so monitors tagged
later are covered without touching the rule.
create_alert_rule
name: "prod breaches → on-call"
eventTypes: ["state_transition"] minSeverity: "error"
tagFilters: { "env": ["prod"] } channelIds: [...]
throttleSec: 600 notifyOnRecovery: truenotifyOnRecovery (default true) sends a resolved notice when a monitor this
rule fired for returns to ok, so an incident closes itself instead of leaving
someone watching a screen for the all-clear. Set it false for a rule whose
firing is not an incident anyone waits out.
Dynamic alerts
A dynamic alert fires on where a metric is heading, not on where it is. After three days of history every numeric monitor gets a profile:
- fill moves in a straight line toward its bad threshold, the way a disk fills or a queue grows. The question is when it arrives.
- cycle repeats a daily shape, the way traffic or CPU does. The question is whether it is outside the range that shape predicts.
- none fits neither, and pending has not been profiled yet.
Five tools:
| Tool | Does | Scope |
| --- | --- | --- |
| list_dynamic_alerts | the whole org at once: watched, suggested, openEpisodes | read:status |
| monitor_forecast | profile, reason, ETA, shadow count, and the {ts,mid,lo,hi} curve | read:status |
| set_dynamic_alert | enabled, mode: "live", horizonHours (1 to 720) for one monitor | manage:alerts |
| org_settings | reads dynamicAlerts, the workspace switch | read:status |
| update_org_settings | flips dynamicAlerts for the whole org (admin) | manage:alerts |
monitor_forecast monitorId: "…" window: "24h"
→ { "profile":"fill", "reason":"climbs 2.6 %/day toward 90 %",
"enabled":false, "mode":"shadow", "expectedBreachAt":"2026-09-11T04:00:00Z",
"horizonHours":168, "shadowFired":0, "curve":[…] }
set_dynamic_alert monitorId: "…" enabled: trueStart from the fleet, not a monitor id, when the question is "what are we watching" or "what should we watch":
list_dynamic_alerts
→ { "watched":[{ "name":"disk / on web-1", "unit":"%", "profile":"fill", "mode":"shadow",
"expectedBreachAt":"2026-09-11T04:00:00Z", "shadowFired":2 }],
"suggested":[…], "openEpisodes":1 }One request for the whole org. suggested is what set_dynamic_alert is for,
and openEpisodes counts the watched monitors alerting right now: an anomaly
that has not cleared, or an ETA inside the horizon. These are the three lists
the Forecast tab shows in the app.
Shadow first. Enabling starts a 7 day shadow period: events are still
written, but with severity info, a shadow flag and the title prefix
Would alert: , and nothing is delivered to a channel. Read what the alert
would have said, then pass mode: "live" to end the shadow early, or leave it
and the worker goes live by itself after 7 days.
The two event types are forecast_breach (on trend to reach the bad threshold
inside the horizon) and forecast_anomaly (outside its usual daily range for
two readings in a row). Both are matchable by create_alert_rule, and both
close themselves with a recovery event. Shadow events are info, so a rule at
the default warn floor ignores a monitor until its alerts go live.
A 409 from set_dynamic_alert is the API refusing on purpose:
Not enough data to forecast this monitor yet means the profile is pending or
none, so wait rather than retry, and Dynamic alerts are off for this
workspace means the org switch is off.
Bulk tagging
Tags are what make a fleet filterable, alertable (create_alert_rule scopes by
tag) and groupable, so retagging in bulk is usually step one:
list_monitors search: "checkout"
bulk_tag_monitors
monitorIds: [...] set: { "team": "payments", "env": "prod" }merge (the default) upserts those keys and leaves the rest alone; remove
strips keys; mode: "replace" makes set the whole tag map and drops
everything else. Agent-shipped monitors are retagged through their metric
def, so the tag survives the host's next check-in instead of reverting.
The call returns { updated, unchanged, skipped, agentDefsUpdated, ... } and a
200 does not mean everything changed — anything unwritable lands in skipped
with a reason (not_found, agent_unresolved, agent_inherited). Read it.
Summary boards
summary_panels answers fleet questions without saving anything: describe the
panels, get the numbers back. This is how you get a rollup sliced by tag —
one vitality per env, per team, per region — which org_status cannot do.
summary_panels
window: "30d" tags: { "env": "prod" }
panels: [{ "id":"by-team", "type":"tag_breakdown", "w":8, "h":2,
"scope":{}, "groupBy":"team", "metric":"all", "sort":"vitality" }]When a layout is worth keeping, save it with create_summary_dashboard — a
board is org-wide, and isDefault picks the one the Summary screen opens on.
| Widget type | Its own fields |
| --- | --- |
| vitality | compare, display number|dial |
| status_counts | show |
| tag_breakdown | groupBy (required), metric, sort, limit, includeUntagged |
| incidents_chart | minSeverity, types, showTotal |
| top_offenders | minSeverity, limit |
| monitor_chart | monitorIds (1–6, required), historyWindow, mode, showThresholds |
| monitor_stat | monitorId (required), stat, historyWindow, sparkline |
| monitor_list | sort, limit, columns |
| event_feed | minSeverity, types, limit |
| matrix | rowKey + colKey (must differ), cellMetric |
| text | body, emphasis |
Every widget also takes id (your slug, unique on the board), title, w
(1–12), h (1–6), window, and a scope of { tags, source, agentId, search,
status } that is ANDed with the request-level tags. Panels that cannot be
answered come back as { id, error } inside a 200; the rest still resolve.
update_summary_dashboard's widgets replaces the whole array — read the
board with list_summary_dashboards first, edit that array, send it back.
Secrets
Secrets let a probe authenticate, so you can monitor an internal API instead of only public endpoints. Store one, then reference it from an HTTP probe header:
create_secret
name: "OPS_TOKEN" value: "<the token>" description: "read-only ops API"
create_monitor
name: "internal api health"
probeConfig: { "type":"http", "url":"https://internal.example.com/health",
"headers": { "authorization": "Bearer ${secret.OPS_TOKEN}" } }The monitor stores the reference; the worker substitutes the real value only while sending the request.
There is no tool that reads a secret's value, and there never will be. The
API has no endpoint that returns one — a leaked key still cannot exfiltrate a
credential. list_secrets gives you names, descriptions and usage counts, which
is everything you need to write a reference. delete_secret is refused (with the
monitors named) while something still references it, unless you pass force.
Derived monitors
A derived monitor computes its value from other monitors on an interval — e.g. an error-rate % from an error counter and a request counter. Preview the expression against live values first, then create:
preview_expression
inputs: [{var:"a", monitorId:"<errors-id>"}, {var:"b", monitorId:"<requests-id>"}]
expression: "(a / max(b, 1)) * 100"
→ { "value": 0.42, "kind": "number", "env": { "a": 3, "b": 714 } }
create_derived_monitor
name: "checkout error rate" unit: "%"
inputs / expression as above intervalSec: 60
valueType: "numeric" warn: 1 bad: 5 dir: "high"Inputs are existing non-derived monitors (up to 8, vars a–h; find ids with
list_monitors). Expressions support arithmetic, comparisons, && || !,
if(cond,a,b), min max avg abs round floor ceil clamp, and string helpers
concat str num — categorical/boolean results pick their threshold shape via
valueType.
