Skip to content

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 answers isError: true with Output validation error for tool …: no cause, no partial answer, the whole call. A new src/boundary.ts decides per field what an unusable value means, drops list entries that are not objects and says how many, and explains a thin answer under unexpected_response.
  • A confirmation now binds every field its call will write. create_rrset and add_records raise the dialog on the record list and also write ttl (and, for create_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_zone asks when it carries content. It accepts primary_nameservers and a zonefile — the same two payloads change_primary_nameservers and import_zonefile raise a dialog for — and applied them on the first call, so HETZNER_DENY_TOOLS on 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. address was free text while the schema said "IPv4 or IPv6"; it is checked with net.isIP before the dialog is raised.
  • The status is decided before the body, and both are read under a ceiling. A reverse proxy answering 401 with 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.stringify leaves <, > 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, credential or credentials, so git-password is caught as readily as password. In the DNS part of the API tsig_key is still the only such field; the rule is for the one that gets added later.
  • A __proto__ key survives the result walk. JSON.parse produces it as an ordinary own property and a label key is caller-chosen; rebuilding objects with out[name] = … ran the prototype setter instead, so the field vanished from the answer and the copy's prototype was replaced, with no error anywhere.
  • ELICITATION is no longer echoed raw. It is unprefixed and sits one line from HETZNER_API_TOKEN in 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_URL is 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 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 resource key is built with orderedResourceKey from 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-action on pull requests. npm audit checks the tree as it is; this checks the change.
  • The publish job installs with --ignore-scripts. It holds id-token: write for 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 create gained --verify-tag.
  • yarn is removed from the runtime image beside npm and corepack. It lives in /opt, so the line that named node_modules and /usr/local/bin missed it.

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. 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. Results carry an untrusted marker, 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 whose connect lists the tools once. A client only validates structuredContent against 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.ts and test/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 essential preset 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.
  • homepage in package.json points 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 a src/ this package does not ship — so a stack trace under that flag named a file nobody could open. dist/**/*.js is unchanged; the package is about a fifth smaller.
  • inlineSources is 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_zonefile hands 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.
  • hintFor explains 409, 429, 502, 503 and 504. 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 A record does not raise a dialog — a real gap, left open deliberately, named rather than left to be discovered.
  • target is ES2024, for String#toWellFormed on every string that leaves.

Removed ​

  • src/resource-key.ts and the unused textResult helper. The fingerprint it provided is orderedResourceKey'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 outputSchema and answers with structuredContent beside the text block. A client no longer has to parse prose to use a result.

    All twenty-two carry untrusted: true and source: "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_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, where HETZNER_READ_ONLY right beside it deliberately accepts true, 1 and yes: 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 after HETZNER_API_TOKEN is wiped from the environment, so that exit cannot leave the token behind.

  • A docs/guide/approval.md page.

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": true rather 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_token prompt is an error result. What was asked for did not happen, which is what isError says. 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 MX at preference 0 beside the real one wins all mail, because senders try ascending preference (RFC 5321 §5.1) and the existing record stays
    • an NS on a subname creates a zone cut, and the parent starts issuing referrals for everything beneath it
    • a TXT at _acme-challenge is 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 CAA decides which authority may issue at all

    create_rrset and add_records therefore ask when the type is NS, DS, MX, CNAME, CAA, TLSA, SVCB, HTTPS or SRV, when the name is the apex @, or when it contains *. www/A still goes through untouched.

    _acme-challenge TXT is 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.

  • The dialog shows the values the call is about to write. No requestApproval in this repo passed details, so the prompt was byte-identical whether the record pointed where you meant or somewhere else: "replace the records of www/A — it currently holds 1 record" is true either way, and it was the only sentence a person saw. change_primary_nameservers named 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. renderDetails prints them under "supplied by the caller, not by this server", collapsed and capped. A tsig_key is never printed.

  • ttl is 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, not confirmToken. This server was the only one of seventeen spelling it in camelCase. A call passing confirmToken is rejected as an unknown argument; pass confirm_token instead. 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 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, the host classifier and the documentation-asset generator now come from mcp-tool-allowlist, mcp-approval, mcp-internal-hosts and svg-asset-set rather 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 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.

Fixed ​

  • Secret redaction and the per-value truncation now run over the structured value as well. Both ran as a JSON.stringify replacer, which reached every string in the document for free; a value handed over as structuredContent is 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_rrset claimed it "replaces the records of an RRSet, exactly like set_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_token that 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_TOOLS that is not tool-name-shaped is now redacted in the error rather than quoted back. HETZNER_API_TOKEN and HETZNER_ALLOW_TOOLS are 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_TOOLS and HETZNER_DENY_TOOLS choose 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, and HETZNER_ALLOW_TOOLS=essential selects 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/list and answers tools/call with "tool not found" — the same cut HETZNER_READ_ONLY already 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/list with nothing pointing at the cause. The same applies to a malformed pattern such as *_zone. Under HETZNER_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-alpine digest 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 upgrades libcrypto3 and libssl3 by name. Upgrading those two rather than running a blanket apk upgrade keeps 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_URL counts as local — which decides both whether plain http is allowed at all and whether a host other than api.hetzner.cloud is accepted — is now decided by the same host classifier the other MCP servers in this family use, in src/hosts.ts, instead of a list of three exact spellings. 127.0.0.2, sub.localhost, localhost. and http://[::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 plain http to 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_ttl and change_rrset_protection — and every one of them ran it through encodeURIComponent, which turns the apex name @ into %40. The Hetzner Cloud API does not decode that: GET /zones/example.com/rrsets/%40/A answers 404 not_found while the RRSet exists and list_rrsets returns it. The A, AAAA, MX and TXT records of the domain itself could therefore be listed but neither read nor changed, and add_records failed 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 — encodeURIComponent leaves * 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; encodeURIComponent had 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_rrsets now documents that its name filter 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-scheme block, 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.ts pointed og:image at /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, by npm run assets. The four rendered copies had already drifted apart; CI now fails if one of them is edited by hand.
  • docs/public/og.png is 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.json lists the image as an OCI package, so the MCP registry offers it alongside the npm package.
  • HETZNER_READ_ONLY=true registers 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_TOKEN no 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 confirm boolean is gone. Destructive tools now take an optional confirmToken instead — see below. Callers that passed confirm: true will be refused and handed a token to call again with.
  • engines.node raised 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 a src/ 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_zonefile and change_primary_nameservers the 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 confirm boolean 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_protection and change_rrset_protection had 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 matching tsig_key, token, secret, password or credential are 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_TOKEN and HETZNER_API_BASE_URL are 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 a user:token@ part.

  • mcp-publisher is pinned to a release and verified against its SHA-256 before it runs. It was fetched from /releases/latest unverified, in a job holding id-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/node is listed explicitly in the tsconfig types field, as TS 6 no longer auto-includes @types packages), @eslint/js 10 (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, mcpName field).

Changed ​

  • Dev dependencies updated: vitest 4 (+ matching @vitest/coverage-v8), ESLint 10, @types/node 26; 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.Z tag 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 audit job, Dependabot for npm packages and pinned GitHub Actions.

[0.2.0] - 2026-08-11 ​

Changed ​

  • Renamed the package from mcp-hetzner-dns to hetzner-dns-mcp.
  • set_records, remove_records and change_primary_nameservers now require confirm=true (like the other destructive tools) and report the current state when refusing; change_primary_nameservers is annotated as destructive.

Security ​

  • HETZNER_API_BASE_URL is 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.
  • zone and RRSet name parameters 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 a confirm parameter), export_zonefile, import_zonefile (guarded by a confirm parameter), change_zone_ttl, change_zone_protection, change_primary_nameservers.
  • RRSet tools: list_rrsets, get_rrset, create_rrset, update_rrset, delete_rrset (guarded by a confirm parameter), 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, optional HETZNER_API_BASE_URL.

Released under the MIT License.