Skip to main content
Glama
README.md
# the-real-hetzner-mcp

An unofficial local [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server
(stdio) for the Hetzner Cloud API: every operation of the vendored Hetzner Cloud API
specification as a typed tool, read and write, plus hand-tuned wrappers for the common server
operations. npm package: `@mguttmann/hetzner-cloud-mcp`.

**Unofficial project.** This server is not affiliated with, endorsed by, sponsored by or
supported by Hetzner Online GmbH. "Hetzner" is a trademark of Hetzner Online GmbH. The vendored
API specification (`specs/cloud.spec.json`) is Hetzner's and is not covered by this project's
MIT License. See [`NOTICE`](NOTICE).

## Maintenance status

- Maintained on a best-effort basis by one maintainer
  ([@mguttmann](https://github.com/mguttmann)) in spare time. There is no support contract and
  no guaranteed response time.
- Security fixes take priority over features. Report vulnerabilities privately through GitHub
  private vulnerability reporting, never in a public issue; see [SECURITY.md](SECURITY.md).
- Only the latest minor release gets fixes ([SECURITY.md](SECURITY.md) lists the supported
  versions).
- Dependency updates come from Dependabot; CI fails when a runtime dependency has a known high
  or critical advisory.
- Releases are built and published by GitHub Actions from a tag on `main`, with a CycloneDX
  SBOM, `SHA256SUMS` and build provenance ([CHANGELOG.md](CHANGELOG.md) lists the changes).
- Issues and pull requests are welcome. Tool names, parameters and environment variables are
  kept stable between releases; see [AGENTS.md](AGENTS.md) for the contract rules.

## Features

- Full coverage of the Hetzner Cloud API via codegen from `specs/cloud.spec.json` (one generated tool per operation), plus hand-tuned wrappers for the common server operations. The exact tool list and count are pinned by `tests/tools-contract.test.ts` against `tests/snapshots/tools-contract.json`.
- `hcloud_raw_request` as a generic escape hatch for any endpoint.
- Capped 429 backoff, automatic pagination with a soft-cap, action polling with a wall-clock budget (see [Timeouts and retries](#timeouts-and-retries)).
- Optional write guard `HETZNER_CONFIRM_WRITES=true`: every tool that is not read-only returns a preview (tool name and input) unless the call carries `confirm: "YES"`.
- Optional operator switches `HETZNER_READ_ONLY=true` (only read-only tools and `GET` through `hcloud_raw_request` run) and `HETZNER_DENY_TOOLS` (tool name globs that always return an error). Both leave the tool list and the schemas unchanged.

## Out of Scope

**Not covered:** Hetzner Robot (the dedicated-server API), Hetzner DNS (the standalone DNS product, a separate API from Cloud DNS) and Hetzner Storage Boxes. They are not part of the Cloud API and the codegen has no spec for them.

**Covered:** Cloud DNS zones and RRSets (`/v1/zones`) are part of the Cloud API spec, so they are in scope through the generated tools.

## Installation

Requires Node.js 22.12 or newer (`engines` in `package.json`; the CI matrix in `.github/workflows/ci.yml` tests the floor and the current Node lines).

**Do not install 0.1.x.** npm versions below 0.2.0 (0.1.0 and 0.1.1) are affected by a path-injection vulnerability that is fixed in 0.2.0, see [SECURITY.md](SECURITY.md).

### From npm (0.2.0 or newer)

The package name is exactly `@mguttmann/hetzner-cloud-mcp`; packages with similar names are not from this project.

```bash
npm install -g "@mguttmann/hetzner-cloud-mcp@>=0.2.0"
```

The version range makes npm refuse the vulnerable 0.1.x versions. If npm reports that no matching version exists, 0.2.0 is not published yet: install from a pinned git commit instead. In your MCP client config the `command` is then just `hetzner-cloud-mcp` (no path needed).

### With npx (no global install)

```bash
npx -y "@mguttmann/hetzner-cloud-mcp@>=0.2.0"
```

MCP clients start it the same way, see [Variant C](#variant-c-with-npx). Keep the version range: without it npx would take whatever version npm serves as `latest`.

### From the MCP Registry

Once listed, the server is in the official MCP Registry as `io.github.mguttmann/the-real-hetzner-mcp`, with the npm package above as its package. Clients that install from the registry use the same npm package.

### From a pinned git commit

```bash
git clone https://github.com/mguttmann/the-real-hetzner-mcp.git
cd the-real-hetzner-mcp
git checkout <full commit sha>
npm ci
npm run build
```

Pin a full commit SHA, not a branch name: the commit of the `v0.2.0` tag (`git rev-list -n 1 v0.2.0`) or a later commit on `main`. `npm ci` installs exactly the committed lockfile.

## Setup

With the npm package, set the variables in the `env` block of your MCP client configuration (see [MCP Connector Setup](#mcp-connector-setup-claude-desktop--claude-code)); the package ships no `.env` file. In a git checkout:

```bash
cp .env.example .env
# Add HETZNER_API_TOKEN to .env (Read & Write token from the Hetzner Cloud Console)
```

`npm run refresh-spec` and `npm run generate` are not part of the setup: they change the tool surface (see [Spec Updates](#spec-updates)).

The server reads its configuration from the environment. At startup it also loads the `.env` file in the package directory (next to `package.json`, `dotenv`), never one from the current working directory; variables that are already set win. A deployment can pass the file explicitly instead, from any working directory: `node --env-file=/path/to/.env dist/index.js`.

Optional smoke test against the real API (four read-only calls):

```bash
npm run test:live
```

### Configuration

Only `HETZNER_API_TOKEN` is required. The defaults below are pinned by `tests/config.test.ts`. A numeric value that is not a positive number, and an unknown boolean or log level, falls back to the default. Exceptions that stop the server at startup instead: an invalid `HETZNER_API_BASE` and an unknown `HETZNER_READ_ONLY` value.

| Variable | Default | Meaning |
|---|---|---|
| `HETZNER_API_TOKEN` | (required) | Hetzner Cloud API token, sent as a bearer token on every request. The server can do exactly what this token allows. |
| `HETZNER_API_BASE` | `https://api.hetzner.cloud/v1` | API base URL. Must be `https`; plain `http` only for `localhost`, `127.0.0.1` or `::1`. Any other value stops the server at startup. Override only for tests or a local fake upstream. |
| `HETZNER_CONFIRM_WRITES` | `false` | `true`, `1` or `yes`: every tool that is not read-only returns a preview unless the call carries `confirm: "YES"`. |
| `HETZNER_READ_ONLY` | `false` | `true`, `1` or `yes`: every tool that is not annotated read-only, and `hcloud_raw_request` with a method other than `GET`, returns `isError` without sending a request. Checked before the confirm guard. Tool list and schemas are unchanged. Any value other than `true`, `1`, `yes`, `false`, `0`, `no` or empty stops the server at startup. |
| `HETZNER_DENY_TOOLS` | (empty) | Comma-separated tool name globs (`*` any characters, `?` one character, whole name, case-sensitive), for example `hcloud_delete_*,hcloud_raw_request`. A matching tool returns `isError` without sending a request, read-only tools included. Tool list and schemas are unchanged. Patterns use the server's own `hcloud_*` names, not a gateway-prefixed name; a pattern that matches no tool stops the server at startup. Denying a tool does not block its endpoint through `hcloud_raw_request`, so deny `hcloud_raw_request` as well, otherwise every denied endpoint stays reachable through it. |
| `LOG_LEVEL` | `warn` | `debug`, `info`, `warn` or `error`. Logs are JSON on stderr; stdout carries the MCP protocol. |
| `HTTP_TIMEOUT_MS` | `30000` | Wall-clock budget for one API request, all attempts and retry delays included. |
| `HTTP_MAX_RETRY_DELAY_MS` | `10000` | Longest single retry delay. A 429 whose `Retry-After` asks for more is returned at once as an error. |
| `ACTION_POLL_TIMEOUT_MS` | `45000` | Wall-clock budget of a `wait: true` call, measured from the start of the call (write request, poll requests and their retries included). Also the ceiling for the `timeout_ms` argument of `hcloud_wait_action`. |
| `ACTION_POLL_INTERVAL_MS` | `2000` | Pause between two polls. The `interval_ms` argument of `hcloud_wait_action` cannot go below 1000 or this value, whichever is lower. |
| `PAGINATION_MAX_ITEMS` | `500` | Soft cap on the items an auto-paginated list collects; a capped list reports `truncated: true`. |
| `PAGINATION_MAX_PAGES` | `10` | Soft cap on the pages an auto-paginated list requests. |
| `MAX_OUTPUT_BYTES` | `8388608` | Largest tool result text in bytes, counted as sent (JSON-escaped). A longer result is cut and ends with `[output truncated by MAX_OUTPUT_BYTES=...]`. Values above the default are lowered to it, so one result line stays below the 10 MiB stdio limit. |
| `PAGINATION_TIMEOUT_MS` | `45000` | Wall-clock budget of one auto-paginated list. When it ends, the pages read so far come back with `truncated: true` and a `note`. |
| `MCP_SERVE_MODERN_PROTOCOL` | `false` | `true`, `1` or `yes`: also serve the MCP 2026-07-28 revision over stdio (see [MCP protocol](#mcp-protocol)). A client that opens with `initialize` gets the same 2025-era session as with `false`. |

Development only: `RUN_LIVE_TESTS=1` enables `tests/live-smoke.test.ts` (set by `npm run test:live`), and `HETZNER_LIVE_SMOKE_ANCHORS` optionally names servers that test expects to find.

### Timeouts and retries

Retries: a 429 is retried after `Retry-After`, or after a short backoff when the header is
missing (Hetzner refills one request per second; `RateLimit-Reset` is the time the bucket is
full again and is not used as a delay). A 5xx is retried with backoff, except for `POST`, which
may already have been processed. With the defaults one tool call answers within about
`max(HTTP_TIMEOUT_MS, ACTION_POLL_TIMEOUT_MS)` (`tests/call-budget.test.ts` measures this), so it
stays below a 60 s client call timeout, common in MCP clients and gateways. Keep that relation if you
raise the values. Auto-paginated lists run one request per page within
`PAGINATION_TIMEOUT_MS`; when that budget ends, the pages read so far come back with
`truncated: true` and a `note`, never as a list that looks complete.

When polling fails or the budget runs out after a successful write, the tool returns the write
response (for example the new resource id and a one-time `root_password`) with
`polling_timed_out: true`, plus `polling_error` if polling failed.

## MCP protocol

The server speaks MCP over stdio only and opens no network port.

- **Protocol revisions:** a client that asks for `2024-11-05`, `2025-03-26`, `2025-06-18` or
  `2025-11-25` gets that revision back; a client that asks for a newer one (for example
  `2026-07-28`) in `initialize` gets `2025-11-25` (`tests/server.test.ts`).
- **2026-07-28 is opt-in:** with `MCP_SERVE_MODERN_PROTOCOL=true` the server also answers a
  client that opens with `server/discover` and serves it the 2026-07-28 revision, with the same
  tools. A client that opens with `initialize` still gets the 2025-era session above
  (`tests/serve-protocol.test.ts`). The default is `false`: a client in auto mode (such as the
  python `mcp` client) would switch to the new revision as soon as it is offered, so turn it on
  only after checking the client. See `docs/adr/0001-mcp-sdk-v2.md`.
- **Message size limit:** one inbound JSON-RPC message (one stdin line) may be at most 10 MiB,
  the MCP SDK's stdio limit (`tests/transport-lifecycle.test.ts`). A longer line closes the
  transport: the server logs `mcp transport error` on stderr and exits with code 1. Keep large
  arguments (for example `user_data`) well below that size. Outbound, a tool result is cut at
  `MAX_OUTPUT_BYTES` (at most 8 MiB, `tests/output-cap.test.ts`) and then ends with an
  `[output truncated by MAX_OUTPUT_BYTES=...]` marker.

## MCP Connector Setup (Claude Desktop / Claude Code)

Append to your client's MCP config (`~/Library/Application Support/Claude/claude_desktop_config.json` for Claude Desktop, or the equivalent in Claude Code).

### Variant A: installed via npm

```json
{
  "mcpServers": {
    "hetzner-cloud": {
      "command": "hetzner-cloud-mcp",
      "env": {
        "HETZNER_API_TOKEN": "...",
        "HETZNER_CONFIRM_WRITES": "false",
        "LOG_LEVEL": "warn"
      }
    }
  }
}
```

### Variant B: from a git checkout

```json
{
  "mcpServers": {
    "hetzner-cloud": {
      "command": "node",
      "args": ["/absolute/path/to/the-real-hetzner-mcp/dist/index.js"],
      "env": {
        "HETZNER_API_TOKEN": "...",
        "HETZNER_CONFIRM_WRITES": "false",
        "LOG_LEVEL": "warn"
      }
    }
  }
}
```

### Variant C: with npx

```json
{
  "mcpServers": {
    "hetzner-cloud": {
      "command": "npx",
      "args": ["-y", "@mguttmann/hetzner-cloud-mcp@>=0.2.0"],
      "env": {
        "HETZNER_API_TOKEN": "...",
        "HETZNER_CONFIRM_WRITES": "false",
        "LOG_LEVEL": "warn"
      }
    }
  }
}
```

Restart the client to pick up the change. `tools/list` should expose the tools pinned in `tests/snapshots/tools-contract.json`, all named `hcloud_*`.

## Tools: overview

### Hand-tuned Wrappers

| Tool                              | What it does                                                                  |
|-----------------------------------|-------------------------------------------------------------------------------|
| `hcloud_list_servers`             | Server list with `name_contains` filter and default sort by `name`.           |
| `hcloud_get_server`               | Fetch a server by `id` OR `name`.                                             |
| `hcloud_server_power`             | poweron / poweroff / reboot / shutdown / reset in a single tool.              |
| `hcloud_server_rebuild`           | Rebuild a server, `image_id` OR `image_name`.                                 |
| `hcloud_server_snapshot`          | `create_image` with default `type=snapshot`.                                  |
| `hcloud_server_backup`            | Toggle automatic backups.                                                     |
| `hcloud_server_rescue`            | Toggle rescue mode, default `type=linux64`.                                   |
| `hcloud_server_change_type`       | Change server type, default `upgrade_disk=false`.                             |
| `hcloud_get_server_metrics`       | Server metrics, `id` OR `name`, default time window "last hour".              |
| `hcloud_apply_firewall_to_server` | Apply a firewall to a server, by-id OR by-name.                               |
| `hcloud_raw_request`              | Arbitrary request against the API. Query parameters go in `query`; a `path` that URL normalisation would alter, or that contains `%2F`, `%5C` or `%2E`, is rejected. |
| `hcloud_wait_action`              | Poll an action until success/error or timeout.                                |

### Generated Tools

One tool per OpenAPI operation, naming scheme `hcloud_<verb>_<resource>` or `hcloud_<resource>_<action>_action`. On name collision with a wrapper the generated tool gets the suffix `_raw` (`hcloud_list_servers_raw`, `hcloud_get_server_raw`, `hcloud_get_server_metrics_raw`).

Run `tools/list` in the Inspector to see the full list.

## Input Validation

Arguments are checked against the tool's declared `inputSchema` before the
handler runs. The low-level MCP SDK server validates only the shape of the
`tools/call` request, so this check is what makes the advertised schema binding.
A violation returns an `isError` result naming the offending property; the
handler is not invoked and no request reaches the Hetzner API.

What is enforced:

- **Resource ids must be JSON integers >= 1.** String-encoded numbers such as
  `"42"` are rejected rather than coerced. Path parameters that the API declares
  as strings (`id_or_name`, `rr_name`, `rr_type`) stay strings, but a string
  used in the path has to name a single segment, see below.
- **Top-level types, `enum`, `minimum`/`maximum`, `minLength`/`maxLength` and
  flat array elements** (element type, `items.enum`, `items.minimum`/`maximum`),
  plus `required`. For a parameter declared as an array of scalars a bare scalar
  is also accepted and checked against the element schema: for a repeatable *query* parameter such as `sort` or `status` it
  serialises to exactly the same single parameter, so rejecting it would break a
  call that has always worked. The one array-typed parameter that goes into a
  request *body*, `hcloud_server_rescue.ssh_keys`, normalises the scalar into a
  one-element array, so the value a caller sends is always used or refused, never
  silently dropped. Per-element messages for a bad array are capped at the first
  10 plus a count (`tests/validate-input.test.ts`), so a large input cannot be
  amplified into the result or the log.
- **Unknown top-level properties**, for the tools whose schema declares
  `additionalProperties: false` (all wrappers and all generated tools). The
  out-of-band keys `confirm`, `wait` and `auto_paginate` are always tolerated,
  so sending `confirm` while `HETZNER_CONFIRM_WRITES` is disabled, or `wait` /
  `auto_paginate` to a tool that does not declare them, is not an error; those
  keys are dropped before the request is built.
- **A path parameter must name exactly one path segment.** For the parameters
  the API declares as strings (`id_or_name`, `rr_name`) the value is rejected if
  it is empty, is `.` or `..`, contains `/` or `\`, or contains a
  percent-encoded separator or dot (`%2f`, `%5c`, `%2e` in any casing). Reason:
  percent-encoding keeps such a value inside one segment only as far as the
  local URL parser is concerned. The actor that resolves the path is the
  *origin*, which decodes encoded separators and resolves dot segments before
  routing, so without this check a crafted value could address a different
  resource than the tool's own. Legitimate values are untouched: IDN zone names
  (raw or punycode), a trailing dot, `@`, `*`, `*.sub`, `_dmarc` and the like all
  pass and are percent-encoded as before.
- **Path segments are percent-encoded** at every interpolation site, and the
  HTTP client refuses any path that URL normalisation would change. Rejected:
  `?` and `#` (which truncate the path into query/fragment), `\` (a path
  separator to the URL parser), characters outside RFC 3986 `pchar` such as
  control characters (which the parser strips, so they could otherwise form a
  dot segment), empty segments, and `.`/`..` segments in every spelling,
  including the percent-encoded ones. The assembled URL is then compared against
  the intended target: its origin must be the configured one and its path must
  equal the base path plus the requested path *exactly*, so any remaining
  normalisation aborts the request instead of redirecting it. This client-side
  check only sees what the local URL parser would rewrite, so it is the last
  line of defence, not the whole guarantee: the per-parameter rule above is
  what covers what the *origin* would rewrite.
- **Rejected input produces no HTTP request at all**, not a sanitised one.

What is deliberately *not* enforced: the contents of a request `body`. Those
trees are the Hetzner Cloud API's own contract: it validates them and its `400`
responses are surfaced to the caller unchanged. `format`, `pattern` and
`nullable` are not evaluated either.

Validation failures are logged with the offending property name and the *type*
of the offending value; the value itself is only quoted back to the caller, so a
malformed `body` carrying a private key or password does not end up in the log.

Note on `hcloud_raw_request`: it remains a deliberate escape hatch that accepts
any path by design, so it is not constrained to a resource whitelist. It is
pinned to the configured API base URL (origin and path prefix), but a caller
granted this tool can reach any endpoint of the Hetzner Cloud API. Like every
other tool it rejects a percent-encoded separator or dot (`%2F`, `%5C`, `%2E` in
any casing) in its `path`, because the origin, not the local URL parser, decides
what an encoded separator means. Grant `hcloud_raw_request` accordingly.

## Spec Updates

When Hetzner updates the OpenAPI:

```bash
npm run refresh-spec       # re-downloads specs/cloud.spec.json
# update the fetch date and the SHA-256 in specs/SOURCE.md
npm run generate           # rebuilds src/tools/generated/operations.ts
npm test                   # codegen and contract snapshots show the diff
npm run refresh-snapshot   # rewrite the snapshots deliberately (after review)
git add specs/cloud.spec.json specs/SOURCE.md src/tools/generated/operations.ts tests/snapshots/tool-registry.json tests/snapshots/tools-contract.json
git commit -m "chore(spec): refresh from Hetzner"
```

A refresh changes tool input schemas and can add tools, which MCP clients and gateways see as
a contract change. List it under "Behaviour changes" in `CHANGELOG.md`. If the new spec makes a
`body` field of an existing tool required, keep the old list in `PINNED_BODY_REQUIRED` in
`scripts/generate.ts`, so the tool contract does not tighten; the API still rejects a call that
lacks the field. A new tool changes the tool count and name hash of the gateway baseline,
which live in one file, `tests/gateway-baseline.json`, read by `tests/tools-contract.test.ts`
and by the CI and release smoke.

## Development

### Tool contract snapshot

`tests/tools-contract.test.ts` builds the registry exactly as the stdio server does
(`src/build-registry.ts`) and compares every tool against `tests/snapshots/tools-contract.json`:
name, `inputSchema.required`, a sha256 of the canonical (key-sorted) JSON of `inputSchema`
(with and without `HETZNER_CONFIRM_WRITES=true`), a sha256 of the description, and the
annotations. It also pins the tool count and the sha256 of the sorted tool names to the
gateway baseline; those two values live in `tests/gateway-baseline.json` and are not rewritten by
any script.

`npm run refresh-snapshot` regenerates both `tests/snapshots/tool-registry.json` and
`tests/snapshots/tools-contract.json`. Run it only for a deliberate contract change and review
the diff: a changed `required`, schema or description hash changes what MCP clients see.

### Stdio smoke test

`npm run smoke` spawns `dist/index.js` (run `npm run build` first), sends `initialize`,
`notifications/initialized` and `tools/list` over stdio, and prints the negotiated protocol
version, `serverInfo`, the tool count and the sha256 of the sorted tool names as JSON. It needs
no network:

```bash
HETZNER_API_TOKEN=dummy HETZNER_API_BASE=http://127.0.0.1:1 npm run smoke -- \
  --min-tools <n> --expect-names-sha256 <sha256>
# or with an env file, passed to node as --env-file:
npm run smoke -- --env-file ./smoke.env
```

Options: `--protocol-version <v>`, `--min-tools <n>`, `--expect-names-sha256 <hex>`,
`--baseline <file>` (sets both from a JSON file with `toolCount` and `namesSha256`),
`--server <path>` (default `dist/index.js`) and `--timeout-ms <ms>`; the defaults are at the top
of `scripts/smoke-stdio.mjs`. Exit code 0 means all checks passed, 1 a check failed, 2 the
server did not start or answer. CI runs it with `--baseline tests/gateway-baseline.json`, the file
`tests/tools-contract.test.ts` also reads.

## Manual Verification Recipe

Run before declaring a release ready:

1. `npm run inspector` opens the MCP Inspector with this server.
2. `tools/list`: verify the count equals `toolCount` in `tests/snapshots/tools-contract.json` and that every wrapper in the table above appears.
3. `hcloud_list_servers` (no args): verify against at least one known server in your account.
4. `hcloud_get_server` with `name: "<one of your server names>"`: should return id, type, ip.
5. `hcloud_create_ssh_key` with `{ name: "mcp-smoke", public_key: "<your test key>" }`: note the returned id.
6. `hcloud_delete_ssh_key` with that id: confirm clean removal.

Do **not** run power/rebuild/snapshot operations against production servers as part of a smoke test.

## Security

See [SECURITY.md](SECURITY.md) for supported versions, how to report a vulnerability privately
and the threat model (token authority, the confirm guard, grants by tool name).

## License

MIT, see [LICENSE](./LICENSE), except the vendored Hetzner Cloud API specification
(`specs/cloud.spec.json`), which is Hetzner's; see [NOTICE](NOTICE) and
[specs/SOURCE.md](specs/SOURCE.md).