Changelog
[0.6.0] - 2026-09-07
Security
- The API token can no longer be quoted back by the HTTP layer. A token with a line break in it — a paste wrapped by a terminal — reached undici, whose refusal is
Headers.append: "Bearer <the whole token>" is an invalid header value., and the generic error path answered the tool call with it. Verified on Node 24.5.0. The shape is now checked at startup (trimmed first, so$(cat token)still works) and again before every request; neither message quotes the value, and the startup one names the length and the position of the offending character instead. - Every response is shape-checked at the boundary instead of being cast. The output schemas required the keys the API promises (
zones,zone,rrset,zonefile), and anything that is not the API — a reverse proxy, a WAF, a 204, a base URL one character off, a body that is not JSON — made the server fail its own schema. On SDK 2.0 that answersisError: truewithOutput validation error for tool …: no cause, no partial answer, the whole call. A newsrc/boundary.tsdecides per field what an unusable value means, drops list entries that are not objects and says how many, and explains a thin answer underunexpected_response. - A confirmation now binds every field its call will write.
create_rrsetandadd_recordsraise the dialog on the record list and also writettl(and, forcreate_rrset,labels), so a token issued for one TTL executed with any other — and a week-long TTL on a record somebody else added is how long the correction takes to reach the caches. Both are in the key and in the dialog now. create_zoneasks when it carries content. It acceptsprimary_nameserversand azonefile— the same two payloadschange_primary_nameserversandimport_zonefileraise a dialog for — and applied them on the first call, soHETZNER_DENY_TOOLSon either of those two removed a name rather than the capability. Creating an empty zone is still additive and still asks nobody.- A primary nameserver has to be an address.
addresswas free text while the schema said "IPv4 or IPv6"; it is checked withnet.isIPbefore the dialog is raised. - The status is decided before the body, and both are read under a ceiling. A reverse proxy answering
401with a large login page used to surface as a size complaint — no status, so no credential hint, and a model retries what it was never told had failed on the credential. Success bodies are capped at 16 MiB and refused above it, error bodies at 64 KiB and cut. - The fence cannot be closed by what it fences.
JSON.stringifyleaves<,>and/alone, so a TXT record whose value was</untrusted-data>ended the fence early and everything after it read as this server's own words. - The generic error path no longer speaks in somebody else's words. undici quotes the header value it refused and Node's TLS layer quotes the subject alternative names of whatever answered on the port; both used to reach the model as this server's message. Every such message is now stripped of control characters, made well-formed and cut.
- Credential keys are matched by suffix rather than by an exact list. A key is redacted when its normalised name ends in
password,passwd,passphrase,secret,token,apikey,privatekey,tsigkey,credentialorcredentials, sogit-passwordis caught as readily aspassword. In the DNS part of the APItsig_keyis still the only such field; the rule is for the one that gets added later. - A
__proto__key survives the result walk.JSON.parseproduces it as an ordinary own property and a label key is caller-chosen; rebuilding objects without[name] = …ran the prototype setter instead, so the field vanished from the answer and the copy's prototype was replaced, with no error anywhere. ELICITATIONis no longer echoed raw. It is unprefixed and sits one line fromHETZNER_API_TOKENin every compose file, and a value pasted into the wrong line is exactly what fails that parse. Only a short word is quoted now; anything else is described by its length.HETZNER_API_BASE_URLis parsed rather than pasted. A query string or fragment used to be kept and glued in front of every path; only the origin and path are used now, and what was dropped is named. The trailing-slash strip is an index walk instead of/\/+$/, which was quadratic — 1626 ms at 80 000 slashes with a character behind them, measured.- 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 resource key is built with
orderedResourceKeyfrom mcp-approval, so position is part of the key by construction rather than by the character set the parts happen to use. actions/dependency-review-actionon pull requests.npm auditchecks the tree as it is; this checks the change.- The publish job installs with
--ignore-scripts. It holdsid-token: writefor npm Trusted Publishing, so a dependency's install hook would have run with the OIDC token available; nothing in the tree needs one.gh release creategained--verify-tag. - yarn is removed from the runtime image beside npm and corepack. It lives in
/opt, so the line that namednode_modulesand/usr/local/binmissed it.
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. 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. Results carry anuntrustedmarker, but that is read after the fact — this is the channel a model sees before it calls anything. - 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. - Bounds on every caller-supplied value, taken from the API's own specification where it states one: 255 characters for a zone name, 50 records per record action, 63 for a label value, and so on.
test/harness.ts, a shared client whoseconnectlists the tools once. A client only validatesstructuredContentagainst a schema it has loaded, so before this no success path in any suite had ever run that check. Its absence was the reason the tool reference could not be checked against the code either; that test is back, and it found three tools that ask a person and were not marked.test/shape.test.ts,test/hardening.test.tsandtest/linear-time.test.ts: a property test that feeds generated bodies to every read tool through a connected client, the credential and untrusted-content assertions, and a timing table that holds every pattern at its ceiling.
Changed
- The tool reference marks the
essentialpreset and the tools that ask a person before they act, per tool rather than only in the introduction. A test keeps both sets in step with the code. homepageinpackage.jsonpoints at the documentation site rather than at the README anchor on GitHub. It is what npm shows next to the package, and every one of these servers has had a documentation site for weeks.- Source maps are no longer published in the npm tarball. Node reads them only under
--enable-source-maps, which nothing here sets, and the maps pointed at asrc/this package does not ship — so a stack trace under that flag named a file nobody could open.dist/**/*.jsis unchanged; the package is about a fifth smaller. inlineSourcesis off again. It was this package's own answer to that same problem — embed the sources so the shipped maps are self-contained — and it worked, at the cost of the largest map payload in the family. Now that the maps do not ship at all it has nothing left to do, and one family should not carry two answers to one question.export_zonefilehands over the zone file itself, cut once at 150 000 characters with the document's real length named. The general 4000-character per-value cap is right for a record value inside a listing and wrong for the field that is the answer — about a hundred records — so the tool had been answering with a fragment of every real zone, and a second cut then reported the length of the first cut rather than of the document.hintForexplains409,429,502,503and504. A rate limit and a zone with an action already running both used to read as an unexplained failure, and the one thing a model reliably does with one of those is try again.- SECURITY.md, the README and the guides say which tools ask and when, what the tool filter can and cannot promise, and that adding an
Arecord does not raise a dialog — a real gap, left open deliberately, named rather than left to be discovered. targetis ES2024, forString#toWellFormedon every string that leaves.
Removed
src/resource-key.tsand the unusedtextResulthelper. The fingerprint it provided isorderedResourceKey's job now, and the fleet standardises on the library rather than the library on the server.
[0.5.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.All twenty-two carry
untrusted: trueandsource: "hetzner-cloud-api"as fields — there is no exception list, because record values, comments, labels and zone files are written by whoever controls the zone and no tool here answers with anything else. The<untrusted-data>fence stays in the text block, where it is the readable presentation of the same marker.The API documents are described as open objects with the top-level keys the spec guarantees. A strict shape would turn a field Hetzner adds into a tool that fails outright, since the SDK validates each result against its schema.
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, whereHETZNER_READ_ONLYright beside it deliberately acceptstrue,1andyes: this 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 afterHETZNER_API_TOKENis wiped from the environment, so that exit cannot leave the token behind.A
docs/guide/approval.mdpage.
Changed
The advertised schemas avoid a spelling that is legal JSON Schema and still gets 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. What the tools accept and return is unchanged; only the way the schema says so is.An over-budget result is an error rather than a document cut off mid-string. That was fine for a text block and is impossible for
structuredContent, which has to parse, and the two channels have to carry the same value.The two-call
confirm_tokenprompt is an error result. What was asked for did not happen, which is whatisErrorsays. The text is unchanged and still carries the token.The confirmation gate is drawn around authority, not around loss. It used to be "eight of the 22 tools can take a name off the internet"; that is the right question for a file and half of it for a zone. The dangerous act in DNS is making a claim, not withdrawing one, and none of these removes anything:
- an
MXat preference0beside the real one wins all mail, because senders try ascending preference (RFC 5321 §5.1) and the existing record stays - an
NSon a subname creates a zone cut, and the parent starts issuing referrals for everything beneath it - a
TXTat_acme-challengeis a valid DNS-01 response — RFC 8555 §8.4 says the CA verifies "one of" the records, so an added one is enough for a publicly trusted certificate - a
CAAdecides which authority may issue at all
create_rrsetandadd_recordstherefore ask when the type isNS,DS,MX,CNAME,CAA,TLSA,SVCB,HTTPSorSRV, when the name is the apex@, or when it contains*.www/Astill goes through untouched._acme-challengeTXTis deliberately exempt. Its whole purpose is to run unattended, and a dialog on every certificate renewal buys nothing — the confirmation cannot tell a real ACME client from a forged token.CAA(now gated) and Certificate Transparency monitoring are what defend that name, and the guide says so.- an
The dialog shows the values the call is about to write. No
requestApprovalin this repo passeddetails, so the prompt was byte-identical whether the record pointed where you meant or somewhere else: "replace the records ofwww/A— it currently holds 1 record" is true either way, and it was the only sentence a person saw.change_primary_nameserversnamed no address at all.The rule that kept them out was applied one step too broadly. Values that come back from the API are written by whoever controls the zone and stay out. Values that go into the call come from the model and are the thing being decided about.
renderDetailsprints them under "supplied by the caller, not by this server", collapsed and capped. Atsig_keyis never printed.ttlis capped at 604800 (one week) instead of the protocol maximum of 2147483647. No resolver honours more — BIND caps at a week, Unbound at a day — so the larger values buy nothing except recovery time for whoever set them: a record served from caches long after it was removed at the authority.BREAKING: the confirmation parameter is now
confirm_token, notconfirmToken. This server was the only one of seventeen spelling it in camelCase. A call passingconfirmTokenis rejected as an unknown argument; passconfirm_tokeninstead. Nothing else about the two-call flow changed.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, the host classifier and the documentation-asset generator now come from
mcp-tool-allowlist,mcp-approval,mcp-internal-hostsandsvg-asset-setrather than from copies kept here — 863 fewer lines, and one place to fix each. None of them has a runtime dependency of its own.The first call of a guarded tool returns a plain result rather than an error result. It is a question, not a failure, and every other server in the fleet already answered it that way.
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.
Fixed
Secret redaction and the per-value truncation now run over the structured value as well. Both ran as a
JSON.stringifyreplacer, which reached every string in the document for free; a value handed over asstructuredContentis not text, so the same pass has to walk the tree. Without it the two channels of one answer would have differed in exactly the fields this server redacts — and the machine-readable one would have been the unredacted half.The annotation comment on
update_rrsetclaimed it "replaces the records of an RRSet, exactly likeset_records". It replaces an RRSet's labels — organisational metadata — and its description said so all along. The comment was on its way to earning the tool a confirmation dialog it does not need: no name goes off the internet. It stays marked destructive, because Hetzner keeps no history of labels either.Confirmation tokens are compared with a constant-time comparison. The copy in this repository used
!==, which leaks through timing how much of a guess was right. Reaching a token still requires having received it in a previous tool result, so this closes a margin rather than a hole.A
confirm_tokenthat does not match is now refused with the reason — invalid, expired, or issued for different arguments — instead of being answered with a fresh prompt. The second is self-healing when a token merely expired and silent when the token was issued for something else, which is the case the binding exists to catch.An entry in
HETZNER_ALLOW_TOOLSthat is not tool-name-shaped is now redacted in the error rather than quoted back.HETZNER_API_TOKENandHETZNER_ALLOW_TOOLSare adjacent lines in every compose file, and a paste into the wrong one used to print the credential into the client's log.
[0.4.0] - 2026-08-27
Added
HETZNER_ALLOW_TOOLSandHETZNER_DENY_TOOLSchoose which of the 22 tools are registered. Both take comma-separated tool names or a prefix with a trailing*(list_*), the allow list decides what is in and the deny list is subtracted from it, andHETZNER_ALLOW_TOOLS=essentialselects a curated eight —list_zones,get_zone,list_rrsets,get_rrset,create_rrset,set_records,delete_rrset,export_zonefile. A model picks the right tool far more reliably from eight than from twenty-two, and every visible tool costs context on every request. Nothing changes for an installation that sets neither: all 22 are still registered.A filtered tool is not registered at all, so it is absent from
tools/listand answerstools/callwith "tool not found" — the same cutHETZNER_READ_ONLYalready makes, not a second, weaker one.An entry that matches no tool stops the server at startup, naming the entry and listing the real names, rather than being ignored: an ignored typo leaves a tool missing from
tools/listwith nothing pointing at the cause. The same applies to a malformed pattern such as*_zone. UnderHETZNER_READ_ONLY, an exact write-tool name in the allow list is refused with a message naming the read-only setting instead of calling the tool unknown, while a pattern covering write tools is accepted and simply contributes nothing.
Changed
- The README now carries the same eight badges, in the same order, as every other MCP server in this family, all of them reading from npm rather than hard-coded; the opening follows one shape; and the standalone "Full documentation" line is gone, because the docs badge three lines above it points at the same page.
Fixed
- The container image no longer ships OpenSSL 3.5.7-r0, which carries CVE-2026-14456 (denial of service via unbounded memory growth). The pinned
node:24-alpinedigest is already the newest one; Alpine's fixed 3.5.8-r0 has simply not been rebuilt into it yet, so the runtime stage now upgradeslibcrypto3andlibssl3by name. Upgrading those two rather than running a blanketapk upgradekeeps the rest of the image exactly as the digest pins it. The step can go once the base image ships the fix.
[0.3.3] - 2026-08-26
Changed
- Whether
HETZNER_API_BASE_URLcounts as local — which decides both whether plainhttpis allowed at all and whether a host other thanapi.hetzner.cloudis accepted — is now decided by the same host classifier the other MCP servers in this family use, insrc/hosts.ts, instead of a list of three exact spellings.127.0.0.2,sub.localhost,localhost.andhttp://[::ffff:127.0.0.1]are just as local as the three that were listed, and the API token stays on the machine in every one of those cases. The token is still refused over plainhttpto anything that is not.
[0.3.2] - 2026-08-24
Fixed
- The zone apex was unreachable. Eight of the RRSet tools put the name into the URL path —
get_rrset,update_rrset,delete_rrset,set_records,add_records,remove_records,change_rrset_ttlandchange_rrset_protection— and every one of them ran it throughencodeURIComponent, which turns the apex name@into%40. The Hetzner Cloud API does not decode that:GET /zones/example.com/rrsets/%40/Aanswers 404not_foundwhile the RRSet exists andlist_rrsetsreturns it. The A, AAAA, MX and TXT records of the domain itself could therefore be listed but neither read nor changed, andadd_recordsfailed with a 422 about invalid syntax. RFC 3986 permits@in a path segment, so the name now goes into the path verbatim. Wildcard names were never affected —encodeURIComponentleaves*alone.
Changed
rrsetPath()validates the segments it is handed instead of escaping them. The character set —[A-Za-z0-9@*._-], no slash, no percent sign, no bare.or..— was always what kept a request inside the intended endpoint;encodeURIComponenthad nothing left to escape on top of it except the one character it broke. That guard used to live only at the tool boundary and is now re-checked where the path is built, so a future call site cannot skip it.list_rrsetsnow documents that itsnamefilter accepts@for the zone apex. The filter always worked — it is a query parameter, not a path segment — but nothing said so.
[0.3.1] - 2026-08-18
Fixed
- The architecture diagram no longer depends on the reader's operating system. It carried a
prefers-color-schemeblock, which resolves against the OS rather than the theme toggle of GitHub or npm — so dark-mode readers on a light OS got the light artwork on a dark page. The README now uses<picture>, which is resolved against the page, and the<img>that npm falls back to brings its own card instead of a media query. docs/.vitepress/config.tspointedog:imageat/og.png, which did not exist — the documentation site had no link preview at all. The file is generated now.
Changed
- The diagram is generated from a single source,
docs/assets/architecture.source.svg, bynpm run assets. The four rendered copies had already drifted apart; CI now fails if one of them is edited by hand. docs/public/og.pngis generated at exactly 1280x640, GitHub's recommended size for a social preview, instead of being drawn by hand.
[0.3.0] - 2026-08-16
Added
Dockerfile(multi-stage, non-root, stdio entrypoint) and.dockerignore, so registries that build and introspect the server in a container no longer have to guess a build.- Multi-arch container images (amd64/arm64) at
ghcr.io/ni-c/hetzner-dns-mcp, published from CI with an SBOM and max-mode build provenance and scanned with Trivy on every push and pull request.server.jsonlists the image as an OCI package, so the MCP registry offers it alongside the npm package. HETZNER_READ_ONLY=trueregisters only the seven read-only tools. The write tools are not registered at all rather than rejected when called, so there is no code path from a write request to the API.- Documentation site at hetzner-dns-mcp.ni-c.de with a guide, the full tool reference and the security model.
Changed
- A missing
HETZNER_API_TOKENno longer exits at startup. The server completes the MCP handshake and lists its tools without credentials; the token is required when a tool actually calls the API, which then fails with the same setup instructions as before. Base URL validation still exits, since a bad base URL can leak the token. - Breaking: the
confirmboolean is gone. Destructive tools now take an optionalconfirmTokeninstead — see below. Callers that passedconfirm: truewill be refused and handed a token to call again with. engines.noderaised to>=22; Node 20 is end-of-life and is no longer in the CI matrix. The container has been on Node 24 all along.- Published source maps embed their sources, since only
dist/is shipped and the maps previously pointed at asrc/that is not in the tarball.
Security
Destructive tools require a server-issued confirmation token. Every irreversible tool refuses its first call and returns a random, single-use token with a five-minute lifetime; a second call must repeat the identical arguments and pass it. For
set_records,remove_records,import_zonefileandchange_primary_nameserversthe token is bound to a SHA-256 fingerprint of the payload, so a confirmation for one record list cannot write a different one.The previous
confirmboolean was a value the model set itself, while the refusal messages pasted the current RRSet contents back as raw API JSON. Together that was a self-approving loop: an instruction hidden in a TXT record value or a zone-file comment arrived verbatim in the very message asking for confirmation. A token cannot be produced that way, because it only ever exists in a previous result from this server.Removing protection now counts as destructive.
change_zone_protectionandchange_rrset_protectionhad no guard at all, so unprotecting a zone and deleting it was two uninterrupted calls; disabling protection is now gated exactly like the deletion it enables, while enabling it stays immediate.Confirmation messages no longer quote anything read back from the API. They report record counts and TTLs only.
API responses are wrapped in an
<untrusted-data>envelope, keys matchingtsig_key,token,secret,passwordorcredentialare redacted, single values are truncated at 4 000 characters and whole results at 200 000.Upstream error bodies are truncated at 2 000 characters and HTML error pages — a reverse proxy or WAF in front of the API — are dropped entirely instead of being pasted into the model's context.
HETZNER_API_TOKENandHETZNER_API_BASE_URLare deleted from the environment once read, so a later crash report or diagnostic dump cannot expose them. An unparseable base URL is no longer echoed back, since it can contain auser:token@part.mcp-publisheris pinned to a release and verified against its SHA-256 before it runs. It was fetched from/releases/latestunverified, in a job holdingid-token: write.The runtime image no longer ships npm, npx or corepack; they are never invoked there, but their vendored dependencies kept appearing in scans.
CI additionally runs CodeQL and a Trivy scan of the image for both architectures.
[0.2.3] - 2026-08-13
Changed
- zod updated to v4 (the MCP SDK supports
^3.25 || ^4.0);z.record()now uses the explicit two-argument form. - Dev dependencies updated: TypeScript 6 (
@types/nodeis listed explicitly in the tsconfigtypesfield, as TS 6 no longer auto-includes@typespackages),@eslint/js10 (matching ESLint 10).
[0.2.2] - 2026-08-11
Added
- Listed in the official MCP Registry as
io.github.ni-c/hetzner-dns-mcp; the release workflow publishes registry updates automatically via GitHub OIDC (server.json,mcpNamefield).
Changed
- Dev dependencies updated: vitest 4 (+ matching
@vitest/coverage-v8), ESLint 10,@types/node26; GitHub Actions pins bumped to current major versions. Coverage thresholds rebased to vitest 4's stricter AST-based measurement.
[0.2.1] - 2026-08-11
Added
- Release workflow: pushing a
vX.Y.Ztag runs the tests, publishes to npm via Trusted Publishing (OIDC, with provenance) and creates a GitHub release with the notes from this changelog. - CI: test matrix extended to Node 24, coverage report (thresholds enforced, uploaded as artifact), weekly
npm auditjob, Dependabot for npm packages and pinned GitHub Actions.
[0.2.0] - 2026-08-11
Changed
- Renamed the package from
mcp-hetzner-dnstohetzner-dns-mcp. set_records,remove_recordsandchange_primary_nameserversnow requireconfirm=true(like the other destructive tools) and report the current state when refusing;change_primary_nameserversis annotated as destructive.
Security
HETZNER_API_BASE_URLis validated: https only (http allowed for localhost), URLs with embedded credentials are rejected, and a warning is printed for non-default hosts, since the API token is sent there.zoneand RRSetnameparameters are restricted to a safe character set and./..are rejected, preventing URL path traversal out of the intended API endpoints.- API requests no longer follow redirects and time out after 30 seconds.
- Fatal errors log only the error message instead of the full stack trace.
- CI: workflow token restricted to
contents: read, actions pinned to commit SHAs.
[0.1.0] - 2026-08-06
Added
- Initial release targeting the DNS endpoints of the Hetzner Cloud API (
api.hetzner.cloud/v1, Bearer token authentication). The legacy DNS API (dns.hetzner.com) is not supported. - Zone tools:
list_zones,get_zone,create_zone,update_zone,delete_zone(guarded by aconfirmparameter),export_zonefile,import_zonefile(guarded by aconfirmparameter),change_zone_ttl,change_zone_protection,change_primary_nameservers. - RRSet tools:
list_rrsets,get_rrset,create_rrset,update_rrset,delete_rrset(guarded by aconfirmparameter),set_records,add_records,remove_records,change_rrset_ttl,change_rrset_protection. - Action tools:
list_zone_actions,get_zone_action. - Configuration via
HETZNER_API_TOKEN, optionalHETZNER_API_BASE_URL.