DNS — API v1 and MCP | Nodea Documentation
The DNS module manages zones and records hosted on our authoritative PowerDNS cluster: list zones, view a zone with its full record set, and create, update or delete individual records. This document covers the REST API v1 surface (/api/v1/dns) and the MCP tools in this module.
Authentication and scopes
API v1 authenticates with a Passport personal access token via the Authorization: Bearer <token> header. Access tiers:
infra:read— list zones and view a zone with its records (index, show).infra:write— create, update and delete records. A record is subordinate zone configuration, so deleting it does NOT requireinfra:delete(that tier is reserved for destroying whole resources such as devices or servers).
The whole module is additionally gated by the Pennant flag features:hosting-suite — disabling the hosting suite hides DNS on both web UI and API/MCP. Rate limit: throttle:api-v1 = 240/min per token user.
Ownership and visibility
The PowerDnsZone model has no policy class — row-level access is hard-scoped to user_id = token user. A foreign, system (user_id NULL, shared infrastructure) or unknown zone always yields 404 — the API never confirms that a foreign zone exists (no cross-tenant enumeration). The {record} parameter uses scoped route binding against {zone}, so a record belonging to another zone yields 404 before the controller runs.
Mutations mirror the web UI: the local row is written first (the database is the source of truth), then a synchronous push to PowerDNS follows. Transport errors are caught and logged — a failed push does not fail the request; state is reconciled by a scheduled job.
API v1 — endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/dns/zones | infra:read | The user's zones, paginated; with a record count. |
| GET | /api/v1/dns/zones/{zone} | infra:read | Zone detail with the full record set (sorted by type, name). |
| POST | /api/v1/dns/zones/{zone}/records | infra:write | Create a record in the zone. Returns 201. |
| PATCH | /api/v1/dns/zones/{zone}/records/{record} | infra:write | Partial record update (only submitted fields). Scoped binding. |
| DELETE | /api/v1/dns/zones/{zone}/records/{record} | infra:write | Delete a record (scoped binding). Returns 204. |
GET /dns/zones — query parameters
search(string, optional, max 255) — matched against the zone name.per_page(int 1–100, default 20),page(int ≥1).
Response: a DnsZoneResource collection — fields: id, name, kind, serial (SOA), dnssec (bool), records_count, last_synced_at, created_at, updated_at. show adds a records[] array (DnsRecordResource). Internal zone config (masters, nsec3param, the remote PowerDNS handle) stays server-side and is not serialized.
DnsRecordResource — record fields
id, zone_id, name (FQDN), type, content, ttl, priority (nullable), comment (nullable), disabled (bool), created_at, updated_at.
POST/PATCH — record body parameters
name(string, max 253) — accepts@(zone apex), a bare label (suffixed with the zone) or a fully-qualified FQDN.type(enum) — one of:A,AAAA,CNAME,MX,TXT,NS,SRV,CAA,PTR.content(string, max 1000) — IP address, target host or text value.ttl(int, 60–604800 s).priority(int, 0–65535, optional) — for MX/SRV.comment(string, max 255, optional).disabled(bool, optional) — record created/marked as not served.
POST requires the full name/type/content/ttl. PATCH is partial — only submitted fields change (the rest stays intact), unlike the web UI which re-posts the whole record each time.
Error codes
401— missing/invalid token.403— missing required scope.404— unknown, foreign or system zone; a record from another zone (scoped binding).422— validation (disallowed type, TTL out of range, name/content too long).
Examples — API
List zones with search
curl -s "https://app.nodea.io/api/v1/dns/zones?search=example&per_page=20" \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Create an A record
curl -s -X POST https://app.nodea.io/api/v1/dns/zones/42/records \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "www",
"type": "A",
"content": "203.0.113.10",
"ttl": 3600
}'
An MX record with priority
curl -s -X POST https://app.nodea.io/api/v1/dns/zones/42/records \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "@",
"type": "MX",
"content": "mail.example.com",
"ttl": 3600,
"priority": 10
}'
Partial update (TTL only)
curl -s -X PATCH https://app.nodea.io/api/v1/dns/zones/42/records/1001 \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "ttl": 300 }'
MCP — tools
The MCP tools share the authentication and scope model with API v1 (Bearer + scope checked in ensureScope) and the features:hosting-suite flag (a tool on an inactive flag disappears from tools/list). Zones and records are addressed by integer ids; a missing, foreign or system id raises the same error (no cross-tenant enumeration). Write responses carry a synced flag — false means the push was deferred to the reconciler, not an error.
| Tool | Scope | Mode | Description |
|---|---|---|---|
| dns-zones-list | infra:read | read-only | List zones; search, page, per_page (max 50). Returns id/name/kind/serial/dnssec/count/last_synced. |
| dns-zone-get | infra:read | read-only | Zone with every record (sorted type, name). Param: id. |
| dns-record-create | infra:write | write | Add a record; zone_id, name, type, content, ttl, optional priority/comment/disabled. |
| dns-record-update | infra:write | idempotent (full replace) | Replace a record; name/type/content/ttl required every time (read them first via dns-zone-get). |
| dns-record-delete | infra:write | destructive | Delete a record; requires confirm: true. The name stops resolving immediately. |
Example — MCP (JSON-RPC)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "dns-record-create",
"arguments": {
"zone_id": 42,
"name": "www",
"type": "A",
"content": "203.0.113.10",
"ttl": 3600
}
}
}
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "dns-record-delete",
"arguments": { "zone_id": 42, "record_id": 1001, "confirm": true }
}
}