@hoststack.dev/mcp 0.25.0 → 0.26.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/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.25.0" : "0.0.0-dev";
6
+ var MCP_VERSION = true ? "0.26.0" : "0.0.0-dev";
7
7
  var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
8
8
 
9
9
  // src/api-client.ts
@@ -656,6 +656,10 @@ async function resolveMachineId(ctx, teamId, machine) {
656
656
  return matches[0].id;
657
657
  }
658
658
  function describeMachine(m) {
659
+ if (m.kind === "infra") {
660
+ const state = m.status === "active" ? "online" : "offline";
661
+ return `${m.name}: infrastructure machine for project ${m.infraProjectId}, ${state} (only that project's services can be pinned to it)`;
662
+ }
659
663
  if (!m.enrolled) return `${m.name}: registered but never paired`;
660
664
  if (m.status !== "active") return `${m.name}: offline`;
661
665
  if (m.agentBuild === "from-source") return `${m.name}: online, agent running from source`;
@@ -1425,19 +1429,85 @@ defineTool({
1425
1429
  "",
1426
1430
  "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
1431
  "",
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.',
1432
+ '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.',
1433
+ "",
1434
+ '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:',
1435
+ ' - "delegated" \u2014 the registry names our nameservers; queries reach us.',
1436
+ ' - "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.',
1437
+ ' - "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.',
1438
+ "",
1439
+ "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.",
1440
+ "",
1441
+ '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"] }] }',
1442
+ "",
1443
+ '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").',
1444
+ "",
1445
+ '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.',
1429
1446
  "",
1430
- 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
1447
+ '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.',
1448
+ "",
1449
+ "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`.",
1450
+ "",
1451
+ '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
1452
  ].join("\n"),
1432
1453
  input: {},
1433
1454
  handler: async (_args, ctx) => {
1434
1455
  const teamId = await ctx.resolveTeamId();
1435
1456
  const response = await ctx.hoststack.dns.listZones(teamId);
1436
- const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
1437
- 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"}.`;
1457
+ const zones = Array.isArray(response.zones) ? response.zones : [];
1458
+ const items = zones.map(shape);
1459
+ const foreign = zones.filter((z23) => z23.delegationStatus === "foreign");
1460
+ 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(
1461
+ (z23) => `${z23.domainName} \u2192 ${(z23.delegationObservedNs ?? []).join(", ") || "unknown nameservers"}`
1462
+ ).join("; ") + `. Records on ${foreign.length === 1 ? "it" : "them"} are correct but unreachable; the nameservers must be changed at the registrar.`;
1463
+ 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
1464
  return respond({ summary, data: { items } });
1439
1465
  }
1440
1466
  });
1467
+ defineTool({
1468
+ name: "check_dns_delegation",
1469
+ category: "dns",
1470
+ description: [
1471
+ "Read, live, whether the parent registry actually delegates a zone's apex to HostStack \u2014 and store the reading on the zone.",
1472
+ "",
1473
+ '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.',
1474
+ "",
1475
+ "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.",
1476
+ "",
1477
+ "Provide EITHER zone_id (the zone publicId) OR domain (apex or subdomain \u2014 the longest-matching hosted apex wins).",
1478
+ "",
1479
+ "Returns: { zone, delegation: { status, observedNameservers, expectedNameservers, checkedAt } }.",
1480
+ ` - 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.`,
1481
+ ' - 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.',
1482
+ ' - 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.',
1483
+ "",
1484
+ '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.',
1485
+ "",
1486
+ '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"] } }'
1487
+ ].join("\n"),
1488
+ input: {
1489
+ zone_id: z8.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
1490
+ domain: z8.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
1491
+ },
1492
+ handler: async (args, ctx) => {
1493
+ const teamId = await ctx.resolveTeamId();
1494
+ const zoneInput = {};
1495
+ if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
1496
+ if (args.domain !== void 0) zoneInput.domain = args.domain;
1497
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1498
+ const { delegation } = await ctx.hoststack.dns.checkDelegation(teamId, zone.publicId);
1499
+ const observed = delegation.observedNameservers.join(", ");
1500
+ const expected = delegation.expectedNameservers.join(" / ");
1501
+ 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.`;
1502
+ return respond({
1503
+ summary,
1504
+ data: {
1505
+ zone: { publicId: zone.publicId, domainName: zone.domainName },
1506
+ delegation
1507
+ }
1508
+ });
1509
+ }
1510
+ });
1441
1511
  defineTool({
1442
1512
  name: "list_dns_records",
1443
1513
  category: "dns",
@@ -1769,10 +1839,14 @@ defineTool({
1769
1839
  };
1770
1840
  if (args.path_prefix !== void 0) input.pathPrefix = args.path_prefix;
1771
1841
  const response = await ctx.hoststack.domains.add(teamId, input);
1772
- const dnsSyncWarning = response.domain.dnsSyncWarning;
1773
- const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
1842
+ const { dnsSyncWarning, delegationWarning } = response.domain;
1843
+ const data = {
1844
+ domain: shapeDomain(response.domain),
1845
+ ...dnsSyncWarning ? { dnsSyncWarning } : {},
1846
+ ...delegationWarning ? { delegationWarning } : {}
1847
+ };
1774
1848
  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.`,
1849
+ 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
1850
  data
1777
1851
  });
1778
1852
  }
