@hoststack.dev/mcp 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -97,7 +97,7 @@ use by hand — an MCP client is what should be launching it.
97
97
 
98
98
  ## Tool inventory
99
99
 
100
- 109 tools. The headings are the registry's own categories rather than a friendlier regrouping, so the build can diff this table against the registry and fail when the two disagree — which is how an earlier version of it came to advertise a total from three releases back and send agents to the dashboard for a `create_database` that had already shipped.
100
+ 113 tools. The headings are the registry's own categories rather than a friendlier regrouping, so the build can diff this table against the registry and fail when the two disagree — which is how an earlier version of it came to advertise a total from three releases back and send agents to the dashboard for a `create_database` that had already shipped.
101
101
 
102
102
  | Category | Read | Write |
103
103
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -106,12 +106,12 @@ use by hand — an MCP client is what should be launching it.
106
106
  | **logs** | `get_service_logs`, `get_service_logs_bulk` | — |
107
107
  | **deploys** | `list_deploys`, `get_deploy`, `get_deploy_logs`, `diagnose_deploy` | `trigger_deploy`, `cancel_deploy` |
108
108
  | **environments** | `list_environments` | `create_environment`, `update_environment`, `delete_environment`, `promote_deploy` |
109
- | **databases** | `list_databases`, `get_database`, `get_database_cluster`, `query_database` | `create_database`, `update_database`, `delete_database`, `suspend_database`, `resume_database`, `restart_database`, `upgrade_database_to_ha`, `upgrade_database_version` |
110
- | **volumes** | `list_volumes` | `create_volume`, `update_volume`, `delete_volume` |
109
+ | **databases** | `list_databases`, `get_database`, `get_database_cluster`, `list_database_backups`, `query_database` | `create_database`, `update_database`, `delete_database`, `suspend_database`, `resume_database`, `restart_database`, `upgrade_database_to_ha`, `upgrade_database_version` |
110
+ | **volumes** | `list_volumes`, `list_volume_backups` | `create_volume`, `update_volume`, `delete_volume`, `restore_volume` |
111
111
  | **resource-links** | `list_managed_resources`, `list_service_resources` | `link_resource_to_service`, `unlink_resource_from_service` |
112
112
  | **machines** | `list_machines`, `get_machine` | — |
113
113
  | **domains** | `list_domains` | `add_domain`, `verify_domain`, `update_domain`, `remove_domain` |
114
- | **dns** | `list_dns_zones`, `list_dns_records`, `get_dns_record` | `create_dns_record`, `update_dns_record`, `delete_dns_record`, `resync_dns_record` |
114
+ | **dns** | `list_dns_zones`, `list_dns_records`, `get_dns_record`, `check_dns_delegation` | `create_dns_record`, `update_dns_record`, `delete_dns_record`, `resync_dns_record` |
115
115
  | **env-vars** | `list_env_vars` | `set_env_var`, `delete_env_var`, `bulk_set_env_vars` |
116
116
  | **cron** | `list_cron_executions`, `get_cron_execution` | — |
117
117
  | **dev-tasks** | `list_dev_tasks`, `get_dev_task` | `create_dev_task`, `update_dev_task` |
@@ -8,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { HostStack } from "@hoststack.dev/sdk";
9
9
 
10
10
  // src/version.ts
11
- var MCP_VERSION = true ? "0.25.0" : "0.0.0-dev";
11
+ var MCP_VERSION = true ? "0.27.0" : "0.0.0-dev";
12
12
  var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
13
13
 
14
14
  // src/api-client.ts
