zoneedit-mcp
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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. |
Reading and changing records safely, step by step. | |
Prerequisites, vault entries, the stdio and HTTP runs, and the install check. | |
Symptom, cause and action for every refusal, login fault and run fault. | |
The terms used above. | |
| Guardrails for coding agents working in this repo. |
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-mcpon a NAS, deploy directory/volume1/docker/zoneedit-mcp, published on the NAS loopback127.0.0.1:8768only (layout and build in Deploy).Clients reach it at
https://zoneedit.example.net/mcp: a LAN-only DSM reverse-proxy rule on 443 (theLAN Onlyaccess profile) in front of the loopback port, and a dnsmasq LAN namezoneedit.example.netpointing at the NAS. Claude Code registers it in the workspace.mcp.jsonas anhttpserver withheadersHelper(mcp_admin_headers.py), so no agent handles the fleet token.Auth: every
tools/callneeds the fleetmcp-admin-tokenbearer.initializeandtools/listneed no token and return only names, descriptions and the server version. Prove the gate with a tokenlesstools/call, never withtools/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'shashacross 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 withinitialize(see the runbook).
Tools
Tool | Title | What it does |
| ZoneEdit Records | Reads every DNS page and returns the records. Optional |
| ZoneEdit Self-Test | Logs in once (answering the second factor from the TOTP seed when asked) and reads DNS page 1. Returns |
| ZoneEdit Domains | Lists the account's domains from the panel's domain list: |
| ZoneEdit Record Schema | Reads one domain's record types from the add form, each with its fields ( |
| ZoneEdit 2FA Status | Reports the second-factor state without logging in and without a browser: |
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 Add Record | Adds one record of any type |
| ZoneEdit Update Record | Changes one row read in the same call, identified by exact |
| ZoneEdit Delete Record | Deletes one row read in the same call, matched by host, type and exact |
| ZoneEdit Clear Login Latch | Clears the 900 s login latch so the next login may run. Previews the seconds left unless |
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"(withmessageanderror_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: truewhen shown as queued, queued for deletion or revoked; a pending match never verifies a write.selftestbulk_save_offis read live from the checked state of#editor_bulk_save_mode, never from its staticvalueattribute.Every browser run has a deadline. One run at a time holds the browser; a caller waits up to 170 s for it (
RunBusyafter that). A run still going at 150 s has its browser processes killed and raisesRunDeadlineExceeded, which is never a credential failure. A confirmed write cut by the deadline returnsoutcome: "unknown": read the zone before retrying.The browser user agent is taken from the installed Chrome (
user_agent_for), neverHeadlessChrome.The login latch (
LoginLatched, 900 s) is in process memory.zoneedit_2fa_statusshows it andzoneedit_2fa_latch_clearclears it.
Environment
Variable | Purpose |
| SHA-256 hex of the fleet |
|
|
| Bind address, default |
| Bind port, default |
| Comma-separated extra Host names accepted behind an ingress. |
| Path to a vault CLI script used to read the panel credentials. The server runs |
| Extra Chromium flags, whitespace-split, read at call time. Compose sets |
| For |
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_clearand 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.pypins 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.pyruns 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 intests/fixtures/*.htmlstand in for the panel's own JavaScript. One strictxfailrecords 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 againsttests/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 newerThe 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 installedVersioning
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.pyis 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 withpython3 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
initializeto the container (loopback on the NAS) and checkserverInfo.versionequals__version__at the deployed commit. The image tag stayszoneedit-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.exampletodeploy/site.varsand fill it in, thendocker compose --env-file ./site.vars -f compose.yaml up -d --buildfromdeploy/. The image installsdnsutils(fordig) and Google Chrome (patchright install --with-deps chrome), and runs as uid 10001 inside the image.NAS layout: the repo-root files (
Dockerfileand the eight modules) live in/volume1/docker/zoneedit-mcp.compose.yaml,site.vars,zoneedit-mcp.env,vault/andnode-vault-mcp/live in/volume1/docker/zoneedit-mcp/deploy.ZONEEDIT_ADMIN_DEPLOY_DIRis thatdeploypath, sovault syncwritesdeploy/vault/, which compose mounts at/vault(the dedicated vault is/vault/zoneedit-mcp.vaultbeside its key file).node-vault-mcp/must holdservices/secrets.pyandservices/vault_secrets/, because compose mounts it at/opt/node-vault-mcpand 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) holdsMCP_ADMIN_TOKEN_HASH=<sha256 of the fleet token>. Never the token itself.Vault sync:
deploy/zoneedit_admin.py vault syncrebuilds the NAS vault invault/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. Runvault statusafter the container starts; that is the only command that needs the container (docker exec). RequiresZONEEDIT_SECRETS_CLI,ZONEEDIT_ADMIN_DEPLOY_DIRandZONEEDIT_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 setsshm_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.
Tests pass on the commit you mean to ship,
__version__was bumped (the bump gate enforces it), and the tree is clean.Reach the NAS through the
nas-sshvault entry, one attempt (the lockout rule in the Isaac safety rules): resolve itsssh_cmdinline, never print it. Every remote command needsPATH=$PATH:/usr/local/binfordocker.Compose names the image
zoneedit-mcp:local(deploy/compose.yaml). Record the running state:docker inspect -f '{{.Image}}' zoneedit-mcpand the version a tokenlessinitializereports.Keep a rollback copy (before step 1 below):
docker tag zoneedit-mcp:local zoneedit-mcp:prev, andcp -peach module you are about to replace to<module>.prev.
Procedure.
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. Comparesha256sumon the NAS with the local file.From
/volume1/docker/zoneedit-mcp/deployrundocker 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.A change to
deploy/zoneedit-mcp.envorsite.varsneeds the sameup -d, because compose reads them when it creates the container.
Verify (all of these before saying it worked).
docker ps --filter name=zoneedit-mcpshowsUp, published on127.0.0.1:8768only, and the image id differs from the one recorded in preflight.Read the version back: POST
initializetohttp://127.0.0.1:8768/mcpfrom the NAS (headersContent-Type: application/json,Accept: application/json, text/event-stream).serverInfo.versionmust equal__version__at the shipped commit.A tokenless
tools/callofzoneedit_recordsreturns anisErrorresult saying the call was refused and nothing ran.sha256sumof all eight modules on the NAS equals the repo at the shipped commit.Through the registered client, call
zoneedit_selftestonce:loginok,panel_totalequal to the live count,bulk_save_offtrue. The container log then showstool=zoneedit_selftest principal=fleet.From the repo on any LAN machine, run
python3 deploy/verify_endpoint.py. It checks the public mounthttps://zoneedit.example.net/mcpwith no bearer. Four lines must readok: bare GET 406,serverInfoequal 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.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 entrieson_nastrue andmatchestrue.
Failure branches.
Any Verify step fails (version, sha256, image id, a tokenless call not refused, a
verify_endpoint.pyline notok,bulk_save_offfalse, apanel_totalthat 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.envlost itsMCP_ADMIN_TOKEN_HASH. Restore the line from the amt-mcp env file on the NAS (the fleet hash is shared) without printing it, thenup -d.selftestreturnslogin_failed: stop and do not retry. A repeated failed login can lock the panel account, and the failure latches new logins for 900 s. Runvault status(on the machine that holds the main vault, with the threeZONEEDIT_*variables from Environment):on_nasfalse means runvault sync;matchesfalse means the main-vault credential changed, so runvault sync. Report to the operator before any further login.selftestreturnssecond_factor_required: the login reached the code page but no usable seed is in the vault (zoneedit-panel-totp-seedmissing or unreadable on the NAS). Runvault status; if the entry is absent, runvault sync. Do not retry the login. Report to the operator.selftestreturnssecond_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.selftestreturnslogin_latched: a login or second-factor failure happened in the last 900 s. Nothing reached the panel. Read the cause first withzoneedit_2fa_status. Wait out the latch, or clear it withzoneedit_2fa_latch_clearonly after the operator agrees. A restart also clears it.selftestor a read returnsRunDeadlineExceededorRunBusy: 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 endsoutcome: "unknown"needs a zone read before any retry.Roll back:
docker tag zoneedit-mcp:prev zoneedit-mcp:local, restore each module withmv -f <module>.prev <module>, thenup -dwithout--build(compose reuses the re-taggedzoneedit-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.
This server cannot be deployed
Maintenance
Related MCP Connectors
MCP server for DNSimple — domains, DNS zone records, availability, pricing and contacts.
Scan, fix, verify and monitor DNS: SPF, DMARC, DKIM, propagation, health, expiry. Validated fixes.
Read NS1 zones, records, monitors, data feeds and usage; create or update one DNS record.
Read-only security checks for dApp domains: registry lock, DNSSEC, CAA, SRI, SEAL DNS controls
Related MCP Servers
- AlicenseNot gradedqualityBmaintenanceEnables policy-gated browser automation with persistent Firefox/Chromium profile isolation, version-locked fingerprint consistency, and auditable local session management.Apache 2.0
- AlicenseAqualityCmaintenanceEnables LLMs to configure Tencent Cloud resources such as DNSPod DNS records and CVM/Lighthouse security group rules by controlling a locally logged-in Edge browser over CDP, leveraging existing login state without needing API keys or credentials.71MIT
- AlicenseNot gradedqualityCmaintenanceEnables agents to drive a headless Chrome browser that is already logged in to the user's accounts, supporting multiple accounts and safe handling of Google sessions without manual login or 2FA.MIT
- AlicenseNot gradedqualityCmaintenanceEnables controlling isolated headless browser sessions through MCP tools for navigation, inspection, JavaScript evaluation, input actions, screenshots, and multi-tab session management.9 npm1MIT