Skip to main content
Glama

zoneedit-mcp

MCP server for the DNS records of the domains on one ZoneEdit account, read and changed through the ZoneEdit control panel (cp.zoneedit.com) in a headless Chrome driven by Patchright. Only the panel login is configured: the domains, record types, form fields, option lists, TTL labels, editor settings and name servers are read from the account on every call. Streamable HTTP, bearer-gated, in the same shape as the sibling amt-mcp.

Documentation

Document

For

This README

What the server is, the tools, the environment, deploy and the redeploy runbook.

Operator's guide

Reading and changing records safely, step by step.

Install guide

Prerequisites, vault entries, the stdio and HTTP runs, and the install check.

Troubleshooting

Symptom, cause and action for every refusal, login fault and run fault.

Glossary

The terms used above.

CLAUDE.md

Guardrails for coding agents working in this repo.

LICENSE

DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE, version 2 (WTFPL).

Update the guides when a tool, an argument, a refusal code, a dependency, an environment variable or a vault entry changes. The same commit that changes the code changes the guide.

Related MCP server: browser-control-tencent-cloud

Where it runs

  • Container zoneedit-mcp on a NAS, deploy directory /volume1/docker/zoneedit-mcp, published on the NAS loopback 127.0.0.1:8768 only (layout and build in Deploy).

  • Clients reach it at https://zoneedit.example.net/mcp: a LAN-only DSM reverse-proxy rule on 443 (the LAN Only access profile) in front of the loopback port, and a dnsmasq LAN name zoneedit.example.net pointing at the NAS. Claude Code registers it in the workspace .mcp.json as an http server with headersHelper (mcp_admin_headers.py), so no agent handles the fleet token.

  • Auth: every tools/call needs the fleet mcp-admin-token bearer. initialize and tools/list need no token and return only names, descriptions and the server version. Prove the gate with a tokenless tools/call, never with tools/list.

  • Proven live (2026-11-01 and 2026-11-02): login with the Authenticator code page, the three-page read, zoneedit_domains, zoneedit_schema (31 types), and write PREVIEWS of A, AAAA, TXT, MX, CAA, SRV, URL, DMARC, PARK and SSHFP against the live schema (nothing saved). Proven live on version 1.1.1 only: a TXT add and its delete, and a CNAME add and delete. The write path was rebuilt after that (schema read, generic field fill, row-set verification), so no version since 1.1.1 has a confirmed live write of any type. Not proven live: zoneedit_record_update, a confirmed write of any type on the current code, a write of the apex A, NS, SOA or DYN, a further row beside an existing host and type, and whether the panel keeps every other row's hash across a write. The verification assumes it does, and hashes are only known to be stable across logins. The first confirmed write needs one operator-approved throwaway record.

  • Running version: the repo is ahead of the container. On 2026-11-02 the repo __version__ was 1.9.1 and the NAS container ran 1.1.1. Nothing newer is deployed, and the repo has no git remote, so nothing is pushed. Read the running version back with initialize (see the runbook).

Tools

Tool

Title

What it does

zoneedit_records

ZoneEdit Records

Reads every DNS page and returns the records. Optional host filter (@ for the apex). Returns coverage fields: read, panel_total, pages_read, pages_expected, complete, matched, and a warning when the read is incomplete or empty.

zoneedit_selftest

ZoneEdit Self-Test

Logs in once (answering the second factor from the TOTP seed when asked) and reads DNS page 1. Returns login, dns_page, panel_total, pages, bulk_save_off, second_factor (answered when the code page was met, not_asked for a password-only login, unknown) and seed (present or absent after an answered code page, else not_read), or {ok: false, error_type}. error_type is second_factor_required (no usable seed), second_factor_rejected (the code page still shows after the one submit), login_latched (a failure in the last 900 s blocks new attempts), login_failed, RunDeadlineExceeded, RunBusy, or a type name.

zoneedit_domains

ZoneEdit Domains

Lists the account's domains from the panel's domain list: domains, default (set only when the account has exactly one), count, total and complete (the list holds all of the "out of N total").

zoneedit_schema