@@ -523,7 +523,7 @@ defineTool({
523
523
  "Deploy.failed_consecutive entries (v89) carry the offending `commitHash` in lastMetadata, and the streak now dedupes per (service, commitHash) \u2014 one critical per bad commit, not one every 6 retries.",
524
524
  "",
525
525
  "Inputs (all optional):",
526
- ' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h.',
526
+ ' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h, clamped to 30 days back. It bounds the CLEARED history only: an alert that is still open is always returned, however long ago it fired \u2014 so the default view answers "what is on fire" and not "what caught fire today".',
527
527
  " - until: ISO-8601 upper bound (ignored when aggregating \u2014 aggregated view always extends to now).",
528
528
  " - limit: max rows (default 100, hard cap 500).",
529
529
  " - aggregate: true (default) collapses by (action, resourceId); false returns raw rows.",
@@ -534,7 +534,9 @@ defineTool({
534
534
  "Example: list_alerts({ since: '-1h' }) \u2192 { alerts: [{ action: 'deploy.failed_consecutive', resourceId: 31, severity: 'critical', active: true, count: 3, lastFiredAt: '\u2026', lastResolvedAt: null, lastMetadata: { commitHash: 'abc1234' }, \u2026 }] }."
535
535
  ].join("\n"),
536
536
  input: {
537
- since: z3.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-1h", "-2d"). Default: -24h.'),
537
+ since: z3.string().optional().describe(
538
+ 'ISO-8601 timestamp or relative offset (e.g. "-1h", "-2d"). Default: -24h. Bounds the cleared history only \u2014 still-open alerts are returned regardless of age.'
539
+ ),
538
540
  until: z3.string().optional().describe("ISO-8601 upper bound. Only honored when aggregate=false."),
539
541
  limit: z3.number().int().positive().max(500).optional().describe("Max rows (default 100, hard cap 500)."),
540
542
  aggregate: z3.boolean().optional().describe("Collapse by (action, resourceId). Default true."),
@@ -655,6 +657,10 @@ async function resolveMachineId(ctx, teamId, machine) {
655
657
  return matches[0].id;
656
658
  }
657
659
  function describeMachine(m) {
660
+ if (m.kind === "infra") {
661
+ const state = m.status === "active" ? "online" : "offline";
662
+ return `${m.name}: infrastructure machine for project ${m.infraProjectId}, ${state} (only that project's services can be pinned to it)`;
663
+ }
658
664
  if (!m.enrolled) return `${m.name}: registered but never paired`;
659
665
  if (m.status !== "active") return `${m.name}: offline`;
660
666
  if (m.agentBuild === "from-source") return `${m.name}: online, agent running from source`;
@@ -755,7 +761,7 @@ defineTool({
755
761
  " - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
756
762
  " - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
757
763
  " - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
758
- " - machine (optional): put it on one of the team's OWN enrolled machines (name or id, see list_machines) instead of HostStack compute. It costs nothing there and is still backed up off-site, but it is reachable ONLY from that same machine \u2014 the service that queries it has to run there too, and linking it to a service anywhere else is refused. No HA and no external access on a machine you own.",
764
+ " - machine (optional): put it on one of the team's OWN enrolled machines (name or id, see list_machines) instead of HostStack compute. It costs nothing there and is still backed up off-site, but it is reachable ONLY from that same machine \u2014 the service that queries it has to run there too, and linking it to a service anywhere else is refused. No HA and no external access on a machine you own. An infrastructure machine takes a database of the project it is bound to, and nothing else.",
759
765
  "",
760
766
  'Returns: { database: Database } \u2014 note BOTH `id` (numeric \u2014 this is what link_resource_to_service wants) and `publicId` ("db_\u2026" \u2014 what the other database tools want).',
761
767
  "",
@@ -1132,6 +1138,37 @@ defineTool({
1132
1138
  return respond({ summary, data: result });
1133
1139
  }
1134
1140
  });
1141
+ defineTool({
1142
+ name: "list_database_backups",
1143
+ category: "databases",
1144
+ description: [
1145
+ "List the off-site archives a managed database can be restored from, newest first.",
1146
+ "",
1147
+ 'WHY THIS MATTERS: HostStack dumps every managed database nightly to off-site object storage. That is a SCHEDULE \u2014 a promise about what should happen. It does not say any dump exists. A database whose dump has failed every night since it was created (no upload grant, a full disk, a rotated credential, an engine version the dumper does not know) looks identical from the outside to one backed up every night, and the first person to find out is whoever already needed the restore. This tool is how you tell "protected" from "believed to be protected". Check it for anything whose loss is not survivable.',
1148
+ "",
1149
+ "A dump written onto the machine's OWN disk is not listed, on purpose. On a machine of the customer's own with no upload grant that is where the dump lands \u2014 the same disk holding the database \u2014 so counting it would be counting the thing that can be lost.",
1150
+ "",
1151
+ "When to use: before a risky migration, when someone asks what the recovery position is, or when auditing a project's backup exposure. Read it together with `lastOffsiteBackupAt` on get_database: that field says WHEN, this says WHAT.",
1152
+ "",
1153
+ "Inputs:",
1154
+ " - database_id: publicId of the database (db_\u2026).",
1155
+ "",
1156
+ "Returns: { items: RestorePoint[] } \u2014 id, archiveName, sizeBytes (may be null on older agents), createdAt, s3Url. Only the most recent few are kept, matching the retention on the machine: seven rows means seven restore points, not seven backups ever taken.",
1157
+ "",
1158
+ 'Example: list_database_backups({ database_id: "db_abc" }) \u2192 { items: [{ id: 12, archiveName: "backup-2026-09-16T02-00-11-004Z.sql.gz", sizeBytes: 314572800, createdAt: "2026-09-16T02:01:42Z" }] }'
1159
+ ].join("\n"),
1160
+ input: {
1161
+ database_id: z6.string().describe("Database publicId (e.g. db_\u2026).")
1162
+ },
1163
+ handler: async (args2, ctx) => {
1164
+ const teamId = await ctx.resolveTeamId();
1165
+ const response = await ctx.hoststack.databases.listRestorePoints(teamId, args2.database_id);
1166
+ const data = shapeList(response, "restorePoints", shape);
1167
+ const newest = response.restorePoints[0];
1168
+ const summary = data.items.length === 0 ? `Database ${args2.database_id} has NO off-site restore points. Either no dump has completed yet, or every dump is landing on the machine's own disk \u2014 either way there is currently nothing to restore from. Check lastBackupStatus on get_database for which: offsite_failed means a dump ran and its upload was attempted and failed, and lastBackupError carries the provider's reason.` : `Database ${args2.database_id} has ${data.items.length} restore point${data.items.length === 1 ? "" : "s"}; newest ${newest?.archiveName} from ${newest?.createdAt}.`;
1169
+ return respond({ summary, data });
1170
+ }
1171
+ });
1135
1172
 
1136
1173
  // src/tools/deploys.ts
1137
1174
  import { z as z7 } from "zod";
@@ -1424,19 +1461,85 @@ defineTool({
1424
1461
  "",
1425
1462
  "When to use: discover which apex domains support record management before calling create_dns_record / list_dns_records. The dashboard equivalent is Domains \u2192 DNS.",
1426
1463
  "",
1427
- 'Returns: { items: Zone[] } \u2014 each zone exposes publicId, domainName, status ("active" | "syncing" | "failed" | "deleting"), nsRecords (the nameservers the parent registry must delegate to), provider, createdAt.',
1464
+ 'Returns: { items: Zone[] } \u2014 each zone exposes publicId, domainName, status ("active" | "syncing" | "failed" | "deleting"), nsRecords (the nameservers the parent registry must delegate to), provider, createdAt, plus the delegation reading below.',
1465
+ "",
1466
+ 'READ delegationStatus, NOT status, to answer "is this domain live?". `status` describes the zone on OUR side \u2014 "active" means PowerDNS holds it and answers for it. `delegationStatus` describes whether the parent registry actually SENDS anyone here:',
1467
+ ' - "delegated" \u2014 the registry names our nameservers; queries reach us.',
1468
+ ' - "foreign" \u2014 the registry names somebody else (delegationObservedNs says who). The zone is authoritative for nobody: its records are correct and unreachable, public lookups return the OLD host or nothing, and no domain under it can pass verification or get a certificate. This is the state a half-finished migration sits in, and it looks identical to a healthy zone on every other field.',
1469
+ ' - "unknown" \u2014 no usable answer (SERVFAIL/timeout/never checked). NOT the same as "foreign"; do not tell a user to change their registrar on the strength of it. Call check_dns_delegation for a live reading.',
1470
+ "",
1471
+ "delegationObservedNs is the NS set the parent publishes today, and delegationCheckedAt is when it was read (refreshed hourly). For an answer current to the second \u2014 e.g. just after a registrar change \u2014 call check_dns_delegation.",
1428
1472
  "",
1429
- 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
1473
+ 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"], delegationStatus: "foreign", delegationObservedNs: ["docks15.rzone.de","shades13.rzone.de"] }] }',
1474
+ "",
1475
+ 'Each zone ALSO carries what the registry says about the REGISTRATION, refreshed daily over RDAP: registrar, registryStatus (EPP codes such as clientHold / pendingTransfer / pendingDelete / redemptionPeriod), registryExpiresAt, registryCheckedAt and registryCheckOutcome ("ok" | "not_found" | "unsupported" | "rate_limited" | "error").',
1476
+ "",
1477
+ 'This is a different question from delegation, and a zone can fail either one alone. Delegation asks whether the world is being sent here; these fields ask whether the domain is still going to be the customer\'s next month. `status: "active"` answers neither \u2014 it describes our nameservers only.',
1478
+ "",
1479
+ 'SILENCE IS NOT GOOD NEWS HERE. A null registryExpiresAt or registrar on a zone that HAS been checked means the registry publishes neither \u2014 DENIC publishes no expiry and no registrar for any .de \u2014 and registryCheckOutcome "unsupported" means the TLD has no RDAP service at all (there is none for .dk), so every field above it is unknowable rather than fine. Read registryCheckOutcome before drawing any conclusion from an empty field.',
1480
+ "",
1481
+ "HostStack is not the registrar for a domain whose DNS it merely hosts. Nothing in this MCP, the CLI or the dashboard can renew, transfer or un-hold a registration \u2014 if registryStatus or registryExpiresAt shows trouble, the action is at the registrar named in `registrar`.",
1482
+ "",
1483
+ 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"], registrar: "GoDaddy.com, LLC", registryStatus: ["clientTransferProhibited"], registryExpiresAt: "2027-03-04T00:00:00Z", registryCheckOutcome: "ok" }] }'
1430
1484
  ].join("\n"),
1431
1485
  input: {},
1432
1486
  handler: async (_args, ctx) => {
1433
1487
  const teamId = await ctx.resolveTeamId();
1434
1488
  const response = await ctx.hoststack.dns.listZones(teamId);
1435
- const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
1436
- const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.`;
1489
+ const zones = Array.isArray(response.zones) ? response.zones : [];
1490
+ const items = zones.map(shape);
1491
+ const foreign = zones.filter((z23) => z23.delegationStatus === "foreign");
1492
+ const foreignNote = foreign.length === 0 ? "" : ` WARNING: ${foreign.length} zone${foreign.length === 1 ? " is" : "s are"} hosted here but still delegated elsewhere by the registry \u2014 ` + foreign.map(
1493
+ (z23) => `${z23.domainName} \u2192 ${(z23.delegationObservedNs ?? []).join(", ") || "unknown nameservers"}`
1494
+ ).join("; ") + `. Records on ${foreign.length === 1 ? "it" : "them"} are correct but unreachable; the nameservers must be changed at the registrar.`;
1495
+ const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.${foreignNote}`;
1437
1496
  return respond({ summary, data: { items } });
1438
1497
  }
1439
1498
  });
1499
+ defineTool({
1500
+ name: "check_dns_delegation",
1501
+ category: "dns",
1502
+ description: [
1503
+ "Read, live, whether the parent registry actually delegates a zone's apex to HostStack \u2014 and store the reading on the zone.",
1504
+ "",
1505
+ 'WHAT THIS ANSWERS that nothing else does: a hosted zone can be `status: "active"`, DNSSEC-signed, full of correct records, and authoritative for NOBODY, because the registry still points the domain at the previous host. Public lookups then return the old host (or nothing) while a query against our own nameservers returns the right answer \u2014 and every other field we expose says healthy. This is the normal shape of a half-finished migration, and it is what to check FIRST when a zone looks perfect but the site is not live, a domain will not verify, or a certificate will not issue.',
1506
+ "",
1507
+ "When to use: right after someone changes nameservers at a registrar (list_dns_zones carries the hourly reading, which will be stale by minutes); before telling anyone a cutover is done; or to explain why verify_domain keeps returning pending.",
1508
+ "",
1509
+ "Provide EITHER zone_id (the zone publicId) OR domain (apex or subdomain \u2014 the longest-matching hosted apex wins).",
1510
+ "",
1511
+ "Returns: { zone, delegation: { status, observedNameservers, expectedNameservers, checkedAt } }.",
1512
+ ` - status "delegated": at least one of our nameservers is in the parent's NS set. Any, not all \u2014 a staged migration legitimately runs split for a while, and queries already reach us during it.`,
1513
+ ' - status "foreign": the parent answered and named somebody else. observedNameservers is who. The fix is at the REGISTRAR of the apex, never in our records; expect up to 48h of propagation after the change.',
1514
+ ' - status "unknown": no usable answer (SERVFAIL, timeout, NXDOMAIN). This is NOT "not delegated" \u2014 never tell someone to re-point a registrar on the strength of it. Retry, or check the domain is registered at all.',
1515
+ "",
1516
+ 'Note for `.dk` and other strict registries: they refuse the nameserver change until we already answer authoritatively for the domain, so a zone there is legitimately "foreign" for a while by design. That is the ordering the registry imposes, not a mistake.',
1517
+ "",
1518
+ 'Example: check_dns_delegation({ domain: "wohnwagen-wuckert.de" }) \u2192 { delegation: { status: "foreign", observedNameservers: ["docks15.rzone.de","shades13.rzone.de"], expectedNameservers: ["ns1.hoststack.dev","ns2.hoststack.dev"] } }'
1519
+ ].join("\n"),
1520
+ input: {
1521
+ zone_id: z8.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
1522
+ domain: z8.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
1523
+ },
1524
+ handler: async (args2, ctx) => {
1525
+ const teamId = await ctx.resolveTeamId();
1526
+ const zoneInput = {};
1527
+ if (args2.zone_id !== void 0) zoneInput.zone_id = args2.zone_id;
1528
+ if (args2.domain !== void 0) zoneInput.domain = args2.domain;
1529
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1530
+ const { delegation } = await ctx.hoststack.dns.checkDelegation(teamId, zone.publicId);
1531
+ const observed = delegation.observedNameservers.join(", ");
1532
+ const expected = delegation.expectedNameservers.join(" / ");
1533
+ const summary = delegation.status === "delegated" ? `${zone.domainName} is delegated to HostStack (${observed}). Queries for it reach this zone.` : delegation.status === "foreign" ? `${zone.domainName} is HOSTED here but NOT delegated here: the registry still points it at ${observed}. Records in this zone are correct but nobody is being sent to read them \u2014 public lookups return the old host. Change the nameservers at the registrar of ${zone.domainName} to ${expected}; propagation can take up to 48 hours.` : `Could not determine the delegation for ${zone.domainName} \u2014 the NS lookup returned no usable answer (SERVFAIL, timeout, or the domain is not registered). This is NOT evidence that the delegation is wrong; do not change a registrar on the strength of it. Retry in a moment.`;
1534
+ return respond({
1535
+ summary,
1536
+ data: {
1537
+ zone: { publicId: zone.publicId, domainName: zone.domainName },
1538
+ delegation
1539
+ }
1540
+ });
1541
+ }
1542
+ });
1440
1543
  defineTool({
1441
1544
  name: "list_dns_records",
1442
1545
  category: "dns",
@@ -1768,10 +1871,14 @@ defineTool({
1768
1871
  };
1769
1872
  if (args2.path_prefix !== void 0) input.pathPrefix = args2.path_prefix;
1770
1873
  const response = await ctx.hoststack.domains.add(teamId, input);
1771
- const dnsSyncWarning = response.domain.dnsSyncWarning;
1772
- const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
1874
+ const { dnsSyncWarning, delegationWarning } = response.domain;
1875
+ const data = {
1876
+ domain: shapeDomain(response.domain),
1877
+ ...dnsSyncWarning ? { dnsSyncWarning } : {},
1878
+ ...delegationWarning ? { delegationWarning } : {}
1879
+ };
1773
1880
  return respond({
1774
- summary: dnsSyncWarning ? `Added domain ${args2.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1881
+ summary: delegationWarning ? `Added domain ${args2.hostname}, but it CANNOT verify yet: ${delegationWarning}` : dnsSyncWarning ? `Added domain ${args2.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1775
1882
  data
1776
1883
  });
1777
1884
  }
@@ -1787,19 +1894,30 @@ defineTool({
1787
1894
  "Inputs:",
1788
1895
  " - domain_id: publicId of the domain (from list_domains or add_domain).",
1789
1896
  "",
1790
- "Returns: { ok: true }. Re-call list_domains to inspect the updated verified flag and SSL status.",
1897
+ "Returns: { domain, verified }. `verified` is the outcome of THIS check \u2014 the call does not merely queue one.",
1791
1898
  "",
1792
- 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
1899
+ "If it fails and HostStack hosts the zone, the response may carry `delegationWarning`: the records are ours and correct, but the registry still delegates the apex to another host, so queries never reach us. That will NOT resolve by waiting or by calling this again \u2014 the nameservers have to change at the registrar. Use check_dns_delegation to see who the apex currently points at.",
1900
+ "",
1901
+ "It may instead carry `dnsSyncWarning`: the record HostStack creates for this hostname could not be published, usually because a record already at that name conflicts with it (a CNAME and an A cannot share a name). Also not fixable by retrying \u2014 resolve the conflict it names with list_dns_records / delete_dns_record first.",
1902
+ "",
1903
+ 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { verified: false, delegationWarning: "\u2026the registry still delegates wohnwagen-wuckert.de to docks15.rzone.de\u2026" }'
1793
1904
  ].join("\n"),
1794
1905
  input: {
1795
1906
  domain_id: z9.string().describe("Domain publicId.")
1796
1907
  },
1797
1908
  handler: async (args2, ctx) => {
1798
1909
  const teamId = await ctx.resolveTeamId();
1799
- await ctx.hoststack.domains.verify(teamId, args2.domain_id);
1910
+ const { domain } = await ctx.hoststack.domains.verify(teamId, args2.domain_id);
1911
+ const verified = domain.status === "active";
1912
+ const summary = verified ? `Verified ${domain.domain} \u2014 it is now active.` : domain.delegationWarning ? `${domain.domain} did NOT verify, and retrying will not help: ${domain.delegationWarning}` : domain.dnsSyncWarning ? `${domain.domain} did NOT verify, and retrying will not help: ${domain.dnsSyncWarning}` : `${domain.domain} did not verify yet (status: ${domain.status}). DNS changes can take time to propagate; check the records resolve publicly, then call this again.`;
1800
1913
  return respond({
1801
- summary: `Triggered DNS verification for ${args2.domain_id}.`,
1802
- data: { ok: true }
1914
+ summary,
1915
+ data: {
1916
+ domain: shapeDomain(domain),
1917
+ verified,
1918
+ ...domain.delegationWarning ? { delegationWarning: domain.delegationWarning } : {},
1919
+ ...domain.dnsSyncWarning ? { dnsSyncWarning: domain.dnsSyncWarning } : {}
1920
+ }
1803
1921
  });
1804
1922
  }
1805
1923
  });
@@ -3149,8 +3267,13 @@ var NOTIFICATION_EVENTS = [
3149
3267
  "database.restore_failed",
3150
3268
  "volume.backup_failed",
3151
3269
  "volume.backup_overdue",
3270
+ "dns.registry_status_changed",
3271
+ "dns.registry_expiring",
3272
+ "dns.registry_domain_missing",
3273
+ "dns.registry_record_changed",
3152
3274
  "domain.registrant_verification_lapsed",
3153
3275
  "service.auto_restarted",
3276
+ "project.release_awaiting_start",
3154
3277
  "machine.offline",
3155
3278
  "machine.online",
3156
3279
  "billing.invoice",
@@ -5307,9 +5430,15 @@ defineTool({
5307
5430
  "Inputs:",
5308
5431
  " - service_id: publicId of the service.",
5309
5432
  "",
5310
- "Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), createdAt, updatedAt.",
5433
+ "Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), backupEnabled, blockBacked, adopted, createdAt, updatedAt.",
5434
+ "",
5435
+ "`backupEnabled` says backups are being TAKEN, not that any exist. Call list_volume_backups to see which archives are actually restorable \u2014 a volume can be enabled and have nothing behind it (nothing has completed yet, or the host has no upload grant and is writing tars onto the very disk it is backing up, which is not a copy of anything). Turn it on with update_volume({ backup_enabled: true }).",
5436
+ "",
5437
+ 'READ `backupEnabled: false` AGAINST `blockBacked` BEFORE REPORTING IT. On a block-backed volume it is not a to-do: the disk is already triple-replicated by Hetzner and update_volume refuses to turn the toggle on, so "backups are off" is the wrong finding to hand a user. On every other volume \u2014 `blockBacked: false`, `adopted` either way \u2014 it IS a to-do, and it means the only copy of that data is one disk.',
5311
5438
  "",
5312
- 'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
5439
+ '`adopted: true` is an existing Docker volume on an infrastructure machine, mounted in place rather than created by us. `sizeGb` is 0 on one and means "not ours to say", not "empty". Backups can be turned on \u2014 they only read it \u2014 but resize, restore, import and delete are refused.',
5440
+ "",
5441
+ 'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active", backupEnabled: false }] }'
5313
5442
  ].join("\n"),
5314
5443
  input: {
5315
5444
  service_id: z22.string().describe("Service publicId (e.g. svc_abc123).")
@@ -5381,7 +5510,11 @@ defineTool({
5381
5510
  " - volume_id: publicId of the volume to update.",
5382
5511
  " - mount_path (optional): new in-container mount path.",
5383
5512
  " - size_gb (optional): new size in GB (must be \u2265 current).",
5384
- " - backup_enabled (optional): whether this volume is backed up. Turning it off stops future backups; it does not delete the ones already taken.",
5513
+ " - backup_enabled (optional): whether this volume is backed up nightly. Turning it off stops future backups; it does not delete the ones already taken. Verify with list_volume_backups \u2014 enabling is not the same as having a backup. Refused on a block-backed volume (Hetzner already replicates it three ways).",
5514
+ "",
5515
+ "On an ADOPTED volume (`adopted: true` from list_volumes \u2014 an infrastructure machine's own Docker volume) this is the ONLY field that can be changed: a backup reads the volume and writes the archive elsewhere, while mount_path and size_gb describe what the machine's operator set up.",
5516
+ "",
5517
+ "IMPORTANT about what a volume backup is: a block-level tar of a LIVE filesystem, i.e. CRASH CONSISTENT, not application consistent. Nothing is quiesced. For a container running its own database (WordPress + MariaDB in one box, Postgres on a disk, SQLite under write), the archive captures whatever was on disk mid-write \u2014 the same state the database would face after a power cut. Usually recoverable, occasionally not. Recommend this as the disaster fallback and a scheduled dump as the actual backup; do not present it as a substitute for one.",
5385
5518
  "",
5386
5519
  "Returns: { volume: Volume } \u2014 the updated record.",
5387
5520
  "",
@@ -5414,6 +5547,78 @@ defineTool({
5414
5547
  return respond({ summary: `Updated ${fields} on volume ${args2.volume_id}.`, data });
5415
5548
  }
5416
5549
  });
5550
+ defineTool({
5551
+ name: "list_volume_backups",
5552
+ category: "volumes",
5553
+ description: [
5554
+ "List the archives a volume can be restored from, newest first.",
5555
+ "",
5556
+ 'WHY THIS MATTERS: `backupEnabled` on a volume says backups are being TAKEN. It does not say any exist. A volume can be enabled and have nothing behind it \u2014 nothing has completed a cycle yet, or the host has no upload grant and is writing its tar onto the very disk it is backing up (that file is not a copy of anything and is deliberately NOT listed here). This tool is how you tell "protected" from "believed to be protected". Check it whenever a volume holds the only copy of something.',
5557
+ "",
5558
+ "When to use: before a risky migration or deploy, when someone asks what the recovery position is, or to pick an archive to hand to restore_volume.",
5559
+ "",
5560
+ "Inputs:",
5561
+ " - service_id: publicId of the service.",
5562
+ " - volume_id: publicId of the volume (vol_\u2026).",
5563
+ "",
5564
+ "Returns: { items: RestorePoint[] } \u2014 id (pass to restore_volume), archiveName, sizeBytes (may be null on older agents), createdAt, s3Url. Only the most recent few are kept: the host prunes older archives out of the bucket on its own schedule.",
5565
+ "",
5566
+ 'Example: list_volume_backups({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { items: [{ id: 41, archiveName: "backup-20260911-0300.tar.gz", sizeBytes: 184532992, createdAt: "2026-09-11T03:00:11Z" }] }'
5567
+ ].join("\n"),
5568
+ input: {
5569
+ service_id: z22.string().describe("Service publicId."),
5570
+ volume_id: z22.string().describe("Volume publicId (e.g. vol_\u2026).")
5571
+ },
5572
+ handler: async (args2, ctx) => {
5573
+ const teamId = await ctx.resolveTeamId();
5574
+ const response = await ctx.hoststack.volumes.listRestorePoints(
5575
+ teamId,
5576
+ args2.service_id,
5577
+ args2.volume_id
5578
+ );
5579
+ const data = shapeList(response, "restorePoints", shape);
5580
+ const newest = response.restorePoints[0];
5581
+ const summary = data.items.length === 0 ? `Volume ${args2.volume_id} has NO restore points. If backupEnabled is true, either no backup has completed yet or the host cannot upload off-site \u2014 either way there is currently nothing to restore from.` : `Volume ${args2.volume_id} has ${data.items.length} restore point${data.items.length === 1 ? "" : "s"}; newest ${newest?.archiveName} from ${newest?.createdAt}.`;
5582
+ return respond({ summary, data });
5583
+ }
5584
+ });
5585
+ defineTool({
5586
+ name: "restore_volume",
5587
+ category: "volumes",
5588
+ description: [
5589
+ "Unpack one of a volume's archives back over its contents.",
5590
+ "",
5591
+ 'DESTRUCTIVE AND NOT REVERSIBLE. Everything on the disk now is replaced by what was on it when the archive was taken. Nothing snapshots the pre-restore state first, so a restore to the wrong point loses the data you had. CONFIRM WITH THE USER \u2014 naming the archive and its timestamp \u2014 before calling this. Never call it to "check whether a backup works".',
5592
+ "",
5593
+ "What the archive is: a block-level tar of a filesystem that was LIVE when it was taken \u2014 crash consistent, not application consistent. A database inside it will come back the way it would after a power cut, and may need its own recovery on first start. Stop the service before restoring if you can.",
5594
+ "",
5595
+ "When to use: the user explicitly asks to roll a volume back to an earlier state, or to recover after data loss.",
5596
+ "",
5597
+ "Inputs:",
5598
+ " - service_id: publicId of the service.",
5599
+ " - volume_id: publicId of the volume.",
5600
+ " - backup_id: the numeric id of a restore point from list_volume_backups. Call that FIRST and show the user the choice \u2014 never guess an id.",
5601
+ "",
5602
+ "Returns: { ok: true } once the host has accepted the restore. The unpack itself runs on the host; re-check the service afterwards.",
5603
+ "",
5604
+ 'Example: restore_volume({ service_id: "svc_abc", volume_id: "vol_xyz", backup_id: 41 })'
5605
+ ].join("\n"),
5606
+ input: {
5607
+ service_id: z22.string().describe("Service publicId."),
5608
+ volume_id: z22.string().describe("Volume publicId."),
5609
+ backup_id: z22.number().int().positive().describe("Restore-point id from list_volume_backups. Confirm with the user first.")
5610
+ },
5611
+ handler: async (args2, ctx) => {
5612
+ const teamId = await ctx.resolveTeamId();
5613
+ await ctx.hoststack.volumes.restore(teamId, args2.service_id, args2.volume_id, {
5614
+ backupId: args2.backup_id
5615
+ });
5616
+ return respond({
5617
+ summary: `Restore of volume ${args2.volume_id} from backup ${args2.backup_id} accepted \u2014 the host is unpacking the archive over the volume's contents. Check the service once it finishes; a database on this volume may run its own crash recovery on first start.`,
5618
+ data: { ok: true }
5619
+ });
5620
+ }
5621
+ });
5417
5622
  defineTool({
5418
5623
  name: "delete_volume",
5419
5624
  category: "volumes",