Skip to content

Changelog ​

[Unreleased] ​

Fixed ​

  • A __proto__ key in an instance's answer is dropped, at every depth. It used to reach the text block but vanish from structuredContent, because the client parses that against the output schema and zod sets a prototype on that name instead of a field. Both channels now carry the same document.

[0.3.0] - 2026-09-07 ​

Added ​

  • The server introduces itself in full. title, description, websiteUrl and icons now travel with name and version, so a client that shows a server to a person has something to show: the human name, the one-line description, the documentation site and the project's own icon. All four were already in server.json for the registry and reached no client at all; a test compares the two so they cannot drift.
  • Server instructions. The results of this server carry an untrusted marker, but that is read after the fact — this is the channel a model sees before it calls anything, and it says where the content comes from and what the two properties of Healthchecks are that surprise people.
  • An OpenSSF Scorecard run, weekly and on every push to main, reporting into the Security tab next to CodeQL and Trivy. The badge is the second in the row.

Changed ​

  • The loopback check behind the plain-HTTP warning comes from mcp-internal-hosts instead of a copy in config.ts. Same classifier the rest of the family uses, same behaviour — one fewer place to keep 25 lines of hand-written IPv6 normalisation correct.

Security ​

  • mcp-approval 0.8.2. A sealed dialog answer is single-use since 0.8.1: the same requestState presented again within its lifetime used to be accepted again, and with a resource key that is the same every time — a whole stream, a fixed set of targets — every replay landed. npm users on ^0.8.0 already had the fix; the Docker image is built from the lockfile and carried 0.8.0 until this release.

  • Every response from the instance is read through a boundary instead of being cast. A name the instance sent as a number, an n_pings of 1e999 (legal JSON, Infinity after parsing, refused by zod), a grace of null, a null where a check belonged: each of these did not spoil one field, it failed the whole call — the SDK validates structuredContent against the declared output schema, so a listing lost four hundred good checks over one bad one, and get_check on a body of null answered Cannot read properties of null. Every field the schemas name now goes through src/boundary.ts: the wrong type is absent and the check keeps its row, while an entry that is not an object at all is counted in note rather than dropped in silence. A uuid that is not shaped like one is absent too — it is spliced into request paths and quoted into the sentence a person reads before approving a deletion.

  • The API key can no longer be quoted back by the HTTP layer. A 32-character key with a line break inside it — a wrapped paste — passed the length check, reached undici, and came back as Headers.append: "<the key>" is an invalid header value. through the generic error path into the model's context. The key is trimmed and shape-checked at startup and again before every request; the message names the variable, the position and the length, and never the value.

  • Control characters and lone surrogates are removed from everything the instance wrote. A marker says what the text is; it does nothing about an escape sequence repainting the log of whoever reads the client's output, or about a lone surrogate that makes a Python client raise on encoding. Both channels are cleaned by src/clean.ts, format characters are kept because they are content in a name, and get_ping_body and get_status report control_characters_removed — a job that prints colour is the one plausible source, and also the least controlled text this server touches.

  • The status of a failed request is decided before its body is read. A 401 answered with a two-megabyte login page — what a reverse proxy or an SSO portal sends — surfaced as "the response exceeds the 5 MB ceiling": the size, not the status, and no hint about the credential. Error bodies now have their own 64 KiB ceiling that cuts instead of refusing.

  • npm ci --ignore-scripts in the release job, which holds id-token: write for npm Trusted Publishing and until now let every dependency's install hook run while an OIDC token was available. gh release create gained --verify-tag, and pull requests gained actions/dependency-review-action.

Fixed ​

  • The result budget cuts many candidates per round instead of one, and descends into nested objects. Twenty thousand fields cost 63 seconds to give up on; they now cost 23 milliseconds. A long description under check — which is where every write tool puts one — was never reached at all, so the tool threw instead of shortening it. list_badges answers a project with thousands of tags by showing the first 500 and saying so; the document is six short URLs per tag, which nothing in the budget can shrink.

  • Trailing slashes in HEALTHCHECKS_URL are trimmed by walking an index rather than with /\/+$/, which is quadratic and cost 1.8 seconds on 80 000 of them.

  • Startup diagnostics describe what they cannot quote. A 56-character hexadecimal key with a colon after it is a valid URL whose scheme is the key, so (got ${protocol}) printed a credential; ELICITATION is unprefixed and sits in the same block of every compose file as the key, and printed its value in full.

  • delete_check names the check the caller asked about and the dialog announced, rather than the identifier the instance echoed back.

  • list_integrations declares the note field it can return. Its output schema is closed, so a client that had loaded tools/list refused the whole result — which nothing noticed, because the test harness never listed tools. It does now, on every connection, so every success path runs the client-side schema check.

  • The runtime image no longer ships yarn, corepack or a lockfile nothing reads.

[0.2.0] - 2026-09-03 ​