@@ -1788,19 +1862,30 @@ defineTool({
1788
1862
  "Inputs:",
1789
1863
  " - domain_id: publicId of the domain (from list_domains or add_domain).",
1790
1864
  "",
1791
- "Returns: { ok: true }. Re-call list_domains to inspect the updated verified flag and SSL status.",
1865
+ "Returns: { domain, verified }. `verified` is the outcome of THIS check \u2014 the call does not merely queue one.",
1792
1866
  "",
1793
- 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
1867
+ "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.",
1868
+ "",
1869
+ "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.",
1870
+ "",
1871
+ 'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { verified: false, delegationWarning: "\u2026the registry still delegates wohnwagen-wuckert.de to docks15.rzone.de\u2026" }'
1794
1872
  ].join("\n"),
1795
1873
  input: {
1796
1874
  domain_id: z9.string().describe("Domain publicId.")
1797
1875
  },
1798
1876
  handler: async (args, ctx) => {
1799
1877
  const teamId = await ctx.resolveTeamId();
1800
- await ctx.hoststack.domains.verify(teamId, args.domain_id);
1878
+ const { domain } = await ctx.hoststack.domains.verify(teamId, args.domain_id);
1879
+ const verified = domain.status === "active";
1880
+ 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
1881
  return respond({
1802
- summary: `Triggered DNS verification for ${args.domain_id}.`,
1803
- data: { ok: true }
1882
+ summary,
1883
+ data: {
1884
+ domain: shapeDomain(domain),
1885
+ verified,
1886
+ ...domain.delegationWarning ? { delegationWarning: domain.delegationWarning } : {},
1887
+ ...domain.dnsSyncWarning ? { dnsSyncWarning: domain.dnsSyncWarning } : {}
1888
+ }
1804
1889
  });
1805
1890
  }
1806
1891
  });