ZoneEdit Record Schema

Reads one domain's record types from the add form, each with its fields (name, label, kind, required, disabled, placeholder, select options), plus ttl_options, the editor settings and an unsettled list. Takes domain and an optional record_type. Selects types and reads the form, never submits. About 65 s for all 31 types, a few seconds for one. Refusals: bad_domain, unknown_domain, domain_required, unknown_record_type.

zoneedit_2fa_status

ZoneEdit 2FA Status

Reports the second-factor state without logging in and without a browser: seed (present, absent or invalid, by name only), last_second_factor, latch_seconds_left, last_submitted_step, step_seconds_left, epoch. Never the seed or a code.

The five read tools are read-only (readOnlyHint: true). zoneedit_records also takes an optional domain that must be on the account's list. With an empty domain the account's only domain is used. The state they report lives in the server process and is cleared by a container restart.

Write tools

Tool

Title

What it does

zoneedit_record_add

ZoneEdit Add Record

Adds one record of any type zoneedit_schema lists. The apex A, NS and SOA steer the whole zone and DYN rows belong to update clients, so the tool text says to preview those first. Adds a further row beside any of the same host and type, such as a second MX or TXT. Refuses on any CNAME conflict.

zoneedit_record_update

ZoneEdit Update Record

Changes one row read in the same call, identified by exact expect_content and/or expect_hash.

zoneedit_record_delete

ZoneEdit Delete Record

Deletes one row read in the same call, matched by host, type and exact expect_content and/or expect_hash.

zoneedit_2fa_latch_clear

ZoneEdit Clear Login Latch

Clears the 900 s login latch so the next login may run. Previews the seconds left unless confirmed=true. Touches no panel and no DNS. Clear it only after the cause of the failure is checked, because a repeated failed login can lock the panel account.

Where the guard lives. Mutations are guarded by the tool text, the annotations and the confirmed write guard, never by refusing a record type or host. zoneedit_record_update and zoneedit_record_delete declare destructiveHint: true. The apex A, NS and SOA steer the whole zone and a DYN row is kept fresh by update clients, so each write tool's text says to preview those first and confirm only on the operator's word. No refusal depends on a policy about a record type or host. The refusal codes are of four kinds: input checks (bad_type for a type the live schema does not list, bad_host for a label that is not a valid host name, empty_content, bad_ttl, bad_prio, bad_caa, bad_field, unknown_field, field_disabled, bad_option, missing_field, bad_domain, unknown_domain, domain_required), row matching (not_found, content_mismatch, ambiguous, bad_hash, no_change), panel state (schema_unavailable, incomplete_read, bulk_save_on_or_unknown, step_failed), and the two that keep the panel from replacing a row (cname_conflict, replace_prompt_shown).