Added ​

  • Every tool declares an outputSchema and answers with structuredContent beside the text block. A client no longer has to parse prose to use a result.

    Every tool that reports anything from the instance carries untrusted: true and source: "healthchecks" as fields, not only as a preamble in the text — a client that reads the structured half would otherwise get a logged ping body, written by whoever knows a ping URL, with no framing at all. get_api_key_info is without it, and get_status carries it only when the instance answered something other than OK. That conditional is the point: a plain OK is this server's own sentence, and a marker on everything is a marker that means nothing.

    Fields this server builds are described exactly; a check record is left open, because normalizeCheck passes through whatever a self-hosted release chose to add and the SDK validates every result against its schema before it goes out.

  • Tools that need a confirmation now ask the user, on clients that can show a prompt. The two-call confirm_token remains for clients that cannot, so nothing that works today stops working — but where a person can be asked, one is, instead of a token that only proves the same call was made twice.

  • ELICITATION switches the dialog off — false sends a client that could have been asked down the two-call-token path instead. For a scheduled job or a test harness, where a dialog is the wrong shape rather than an unwanted one.

    It does not remove the guard: there is no setting in which a guarded call goes unannounced. Two deliberate rough edges come with it. The variable is not prefixed, so one export ELICITATION=false reaches every MCP server in the environment — which is why a server started with it off prints a line saying so, and why the fallback text names the server instead of blaming a client that was working fine. And a value that is neither true nor falsestops the server: it is the only variable here that defaults to on, so failing open on a typo would leave the dialog running while the operator believed it was off. It is read after HEALTHCHECKS_API_KEY is wiped from the environment, so that exit cannot leave the key behind.

  • A docs/guide/approval.md page.

  • A test that asserts the promise this server would least like to break: every outgoing request, across every tool in the catalogue, starts with ${site}/api/v3/. Ping URLs live outside that prefix, so a tool that gained the ability to ping — telling a monitoring system a backup succeeded when it did not, silently, because silence is what the system is watching for — would now fail the suite instead of being noticed by nobody. Five separate things held that promise up and none of them were tested.

Changed ​

  • The advertised schemas avoid spellings that are legal JSON Schema and still get a tool refused, or its constraint silently dropped, by some MCP clients: an open object now writes "additionalProperties": true rather than the empty schema {} zod emits for it; a value that was left untyped is declared as what it really is; and a nullable field is written as anyOf branches rather than "type": ["string", "null"], which several clients read as a single type and then drop. What the tools accept and return is unchanged; only the way the schema says so is.

  • A result too large to shorten is now an error rather than an envelope saying so. The envelope was a different shape from what the tool declares it returns, which the SDK refuses.

  • The two-call confirm_token prompt is an error result. The check was not deleted, which is what isError says — and a tool that declares an output schema may not answer without structuredContent unless the result is an error. The text is unchanged and still carries the token.

  • HEALTHCHECKS_READ_ONLY now accepts 1, true and yes in any case and ignores surrounding whitespace, matching the rest of the family. It only ever takes capability away, so an operator who wrote HEALTHCHECKS_READ_ONLY=True meant the safe thing and now gets it — where before that spelling silently left every write tool registered. HEALTHCHECKS_INSECURE_TLS stays exactly true on purpose: it weakens the server, so only the one unambiguous spelling should do it.

  • The shared libraries move to mcp-approval 0.7.1, mcp-tool-allowlist 0.2.1, mcp-integration-harness 0.2.0 and svg-asset-set 0.2.0.

  • Two integration assertions that could pass without the thing they name. The guard test used expectError: true plus a read-only substring — but statusHint(401) also contains "read-only", and run() appends it to any 401, so deleting the translation outright left the test green. Measured: with needsReadWriteKey removed the old assertions passed and the new ones fail on all three tools. The two "the check is really gone" checks now require HTTP 404 rather than any error, which a timeout or a 429 satisfied equally.

  • Runs on MCP SDK 2.0. Existing clients see the same protocol revision they always did; the change is the package layout behind it, and it is what lets the dialog above work on both protocol eras from one code path — including behind a stateless gateway, where the older mechanism silently fell back to the weaker token for every client.

  • The linter is oxlint instead of eslint plus typescript-eslint, which lifts the TypeScript ceiling: typescript-eslint pins typescript below 6.1, so this repository was held on TypeScript 6 by its linter rather than by its code.

  • The tool filter, the confirmation store and the documentation-asset generator now come from mcp-tool-allowlist, mcp-approval and svg-asset-set rather than from copies kept here — 752 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.

  • stdio is served through serveStdio, so the connection's era is negotiated on the opening exchange rather than assumed. A client that pins the 2026-07-28 era is served it; until now its server/discover probe was answered with "Method not found" and only 2025-11-25 was on offer. A client that speaks the older era sees no change — it is still pinned to one instance for the life of the connection, exactly as a hand-wired StdioServerTransport served it.