@@ -3150,6 +3235,10 @@ var NOTIFICATION_EVENTS = [
3150
3235
  "database.restore_failed",
3151
3236
  "volume.backup_failed",
3152
3237
  "volume.backup_overdue",
3238
+ "dns.registry_status_changed",
3239
+ "dns.registry_expiring",
3240
+ "dns.registry_domain_missing",
3241
+ "dns.registry_record_changed",
3153
3242
  "domain.registrant_verification_lapsed",
3154
3243
  "service.auto_restarted",
3155
3244
  "machine.offline",
@@ -5308,9 +5397,11 @@ defineTool({
5308
5397
  "Inputs:",
5309
5398
  " - service_id: publicId of the service.",
5310
5399
  "",
5311
- "Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), createdAt, updatedAt.",
5400
+ "Returns: { items: Volume[] } \u2014 each entry has id, publicId, name, mountPath, sizeGb, status (pending|active|deleting), backupEnabled, createdAt, updatedAt.",
5312
5401
  "",
5313
- 'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
5402
+ "`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 }).",
5403
+ "",
5404
+ 'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active", backupEnabled: false }] }'
5314
5405
  ].join("\n"),
5315
5406
  input: {
5316
5407
  service_id: z22.string().describe("Service publicId (e.g. svc_abc123).")
@@ -5382,7 +5473,9 @@ defineTool({
5382
5473
  " - volume_id: publicId of the volume to update.",
5383
5474
  " - mount_path (optional): new in-container mount path.",
5384
5475
  " - 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.",
5476
+ " - 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.",
5477
+ "",
5478
+ "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
5479
  "",
5387
5480
  "Returns: { volume: Volume } \u2014 the updated record.",
5388
5481
  "",
@@ -5415,6 +5508,78 @@ defineTool({
5415
5508
  return respond({ summary: `Updated ${fields} on volume ${args.volume_id}.`, data });
5416
5509
  }
5417
5510
  });
5511
+ defineTool({
5512
+ name: "list_volume_backups",
5513
+ category: "volumes",
5514
+ description: [
5515
+ "List the archives a volume can be restored from, newest first.",
5516
+ "",
5517
+ '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.',
5518
+ "",
5519
+ "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.",
5520
+ "",
5521
+ "Inputs:",
5522
+ " - service_id: publicId of the service.",
5523
+ " - volume_id: publicId of the volume (vol_\u2026).",
5524
+ "",
5525
+ "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.",
5526
+ "",
5527
+ '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" }] }'
5528
+ ].join("\n"),
5529
+ input: {
5530
+ service_id: z22.string().describe("Service publicId."),
5531
+ volume_id: z22.string().describe("Volume publicId (e.g. vol_\u2026).")
5532
+ },
5533
+ handler: async (args, ctx) => {
5534
+ const teamId = await ctx.resolveTeamId();
5535
+ const response = await ctx.hoststack.volumes.listRestorePoints(
5536
+ teamId,
5537
+ args.service_id,
5538
+ args.volume_id
5539
+ );
5540
+ const data = shapeList(response, "restorePoints", shape);
5541
+ const newest = response.restorePoints[0];
5542
+ 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}.`;
5543
+ return respond({ summary, data });
5544
+ }
5545
+ });
5546
+ defineTool({
5547
+ name: "restore_volume",
5548
+ category: "volumes",
5549
+ description: [
5550
+ "Unpack one of a volume's archives back over its contents.",
5551
+ "",
5552
+ '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".',
5553
+ "",
5554
+ "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.",
5555
+ "",
5556
+ "When to use: the user explicitly asks to roll a volume back to an earlier state, or to recover after data loss.",
5557
+ "",
5558
+ "Inputs:",
5559
+ " - service_id: publicId of the service.",
5560
+ " - volume_id: publicId of the volume.",
5561
+ " - 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.",
5562
+ "",
5563
+ "Returns: { ok: true } once the host has accepted the restore. The unpack itself runs on the host; re-check the service afterwards.",
5564
+ "",
5565
+ 'Example: restore_volume({ service_id: "svc_abc", volume_id: "vol_xyz", backup_id: 41 })'
5566
+ ].join("\n"),
5567
+ input: {
5568
+ service_id: z22.string().describe("Service publicId."),
5569
+ volume_id: z22.string().describe("Volume publicId."),
5570
+ backup_id: z22.number().int().positive().describe("Restore-point id from list_volume_backups. Confirm with the user first.")
5571
+ },
5572
+ handler: async (args, ctx) => {
5573
+ const teamId = await ctx.resolveTeamId();
5574
+ await ctx.hoststack.volumes.restore(teamId, args.service_id, args.volume_id, {
5575
+ backupId: args.backup_id
5576
+ });
5577
+ return respond({
5578
+ 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.`,
5579
+ data: { ok: true }
5580
+ });
5581
+ }
5582
+ });
5418
5583
  defineTool({
5419
5584
  name: "delete_volume",
5420
5585
  category: "volumes",