Parameters: domain (a domain of the account, see zoneedit_domains; empty uses the account's only domain and is refused with domain_required when there are several), type (any record type from zoneedit_schema), host (bare label, @ for the apex; a type whose schema marks the host optional, such as DMARC, takes "" or @ for an empty host), expect_content and/or expect_hash (update and delete: the row's current content or hash from zoneedit_records, matched exactly), new_content and new_ttl/new_prio (update only), content (for CAA the panel string <flag> <tag> <provider>; otherwise the value of the content field), ttl (a panel TTL label such as 1 hour), prio, fields (any other form field by its schema name, for example proto, weight, port, policy, https; select values take an option's text or the part before -; a checkbox takes true or false), and confirmed (default false). A disabled form field accepts only the panel's own default.

Every write previews unless confirmed=true. A record write's preview logs in and reads the panel and changes nothing. The latch clear's preview does not log in and only reports the seconds left. A confirmed write re-reads every panel page, and ok is true only when that re-read shows the change. Refusal codes: bad_type, bad_host, empty_content, bad_caa, bad_prio, cname_conflict, not_found, content_mismatch, ambiguous, no_change, incomplete_read, bulk_save_on_or_unknown, bad_domain, unknown_domain, domain_required, schema_unavailable, bad_field, unknown_field, field_disabled, bad_option, bad_ttl, missing_field (with the names), bad_hash, step_failed (with the step, before the final click). A visible Confirm button in the panel stops the run (replace_prompt_shown).

DNS read-back (dns_readback, dns_matches) queries the zone's own name servers (the apex NS rows of the editor, else dig +short NS) with the system dig (/usr/bin/dig on macOS). It is informative and never changes ok. Without dig the write still verifies and dns_readback is {"error": "dig_unavailable"}.

Behaviour notes

  • A write that fails after the final Save or Delete click returns outcome: "unknown" (with message and error_type); read the zone before retrying.

  • A confirmed write is verified by a panel re-read: the row set must show exactly the change (one new row and no other row lost for an add, the target gone and nothing else lost for a delete, nothing else lost and one row of that type and host for an update), no pending row, and the requested content and TTL where the type has them.

  • A preview reads the type's form from the panel (it clicks the add button and selects the type, and submits nothing).

  • Records carry pending: true when shown as queued, queued for deletion or revoked; a pending match never verifies a write.

  • selftest bulk_save_off is read live from the checked state of #editor_bulk_save_mode, never from its static value attribute.

  • Every browser run has a deadline. One run at a time holds the browser; a caller waits up to 170 s for it (RunBusy after that). A run still going at 150 s has its browser processes killed and raises RunDeadlineExceeded, which is never a credential failure. A confirmed write cut by the deadline returns outcome: "unknown": read the zone before retrying.

  • The browser user agent is taken from the installed Chrome (user_agent_for), never HeadlessChrome.

  • The login latch (LoginLatched, 900 s) is in process memory. zoneedit_2fa_status shows it and zoneedit_2fa_latch_clear clears it.

Environment

Variable

Purpose

MCP_ADMIN_TOKEN_HASH

SHA-256 hex of the fleet mcp-admin-token. Required over HTTP; unset or malformed refuses every token.

ZONEEDIT_TRANSPORT

http (default) or stdio. Only an explicit stdio skips the bearer gate.

ZONEEDIT_BIND_HOST

Bind address, default 127.0.0.1.

ZONEEDIT_PORT

Bind port, default 8768.

ZONEEDIT_ALLOWED_HOSTS

Comma-separated extra Host names accepted behind an ingress.

ZONEEDIT_SECRETS_CLI

Path to a vault CLI script used to read the panel credentials. The server runs python <cli> get <entry> and reads the value from stdout, raw or as JSON. In the container compose sets it to /opt/node-vault-mcp/services/secrets.py.

ZONEEDIT_CHROME_ARGS

Extra Chromium flags, whitespace-split, read at call time. Compose sets --no-sandbox --disable-dev-shm-usage.

ZONEEDIT_ADMIN_DEPLOY_DIR, ZONEEDIT_ADMIN_SSH_ENTRY

For deploy/zoneedit_admin.py only (run on the machine that holds the main vault): the NAS deploy directory and the vault entry holding the NAS ssh_cmd. For this deployment /volume1/docker/zoneedit-mcp/deploy and nas-ssh; ZONEEDIT_SECRETS_CLI there is the main vault's secrets.py.

The panel credentials are the vault entries zoneedit-panel-username and zoneedit-panel-password. The panel's Google Authenticator seed is the vault entry zoneedit-panel-totp-seed (raw text, or a JSON object with a seed or value field). All three are read in-process at call time and never returned. The seed and each six-digit code are never a tool argument, logged, returned or echoed. The ZoneEdit code page was met and answered live on 2026-11-02: zoneedit_selftest reported second_factor: answered and seed: present. A live selftest shows that state every time.

Layout

  • zoneedit_gate.py: bearer gate (fail closed).

  • zoneedit_vault.py: vault read through the CLI, errors name the key only.

  • zoneedit_panel.py: pure HTML parsing of panel pages (stdlib only).

  • zoneedit_totp.py: RFC 6238 codes and the seconds left in a step (pure, stdlib only).

  • zoneedit_session.py: the only Patchright importer; login, the one second-factor answer, the failure latch, pagination, the domain list and domain open (read_domains, open_domain), the schema reads (schema_open, read_schema), browser lifecycle, the run deadline and the kill of a stuck run's browser processes.

  • zoneedit_tools.py: records, domains, schema, selftest, twofa_status, twofa_latch_clear and the three write wrappers as plain functions over a runner.

  • zoneedit_writes.py: write plan rules (pure), panel page actions, re-read verification and DNS read-back.

  • zoneedit_server.py: FastMCP assembly, tool registration, HTTP and stdio startup.

  • deploy/verify_endpoint.py: tokenless liveness check of a mount (bare GET 406, serverInfo, tool count, gate refusal). tests/test_verify_endpoint.py pins its constants to the server.

  • tests/test_conformity.py: conformity check on the tool list a client receives (list_tools()). Parameters are documented in the schema, not the docstring.

  • tests/real_browser.py: the shared offline real-Chrome fixtures. tests/test_schema_dom.py runs the domain and schema reads against a synthetic editor page whose type forms re-render after a delay.

  • tests/test_clickpaths.py: the shipped add, update, delete and pagination steps against the synthetic test pages in a real headless Chrome with the network off. Small scripts in tests/fixtures/*.html stand in for the panel's own JavaScript. One strict xfail records that the add button scan takes the last match.

  • tests/test_dynamic_writes_dom.py: schema-driven add and update through a real offline Chrome against tests/fixtures/write_model.html.

  • tests/test_no_zone_in_code.py: keeps the zone name and the DNS editor URL out of the eight serving modules.

  • tests/test_session_hardening.py, tests/test_totp.py, tests/test_2fa_tools.py: the run deadline and kill, the login and second factor, and the second-factor tools, all offline with fakes.

Running

cd <path to this repo>
PYTHONDONTWRITEBYTECODE=1 python3 -m pytest -p no:xdist -q   # python3 must be 3.11 or newer

The tests use the synthetic pages in tests/fixtures/ (invented data on example.com, written for the tests) and fake runners. They need no network and no vault. Three files start a real Chrome (channel chrome, headless, every request aborted except the synthetic pages): tests/test_clickpaths.py, tests/test_schema_dom.py and tests/test_dynamic_writes_dom.py. They skip themselves when Chrome cannot launch. The full run takes about 2 minutes. For the fast offline run add --ignore=tests/test_clickpaths.py --ignore=tests/test_schema_dom.py --ignore=tests/test_dynamic_writes_dom.py (391 tests, about 10 seconds, measured 2026-11-02).

To serve over HTTP:

MCP_ADMIN_TOKEN_HASH=<sha256 of the token> ZONEEDIT_SECRETS_CLI=<path to secrets.py> \
  python3 zoneedit_server.py   # 3.11 or newer; the panel login needs Patchright and Chrome installed

Versioning

The version of record is __version__ in zoneedit_server.py, plain semver MAJOR.MINOR.PATCH, and pyproject.toml carries the same string. build_server sets it on the low-level server, so initialize reports it as serverInfo.version (the SDK would otherwise report its own version). It is never derived from a library or package metadata.

  • Bump with every change to a serving module (the modules the Dockerfile copies into the image). New tool or new behaviour is MINOR, a fix is PATCH, a removed or renamed tool or a changed parameter or return shape is MAJOR. The owner judges each step. A docs-, test- or deploy-file-only change needs no bump.

  • Gate: hooks/version_bump_gate.py is a repo-local pre-commit hook that blocks a commit staging a serving file while __version__ is unchanged against HEAD. Install it once per clone with python3 hooks/version_bump_gate.py --install.

  • Commit subject ends with the version, for example Add update proof, 1.1.0. There are no tags and no changelog; the commit is the release, and a push stays operator-gated.

  • Read the running version back after every deploy: POST an initialize to the container (loopback on the NAS) and check serverInfo.version equals __version__ at the deployed commit. The image tag stays zoneedit-mcp:local.

Deploy

Files under deploy/ and the root Dockerfile and .dockerignore. They hold no secret, and the container publishes loopback only (127.0.0.1:8768).

  • Build: copy deploy/site.vars.example to deploy/site.vars and fill it in, then docker compose --env-file ./site.vars -f compose.yaml up -d --build from deploy/. The image installs dnsutils (for dig) and Google Chrome (patchright install --with-deps chrome), and runs as uid 10001 inside the image.

  • NAS layout: the repo-root files (Dockerfile and the eight modules) live in /volume1/docker/zoneedit-mcp. compose.yaml, site.vars, zoneedit-mcp.env, vault/ and node-vault-mcp/ live in /volume1/docker/zoneedit-mcp/deploy. ZONEEDIT_ADMIN_DEPLOY_DIR is that deploy path, so vault sync writes deploy/vault/, which compose mounts at /vault (the dedicated vault is /vault/zoneedit-mcp.vault beside its key file). node-vault-mcp/ must hold services/secrets.py and services/vault_secrets/, because compose mounts it at /opt/node-vault-mcp and reads /opt/node-vault-mcp/services/secrets.py.

  • Build time: the Chrome layer took about 55 minutes on the NAS. Build with --progress plain.

  • Env file: deploy/zoneedit-mcp.env (mode 0600) holds MCP_ADMIN_TOKEN_HASH=<sha256 of the fleet token>. Never the token itself.

  • Vault sync: deploy/zoneedit_admin.py vault sync rebuilds the NAS vault in vault/ with only the three entries (zoneedit-panel-username, zoneedit-panel-password, zoneedit-panel-totp-seed), values passed over stdin. Sync prints only the three key names. It depends only on the main vault and the NAS deploy directory and does not check the container, which does not exist on a fresh deploy until compose has started it. Run vault status after the container starts; that is the only command that needs the container (docker exec). Requires ZONEEDIT_SECRETS_CLI, ZONEEDIT_ADMIN_DEPLOY_DIR and ZONEEDIT_ADMIN_SSH_ENTRY.

  • Chrome args: ZONEEDIT_CHROME_ARGS (whitespace-split, read at call time) is passed to Chromium. The compose file sets --no-sandbox --disable-dev-shm-usage. The container sets shm_size: 512m.

Runbook: redeploy after a change to a serving module

Scope. Rebuild and restart the NAS container zoneedit-mcp with new code. The serving modules are the eight zoneedit_*.py files the Dockerfile copies. A docs- or test-only change needs no redeploy. A Dockerfile or deploy/compose.yaml change needs the rebuild but no version bump (they are not serving modules), so Verify 2 cannot show it took: rely on the image id in Verify 1. A restart cuts any tool call in flight, and a write cut after its final click ends outcome: "unknown": confirm no write is running first, and read the zone after the restart if one was.

Preflight.

  1. Tests pass on the commit you mean to ship, __version__ was bumped (the bump gate enforces it), and the tree is clean.

  2. Reach the NAS through the nas-ssh vault entry, one attempt (the lockout rule in the Isaac safety rules): resolve its ssh_cmd inline, never print it. Every remote command needs PATH=$PATH:/usr/local/bin for docker.

  3. Compose names the image zoneedit-mcp:local (deploy/compose.yaml). Record the running state: docker inspect -f '{{.Image}}' zoneedit-mcp and the version a tokenless initialize reports.

  4. Keep a rollback copy (before step 1 below): docker tag zoneedit-mcp:local zoneedit-mcp:prev, and cp -p each module you are about to replace to <module>.prev.

Procedure.

  1. Ship each changed module by writing it beside the old one and renaming over it, so a dropped connection never leaves a half file: cat > .<module>.new && mv -f .<module>.new <module> in /volume1/docker/zoneedit-mcp, with the file on stdin. Compare sha256sum on the NAS with the local file.

  2. From /volume1/docker/zoneedit-mcp/deploy run docker compose --progress plain --env-file ./site.vars -f compose.yaml up -d --build. With the Chrome layer cached it finishes in under a minute. If it starts rebuilding the apt or Chrome layer it takes about 55 minutes: let it finish, never kill it.

  3. A change to deploy/zoneedit-mcp.env or site.vars needs the same up -d, because compose reads them when it creates the container.

Verify (all of these before saying it worked).

  1. docker ps --filter name=zoneedit-mcp shows Up, published on 127.0.0.1:8768 only, and the image id differs from the one recorded in preflight.

  2. Read the version back: POST initialize to http://127.0.0.1:8768/mcp from the NAS (headers Content-Type: application/json, Accept: application/json, text/event-stream). serverInfo.version must equal __version__ at the shipped commit.

  3. A tokenless tools/call of zoneedit_records returns an isError result saying the call was refused and nothing ran.

  4. sha256sum of all eight modules on the NAS equals the repo at the shipped commit.

  5. Through the registered client, call zoneedit_selftest once: login ok, panel_total equal to the live count, bulk_save_off true. The container log then shows tool=zoneedit_selftest principal=fleet.

  6. From the repo on any LAN machine, run python3 deploy/verify_endpoint.py. It checks the public mount https://zoneedit.example.net/mcp with no bearer. Four lines must read ok: bare GET 406, serverInfo equal to __version__, 9 tools, tokenless call refused. A 404 means the DSM rule is gone and a 421 means a host mismatch. Pass a URL to check another mount.

  7. On the machine that holds the main vault, from the repo, run python3 deploy/zoneedit_admin.py vault status (the three variables are in Environment): both entries on_nas true and matches true.

Failure branches.

  • Any Verify step fails (version, sha256, image id, a tokenless call not refused, a verify_endpoint.py line not ok, bulk_save_off false, a panel_total that differs from the panel): do not report done. Roll back, then report the failing step to the operator.

  • Build fails: read the last 30 lines of the compose output, fix it in the repo and ship again; never hand-edit files on the NAS.

  • Container loops with refusing to start the HTTP mount: zoneedit-mcp.env lost its MCP_ADMIN_TOKEN_HASH. Restore the line from the amt-mcp env file on the NAS (the fleet hash is shared) without printing it, then up -d.

  • selftest returns login_failed: stop and do not retry. A repeated failed login can lock the panel account, and the failure latches new logins for 900 s. Run vault status (on the machine that holds the main vault, with the three ZONEEDIT_* variables from Environment): on_nas false means run vault sync; matches false means the main-vault credential changed, so run vault sync. Report to the operator before any further login.

  • selftest returns second_factor_required: the login reached the code page but no usable seed is in the vault (zoneedit-panel-totp-seed missing or unreadable on the NAS). Run vault status; if the entry is absent, run vault sync. Do not retry the login. Report to the operator.

  • selftest returns second_factor_rejected: the one TOTP submit did not pass the code page. Stop and do not retry; the seed may be wrong or the panel may have changed. The server latches new attempts for 900 s. Report to the operator before any further login.

  • selftest returns login_latched: a login or second-factor failure happened in the last 900 s. Nothing reached the panel. Read the cause first with zoneedit_2fa_status. Wait out the latch, or clear it with zoneedit_2fa_latch_clear only after the operator agrees. A restart also clears it.

  • selftest or a read returns RunDeadlineExceeded or RunBusy: the run exceeded 150 s and its browser was killed, or another run held the browser for 170 s. Nothing is a credential failure. Wait a minute and try once. A confirmed write that ends outcome: "unknown" needs a zone read before any retry.

  • Roll back: docker tag zoneedit-mcp:prev zoneedit-mcp:local, restore each module with mv -f <module>.prev <module>, then up -d without --build (compose reuses the re-tagged zoneedit-mcp:local). Rerun Verify against the previous commit and image id: the version in Verify 2 and the checksums in Verify 4 are now the previous ones.

Done. Verify 1 to 7 all pass, the running version is recorded in the tracker's receipt, and the change is committed (a push still needs the operator's word).

Update this section when the Dockerfile COPY list, deploy/compose.yaml, the NAS directory layout, the DSM rule or the verification tools change. tests/test_deploy.py pins the Dockerfile and compose facts this section relies on.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables controlling isolated headless browser sessions through MCP tools for navigation, inspection, JavaScript evaluation, input actions, screenshots, and multi-tab session management.
    9 npm
    1
    MIT