Removed ​

  • pause_check no longer asks for a confirmation, and no longer declares confirm_token at all — a caller that still sends one is told rather than quietly ignored.

    Pausing switches alerting off, which is quiet rather than loud, and that was the argument for gating it. But resume_check puts it back and nothing is lost in between, and a dialog in front of a reversible state change is how people learn to tick without reading — which spends exactly the attention that delete_check needs. What pausing costs is stated in the tool's description instead.

Fixed ​

  • budget could not terminate on an object of many mid-sized fields. The string pass takes the longest field over a floor of 200 characters and replaces it with 200 characters plus a note saying what was dropped. That note is about thirty characters, so a 210-character value came back out at 230 — still over the floor, still the longest field, and longer than it started. The pass took the same field again every round and the answer grew instead of shrinking. A get_check on an instance whose record carried a few thousand such fields hung the tool call rather than answering it.

    A cut is now only made when it really is a cut; when the longest field cannot be shortened profitably, nothing shorter can be either, and the budget says so with the error it already had for that case. The floor of 200 looked like the guarantee and was not one.

  • A truncated ping body ended with its own source code. The 64 KB truncation note was a single template literal with leftover " + " string concatenation inside it, so a body cut at the ceiling ended with a stray quote, a newline, fourteen spaces, + " and then the rest of the sentence.

Security ​

  • Text written by strangers came back unmarked. get_ping_body carried the untrusted-content marker from the start, and result.ts gave the reason as "above all logged ping bodies" — but the ping header comes through the same door as the ping body and had no marker at all. A ping object carries ua, the raw User-Agent of whoever pinged, kept to 200 characters upstream, plus remote_addr, scheme and method. Nothing validates a User-Agent, and a ping URL is by design sitting in a cron job on every monitored host, so this is the most widely-shared secret in the system. Fifty pings is roughly ten thousand characters of somebody else's text arriving as if the server had said it.

    The same held for check names and descriptions through list_checks, for list_flips, list_integrations and list_badges (whose URLs carry the project's tags), for every write tool that echoes the check back, and for get_status, which put up to 4 KB of an unexpected response inside a sentence of its own — on the one endpoint that takes no key, and therefore exactly where something that is not Healthchecks answers.

    Every tool now marks its result except get_api_key_info and get_status on its happy path, which report on the server's own configuration and nothing else. The unmarked renderers were removed rather than left available: an unmarked variant sitting next to a marked one is something to reach for by accident. test/untrusted.test.ts asserts this over the whole catalogue, so a tool added later cannot skip the decision.

  • A 401 was answered with "Nothing is wrong with the key itself" regardless. A 401 has at least four causes — a read-only key on a read-write endpoint, a mistyped or rotated key, a key from a deleted project, a ping key pasted in place of an API key — and that sentence is false for three of them. It is also the first thing the operator reads, so after a key rotation it sent them to enter a second wrong key and hunt for the fault where it was not: the exact confusion the translation exists to prevent, pointing the other way. The claim is now checked before it is made, by probing /checks/, which either kind of key can read. If that is refused too, the original 401 goes through with the generic hint, which names both possibilities instead of picking one.

[0.1.0] - 2026-08-29 ​

Added ​

  • Initial release: an MCP server for the Healthchecks Management API v3, against the hosted service or a self-hosted instance.
  • A multi-architecture container image at ghcr.io/ni-c/healthchecks-mcp (amd64 and arm64), published with an SBOM and build provenance. It runs as an unprivileged user with no npm in the runtime layer and speaks stdio only, so it needs -i and exposes no port.
  • Nine read tools: list_checks, get_check, list_pings, get_ping_body, list_flips, list_integrations, list_badges, get_status and get_api_key_info.
  • Five write tools: create_check, update_check, pause_check, resume_check and delete_check.
  • get_ping_body reads the body a job POSTed with its ping — the fastest way to see why a check failed, and an endpoint no other Healthchecks MCP server exposes.
  • Read-only API keys are supported end to end: checks are addressed by unique_key where no uuid is returned, and get_api_key_info reports which kind of key is configured and which tools it cannot reach.
  • HEALTHCHECKS_READ_ONLY registers only the read tools; HEALTHCHECKS_ALLOW_TOOLS / HEALTHCHECKS_DENY_TOOLS narrow the list further, with essential as a curated seven-tool preset.
  • pause_check and delete_check are two-step: the first call returns a short-lived confirmation token bound to that one check.
  • Results are budgeted — the Management API paginates nothing, so list_checks drops whole entries rather than overflowing the model's context, and says so.
  • The server cannot ping a check, deliberately. A ping is a job's own claim that it ran, and the same server hands the model ping bodies written by whatever pings the check — a ping tool would close the loop from untrusted text to forged monitoring.

Released under the MIT License.