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 fromstructuredContent, 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,websiteUrlandiconsnow travel withnameandversion, 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 inserver.jsonfor 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 anuntrustedmarker, 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-hostsinstead of a copy inconfig.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
requestStatepresented 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.0already 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
namethe instance sent as a number, ann_pingsof1e999(legal JSON,Infinityafter parsing, refused by zod), agraceofnull, anullwhere a check belonged: each of these did not spoil one field, it failed the whole call — the SDK validatesstructuredContentagainst the declared output schema, so a listing lost four hundred good checks over one bad one, andget_checkon a body ofnullansweredCannot read properties of null. Every field the schemas name now goes throughsrc/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 innoterather than dropped in silence. Auuidthat 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, andget_ping_bodyandget_statusreportcontrol_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-scriptsin the release job, which holdsid-token: writefor npm Trusted Publishing and until now let every dependency's install hook run while an OIDC token was available.gh release creategained--verify-tag, and pull requests gainedactions/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_badgesanswers 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_URLare 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;ELICITATIONis unprefixed and sits in the same block of every compose file as the key, and printed its value in full.delete_checknames the check the caller asked about and the dialog announced, rather than the identifier the instance echoed back.list_integrationsdeclares thenotefield it can return. Its output schema is closed, so a client that had loadedtools/listrefused 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
outputSchemaand answers withstructuredContentbeside 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: trueandsource: "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_infois without it, andget_statuscarries it only when the instance answered something other thanOK. That conditional is the point: a plainOKis 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
normalizeCheckpasses 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_tokenremains 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.ELICITATIONswitches the dialog off —falsesends 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=falsereaches 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 neithertruenorfalsestops 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 afterHEALTHCHECKS_API_KEYis wiped from the environment, so that exit cannot leave the key behind.A
docs/guide/approval.mdpage.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": truerather 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 asanyOfbranches 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_tokenprompt is an error result. The check was not deleted, which is whatisErrorsays — and a tool that declares an output schema may not answer withoutstructuredContentunless the result is an error. The text is unchanged and still carries the token.HEALTHCHECKS_READ_ONLYnow accepts1,trueandyesin any case and ignores surrounding whitespace, matching the rest of the family. It only ever takes capability away, so an operator who wroteHEALTHCHECKS_READ_ONLY=Truemeant the safe thing and now gets it — where before that spelling silently left every write tool registered.HEALTHCHECKS_INSECURE_TLSstays exactlytrueon purpose: it weakens the server, so only the one unambiguous spelling should do it.The shared libraries move to
mcp-approval0.7.1,mcp-tool-allowlist0.2.1,mcp-integration-harness0.2.0 andsvg-asset-set0.2.0.Two integration assertions that could pass without the thing they name. The guard test used
expectError: trueplus aread-onlysubstring — butstatusHint(401)also contains "read-only", andrun()appends it to any 401, so deleting the translation outright left the test green. Measured: withneedsReadWriteKeyremoved the old assertions passed and the new ones fail on all three tools. The two "the check is really gone" checks now requireHTTP 404rather 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
typescriptbelow 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-approvalandsvg-asset-setrather 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 the2026-07-28era is served it; until now itsserver/discoverprobe was answered with "Method not found" and only2025-11-25was 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-wiredStdioServerTransportserved it.
Removed
pause_checkno longer asks for a confirmation, and no longer declaresconfirm_tokenat 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_checkputs 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 thatdelete_checkneeds. What pausing costs is stated in the tool's description instead.
Fixed
budgetcould 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. Aget_checkon 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_bodycarried the untrusted-content marker from the start, andresult.tsgave 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 carriesua, the raw User-Agent of whoever pinged, kept to 200 characters upstream, plusremote_addr,schemeandmethod. 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, forlist_flips,list_integrationsandlist_badges(whose URLs carry the project's tags), for every write tool that echoes the check back, and forget_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_infoandget_statuson 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.tsasserts 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-iand exposes no port. - Nine read tools:
list_checks,get_check,list_pings,get_ping_body,list_flips,list_integrations,list_badges,get_statusandget_api_key_info. - Five write tools:
create_check,update_check,pause_check,resume_checkanddelete_check. get_ping_bodyreads 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_keywhere nouuidis returned, andget_api_key_inforeports which kind of key is configured and which tools it cannot reach. HEALTHCHECKS_READ_ONLYregisters only the read tools;HEALTHCHECKS_ALLOW_TOOLS/HEALTHCHECKS_DENY_TOOLSnarrow the list further, withessentialas a curated seven-tool preset.pause_checkanddelete_checkare 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_checksdrops 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.