@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 +4 -4
- package/dist/hoststack-mcp.js +224 -19
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.js +224 -19
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
4
4
|
|
|
5
5
|
// src/version.ts
|
|
6
|
-
var MCP_VERSION = true ? "0.
|
|
6
|
+
var MCP_VERSION = true ? "0.27.0" : "0.0.0-dev";
|
|
7
7
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
8
8
|
|
|
9
9
|
// src/api-client.ts
|
|
@@ -524,7 +524,7 @@ defineTool({
|
|
|
524
524
|
"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.",
|
|
525
525
|
"",
|
|
526
526
|
"Inputs (all optional):",
|
|
527
|
-
' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h.',
|
|
527
|
+
' - 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".',
|
|
528
528
|
" - until: ISO-8601 upper bound (ignored when aggregating \u2014 aggregated view always extends to now).",
|
|
529
529
|
" - limit: max rows (default 100, hard cap 500).",
|
|
530
530
|
" - aggregate: true (default) collapses by (action, resourceId); false returns raw rows.",
|
|
@@ -535,7 +535,9 @@ defineTool({
|
|
|
535
535
|
"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 }] }."
|
|
536
536
|
].join("\n"),
|
|
537
537
|
input: {
|
|
538
|
-
since: z3.string().optional().describe(
|
|
538
|
+
since: z3.string().optional().describe(
|
|
539
|
+
'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.'
|
|
540
|
+
),
|
|
539
541
|
until: z3.string().optional().describe("ISO-8601 upper bound. Only honored when aggregate=false."),
|
|
540
542
|
limit: z3.number().int().positive().max(500).optional().describe("Max rows (default 100, hard cap 500)."),
|
|
541
543
|
aggregate: z3.boolean().optional().describe("Collapse by (action, resourceId). Default true."),
|
|
@@ -656,6 +658,10 @@ async function resolveMachineId(ctx, teamId, machine) {
|
|
|
656
658
|
return matches[0].id;
|
|
657
659
|
}
|
|
658
660
|
function describeMachine(m) {
|
|
661
|
+
if (m.kind === "infra") {
|
|
662
|
+
const state = m.status === "active" ? "online" : "offline";
|
|
663
|
+
return `${m.name}: infrastructure machine for project ${m.infraProjectId}, ${state} (only that project's services can be pinned to it)`;
|
|
664
|
+
}
|
|
659
665
|
if (!m.enrolled) return `${m.name}: registered but never paired`;
|
|
660
666
|
if (m.status !== "active") return `${m.name}: offline`;
|
|
661
667
|
if (m.agentBuild === "from-source") return `${m.name}: online, agent running from source`;
|
|
@@ -756,7 +762,7 @@ defineTool({
|
|
|
756
762
|
" - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
|
|
757
763
|
" - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
|
|
758
764
|
" - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
|
|
759
|
-
" - 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.",
|
|
765
|
+
" - 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.",
|
|
760
766
|
"",
|
|
761
767
|
'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).',
|
|
762
768
|
"",
|
|
@@ -1133,6 +1139,37 @@ defineTool({
|
|
|
1133
1139
|
return respond({ summary, data: result });
|
|
1134
1140
|
}
|
|
1135
1141
|
});
|
|
1142
|
+
defineTool({
|
|
1143
|
+
name: "list_database_backups",
|
|
1144
|
+
category: "databases",
|
|
1145
|
+
description: [
|
|
1146
|
+
"List the off-site archives a managed database can be restored from, newest first.",
|
|
1147
|
+
"",
|
|
1148
|
+
'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.',
|
|
1149
|
+
"",
|
|
1150
|
+
"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.",
|
|
1151
|
+
"",
|
|
1152
|
+
"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.",
|
|
1153
|
+
"",
|
|
1154
|
+
"Inputs:",
|
|
1155
|
+
" - database_id: publicId of the database (db_\u2026).",
|
|
1156
|
+
"",
|
|
1157
|
+
"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.",
|
|
1158
|
+
"",
|
|
1159
|
+
'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" }] }'
|
|
1160
|
+
].join("\n"),
|
|
1161
|
+
input: {
|
|
1162
|
+
database_id: z6.string().describe("Database publicId (e.g. db_\u2026).")
|
|
1163
|
+
},
|
|
1164
|
+
handler: async (args, ctx) => {
|
|
1165
|
+
const teamId = await ctx.resolveTeamId();
|
|
1166
|
+
const response = await ctx.hoststack.databases.listRestorePoints(teamId, args.database_id);
|
|
1167
|
+
const data = shapeList(response, "restorePoints", shape);
|
|
1168
|
+
const newest = response.restorePoints[0];
|
|
1169
|
+
const summary = data.items.length === 0 ? `Database ${args.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 ${args.database_id} has ${data.items.length} restore point${data.items.length === 1 ? "" : "s"}; newest ${newest?.archiveName} from ${newest?.createdAt}.`;
|
|
1170
|
+
return respond({ summary, data });
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1136
1173
|
|
|
1137
1174
|
// src/tools/deploys.ts
|
|
1138
1175
|
import { z as z7 } from "zod";
|
|
@@ -1425,19 +1462,85 @@ defineTool({
|
|
|
1425
1462
|
"",
|
|
1426
1463
|
"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.",
|
|
1427
1464
|
"",
|
|
1428
|
-
'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.',
|
|
1465
|
+
'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.',
|
|
1466
|
+
"",
|
|
1467
|
+
'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:',
|
|
1468
|
+
' - "delegated" \u2014 the registry names our nameservers; queries reach us.',
|
|
1469
|
+
' - "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.',
|
|
1470
|
+
' - "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.',
|
|
1471
|
+
"",
|
|
1472
|
+
"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.",
|
|
1429
1473
|
"",
|
|
1430
|
-
'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
|
|
1474
|
+
'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"] }] }',
|
|
1475
|
+
"",
|
|
1476
|
+
'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").',
|
|
1477
|
+
"",
|
|
1478
|
+
'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.',
|
|
1479
|
+
"",
|
|
1480
|
+
'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.',
|
|
1481
|
+
"",
|
|
1482
|
+
"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`.",
|
|
1483
|
+
"",
|
|
1484
|
+
'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" }] }'
|
|
1431
1485
|
].join("\n"),
|
|
1432
1486
|
input: {},
|
|
1433
1487
|
handler: async (_args, ctx) => {
|
|
1434
1488
|
const teamId = await ctx.resolveTeamId();
|
|
1435
1489
|
const response = await ctx.hoststack.dns.listZones(teamId);
|
|
1436
|
-
const
|
|
1437
|
-
const
|
|
1490
|
+
const zones = Array.isArray(response.zones) ? response.zones : [];
|
|
1491
|
+
const items = zones.map(shape);
|
|
1492
|
+
const foreign = zones.filter((z23) => z23.delegationStatus === "foreign");
|
|
1493
|
+
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(
|
|
1494
|
+
(z23) => `${z23.domainName} \u2192 ${(z23.delegationObservedNs ?? []).join(", ") || "unknown nameservers"}`
|
|
1495
|
+
).join("; ") + `. Records on ${foreign.length === 1 ? "it" : "them"} are correct but unreachable; the nameservers must be changed at the registrar.`;
|
|
1496
|
+
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}`;
|
|
1438
1497
|
return respond({ summary, data: { items } });
|
|
1439
1498
|
}
|
|
1440
1499
|
});
|
|
1500
|
+
defineTool({
|
|
1501
|
+
name: "check_dns_delegation",
|
|
1502
|
+
category: "dns",
|
|
1503
|
+
description: [
|
|
1504
|
+
"Read, live, whether the parent registry actually delegates a zone's apex to HostStack \u2014 and store the reading on the zone.",
|
|
1505
|
+
"",
|
|
1506
|
+
'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.',
|
|
1507
|
+
"",
|
|
1508
|
+
"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.",
|
|
1509
|
+
"",
|
|
1510
|
+
"Provide EITHER zone_id (the zone publicId) OR domain (apex or subdomain \u2014 the longest-matching hosted apex wins).",
|
|
1511
|
+
"",
|
|
1512
|
+
"Returns: { zone, delegation: { status, observedNameservers, expectedNameservers, checkedAt } }.",
|
|
1513
|
+
` - 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.`,
|
|
1514
|
+
' - 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.',
|
|
1515
|
+
' - 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.',
|
|
1516
|
+
"",
|
|
1517
|
+
'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.',
|
|
1518
|
+
"",
|
|
1519
|
+
'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"] } }'
|
|
1520
|
+
].join("\n"),
|
|
1521
|
+
input: {
|
|
1522
|
+
zone_id: z8.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
|
|
1523
|
+
domain: z8.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
|
|
1524
|
+
},
|
|
1525
|
+
handler: async (args, ctx) => {
|
|
1526
|
+
const teamId = await ctx.resolveTeamId();
|
|
1527
|
+
const zoneInput = {};
|
|
1528
|
+
if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
|
|
1529
|
+
if (args.domain !== void 0) zoneInput.domain = args.domain;
|
|
1530
|
+
const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
|
|
1531
|
+
const { delegation } = await ctx.hoststack.dns.checkDelegation(teamId, zone.publicId);
|
|
1532
|
+
const observed = delegation.observedNameservers.join(", ");
|
|
1533
|
+
const expected = delegation.expectedNameservers.join(" / ");
|
|
1534
|
+
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.`;
|
|
1535
|
+
return respond({
|
|
1536
|
+
summary,
|
|
1537
|
+
data: {
|
|
1538
|
+
zone: { publicId: zone.publicId, domainName: zone.domainName },
|
|
1539
|
+
delegation
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1441
1544
|
defineTool({
|
|
1442
1545
|
name: "list_dns_records",
|
|
1443
1546
|
category: "dns",
|
|
@@ -1769,10 +1872,14 @@ defineTool({
|
|
|
1769
1872
|
};
|
|
1770
1873
|
if (args.path_prefix !== void 0) input.pathPrefix = args.path_prefix;
|
|
1771
1874
|
const response = await ctx.hoststack.domains.add(teamId, input);
|
|
1772
|
-
const dnsSyncWarning = response.domain
|
|
1773
|
-
const data =
|
|
1875
|
+
const { dnsSyncWarning, delegationWarning } = response.domain;
|
|
1876
|
+
const data = {
|
|
1877
|
+
domain: shapeDomain(response.domain),
|
|
1878
|
+
...dnsSyncWarning ? { dnsSyncWarning } : {},
|
|
1879
|
+
...delegationWarning ? { delegationWarning } : {}
|
|
1880
|
+
};
|
|
1774
1881
|
return respond({
|
|
1775
|
-
summary: dnsSyncWarning ? `Added domain ${args.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
|
|
1882
|
+
summary: delegationWarning ? `Added domain ${args.hostname}, but it CANNOT verify yet: ${delegationWarning}` : dnsSyncWarning ? `Added domain ${args.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
|
|
1776
1883
|
data
|
|
1777
1884
|
});
|
|
1778
1885
|
}
|
|
@@ -1788,19 +1895,30 @@ defineTool({
|
|
|
1788
1895
|
"Inputs:",
|
|
1789
1896
|
" - domain_id: publicId of the domain (from list_domains or add_domain).",
|
|
1790
1897
|
"",
|
|
1791
|
-
"Returns: {
|
|
1898
|
+
"Returns: { domain, verified }. `verified` is the outcome of THIS check \u2014 the call does not merely queue one.",
|
|
1792
1899
|
"",
|
|
1793
|
-
|
|
1900
|
+
"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.",
|
|
1901
|
+
"",
|
|
1902
|
+
"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.",
|
|
1903
|
+
"",
|
|
1904
|
+
'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { verified: false, delegationWarning: "\u2026the registry still delegates wohnwagen-wuckert.de to docks15.rzone.de\u2026" }'
|
|
1794
1905
|
].join("\n"),
|
|
1795
1906
|
input: {
|
|
1796
1907
|
domain_id: z9.string().describe("Domain publicId.")
|
|
1797
1908
|
},
|
|
1798
1909
|
handler: async (args, ctx) => {
|
|
1799
1910
|
const teamId = await ctx.resolveTeamId();
|
|
1800
|
-
await ctx.hoststack.domains.verify(teamId, args.domain_id);
|
|
1911
|
+
const { domain } = await ctx.hoststack.domains.verify(teamId, args.domain_id);
|
|
1912
|
+
const verified = domain.status === "active";
|
|
1913
|
+
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.`;
|
|
1801
1914
|
return respond({
|
|
1802
|
-
summary
|
|
1803
|
-
data: {
|
|
1915
|
+
summary,
|
|
1916
|
+
data: {
|
|
1917
|
+
domain: shapeDomain(domain),
|
|
1918
|
+
verified,
|
|
1919
|
+
...domain.delegationWarning ? { delegationWarning: domain.delegationWarning } : {},
|
|
1920
|
+
...domain.dnsSyncWarning ? { dnsSyncWarning: domain.dnsSyncWarning } : {}
|
|
1921
|
+
}
|
|
1804
1922
|
});
|
|
1805
1923
|
}
|
|
1806
1924
|
});
|
|
@@ -3150,8 +3268,13 @@ var NOTIFICATION_EVENTS = [
|
|
|
3150
3268
|
"database.restore_failed",
|
|
3151
3269
|
"volume.backup_failed",
|
|
3152
3270
|
"volume.backup_overdue",
|
|
3271
|
+
"dns.registry_status_changed",
|
|
3272
|
+
"dns.registry_expiring",
|
|
3273
|
+
"dns.registry_domain_missing",
|
|
3274
|
+
"dns.registry_record_changed",
|
|
3153
3275
|
"domain.registrant_verification_lapsed",
|
|
3154
3276
|
"service.auto_restarted",
|
|
3277
|
+
"project.release_awaiting_start",
|
|
3155
3278
|
"machine.offline",
|
|
3156
3279
|
"machine.online",
|
|
3157
3280
|
"billing.invoice",
|
|
@@ -5308,9 +5431,15 @@ defineTool({
|
|
|
5308
5431
|
"Inputs:",
|
|
5309
5432
|
" - service_id: publicId of the service.",
|
|
5310
5433
|
"",
|
|
5311
|
-
"Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), createdAt, updatedAt.",
|
|
5434
|
+
"Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), backupEnabled, blockBacked, adopted, createdAt, updatedAt.",
|
|
5435
|
+
"",
|
|
5436
|
+
"`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 }).",
|
|
5437
|
+
"",
|
|
5438
|
+
'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.',
|
|
5312
5439
|
"",
|
|
5313
|
-
'
|
|
5440
|
+
'`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.',
|
|
5441
|
+
"",
|
|
5442
|
+
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active", backupEnabled: false }] }'
|
|
5314
5443
|
].join("\n"),
|
|
5315
5444
|
input: {
|
|
5316
5445
|
service_id: z22.string().describe("Service publicId (e.g. svc_abc123).")
|
|
@@ -5382,7 +5511,11 @@ defineTool({
|
|
|
5382
5511
|
" - volume_id: publicId of the volume to update.",
|
|
5383
5512
|
" - mount_path (optional): new in-container mount path.",
|
|
5384
5513
|
" - size_gb (optional): new size in GB (must be \u2265 current).",
|
|
5385
|
-
" - backup_enabled (optional): whether this volume is backed up. Turning it off stops future backups; it does not delete the ones already taken.",
|
|
5514
|
+
" - 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).",
|
|
5515
|
+
"",
|
|
5516
|
+
"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.",
|
|
5517
|
+
"",
|
|
5518
|
+
"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.",
|
|
5386
5519
|
"",
|
|
5387
5520
|
"Returns: { volume: Volume } \u2014 the updated record.",
|
|
5388
5521
|
"",
|
|
@@ -5415,6 +5548,78 @@ defineTool({
|
|
|
5415
5548
|
return respond({ summary: `Updated ${fields} on volume ${args.volume_id}.`, data });
|
|
5416
5549
|
}
|
|
5417
5550
|
});
|
|
5551
|
+
defineTool({
|
|
5552
|
+
name: "list_volume_backups",
|
|
5553
|
+
category: "volumes",
|
|
5554
|
+
description: [
|
|
5555
|
+
"List the archives a volume can be restored from, newest first.",
|
|
5556
|
+
"",
|
|
5557
|
+
'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.',
|
|
5558
|
+
"",
|
|
5559
|
+
"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.",
|
|
5560
|
+
"",
|
|
5561
|
+
"Inputs:",
|
|
5562
|
+
" - service_id: publicId of the service.",
|
|
5563
|
+
" - volume_id: publicId of the volume (vol_\u2026).",
|
|
5564
|
+
"",
|
|
5565
|
+
"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.",
|
|
5566
|
+
"",
|
|
5567
|
+
'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" }] }'
|
|
5568
|
+
].join("\n"),
|
|
5569
|
+
input: {
|
|
5570
|
+
service_id: z22.string().describe("Service publicId."),
|
|
5571
|
+
volume_id: z22.string().describe("Volume publicId (e.g. vol_\u2026).")
|
|
5572
|
+
},
|
|
5573
|
+
handler: async (args, ctx) => {
|
|
5574
|
+
const teamId = await ctx.resolveTeamId();
|
|
5575
|
+
const response = await ctx.hoststack.volumes.listRestorePoints(
|
|
5576
|
+
teamId,
|
|
5577
|
+
args.service_id,
|
|
5578
|
+
args.volume_id
|
|
5579
|
+
);
|
|
5580
|
+
const data = shapeList(response, "restorePoints", shape);
|
|
5581
|
+
const newest = response.restorePoints[0];
|
|
5582
|
+
const summary = data.items.length === 0 ? `Volume ${args.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 ${args.volume_id} has ${data.items.length} restore point${data.items.length === 1 ? "" : "s"}; newest ${newest?.archiveName} from ${newest?.createdAt}.`;
|
|
5583
|
+
return respond({ summary, data });
|
|
5584
|
+
}
|
|
5585
|
+
});
|
|
5586
|
+
defineTool({
|
|
5587
|
+
name: "restore_volume",
|
|
5588
|
+
category: "volumes",
|
|
5589
|
+
description: [
|
|
5590
|
+
"Unpack one of a volume's archives back over its contents.",
|
|
5591
|
+
"",
|
|
5592
|
+
'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".',
|
|
5593
|
+
"",
|
|
5594
|
+
"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.",
|
|
5595
|
+
"",
|
|
5596
|
+
"When to use: the user explicitly asks to roll a volume back to an earlier state, or to recover after data loss.",
|
|
5597
|
+
"",
|
|
5598
|
+
"Inputs:",
|
|
5599
|
+
" - service_id: publicId of the service.",
|
|
5600
|
+
" - volume_id: publicId of the volume.",
|
|
5601
|
+
" - 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.",
|
|
5602
|
+
"",
|
|
5603
|
+
"Returns: { ok: true } once the host has accepted the restore. The unpack itself runs on the host; re-check the service afterwards.",
|
|
5604
|
+
"",
|
|
5605
|
+
'Example: restore_volume({ service_id: "svc_abc", volume_id: "vol_xyz", backup_id: 41 })'
|
|
5606
|
+
].join("\n"),
|
|
5607
|
+
input: {
|
|
5608
|
+
service_id: z22.string().describe("Service publicId."),
|
|
5609
|
+
volume_id: z22.string().describe("Volume publicId."),
|
|
5610
|
+
backup_id: z22.number().int().positive().describe("Restore-point id from list_volume_backups. Confirm with the user first.")
|
|
5611
|
+
},
|
|
5612
|
+
handler: async (args, ctx) => {
|
|
5613
|
+
const teamId = await ctx.resolveTeamId();
|
|
5614
|
+
await ctx.hoststack.volumes.restore(teamId, args.service_id, args.volume_id, {
|
|
5615
|
+
backupId: args.backup_id
|
|
5616
|
+
});
|
|
5617
|
+
return respond({
|
|
5618
|
+
summary: `Restore of volume ${args.volume_id} from backup ${args.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.`,
|
|
5619
|
+
data: { ok: true }
|
|
5620
|
+
});
|
|
5621
|
+
}
|
|
5622
|
+
});
|
|
5418
5623
|
defineTool({
|
|
5419
5624
|
name: "delete_volume",
|
|
5420
5625
|
category: "volumes",
|