@hoststack.dev/mcp 0.19.0 → 0.21.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
@@ -85,7 +85,7 @@ If `HOSTSTACK_API_KEY` is set in your shell, it gets baked into the snippet; oth
85
85
 
86
86
  ## Tool inventory
87
87
 
88
- 104 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.
88
+ 107 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.
89
89
 
90
90
  | Category | Read | Write |
91
91
  | ------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -98,7 +98,7 @@ If `HOSTSTACK_API_KEY` is set in your shell, it gets baked into the snippet; oth
98
98
  | **volumes** | `list_volumes` | `create_volume`, `update_volume`, `delete_volume` |
99
99
  | **resource-links** | `list_managed_resources`, `list_service_resources` | `link_resource_to_service`, `unlink_resource_from_service` |
100
100
  | **machines** | `list_machines`, `get_machine` | — |
101
- | **domains** | `list_domains` | `add_domain`, `verify_domain`, `remove_domain` |
101
+ | **domains** | `list_domains` | `add_domain`, `verify_domain`, `update_domain`, `remove_domain` |
102
102
  | **dns** | `list_dns_zones`, `list_dns_records`, `get_dns_record` | `create_dns_record`, `update_dns_record`, `delete_dns_record`, `resync_dns_record` |
103
103
  | **env-vars** | `list_env_vars` | `set_env_var`, `delete_env_var`, `bulk_set_env_vars` |
104
104
  | **cron** | `list_cron_executions`, `get_cron_execution` | — |
@@ -106,7 +106,7 @@ If `HOSTSTACK_API_KEY` is set in your shell, it gets baked into the snippet; oth
106
106
  | **alerts** | `list_alerts`, `list_notification_channels` | `create_notification_channel`, `update_notification_channel`, `delete_notification_channel`, `test_notification_channel` |
107
107
  | **errors** | `list_error_issues`, `get_error_issue`, `list_ingest_keys` | `update_error_issue`, `fix_error_in_dev_box`, `create_ingest_key`, `delete_ingest_key` |
108
108
  | **uptime** | `get_uptime_check` | `set_uptime_check`, `delete_uptime_check` |
109
- | **analytics** | `list_analytics_sites`, `check_analytics_site`, `get_analytics_summary`, `get_analytics_overview` | `create_analytics_site`, `verify_site_domain`, `set_site_uptime_check` |
109
+ | **analytics** | `list_analytics_sites`, `check_analytics_site`, `get_analytics_summary`, `get_analytics_overview`, `get_site_uptime_check` | `create_analytics_site`, `update_analytics_site`, `verify_site_domain`, `set_site_uptime_check` |
110
110
  | **activity-log** | `list_activity_log` | — |
111
111
  | **github** | — | `sync_github_repos` |
112
112
  | **support** | — | `report_issue` |
@@ -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.19.0" : "0.0.0-dev";
11
+ var MCP_VERSION = true ? "0.21.0" : "0.0.0-dev";
12
12
  var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
13
13
 
14
14
  // src/api-client.ts
@@ -1723,9 +1723,11 @@ defineTool({
1723
1723
  "",
1724
1724
  "When to use: enumerate live domains, audit DNS verification status, or find which service a hostname resolves to before troubleshooting routing.",
1725
1725
  "",
1726
- "Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, createdAt.",
1726
+ "Returns: { items: Domain[] } \u2014 id, publicId, hostname, serviceId, verified, dnsTargets, sslStatus, isPrimary, createdAt.",
1727
1727
  "",
1728
- 'Example: list_domains() \u2192 { items: [{ hostname: "api.example.com", verified: true, sslStatus: "active", \u2026 }] }'
1728
+ "`isPrimary` is the one the platform treats as the service's address: what `${service.url}` resolves to, what its uptime check probes, and what the monitoring page shows. When NO domain on a service is primary \u2014 the default, since nothing sets it automatically \u2014 that choice falls to the OLDEST domain on the service, which on a renamed host is usually the retired alias rather than the canonical name. Nominate one with update_domain.",
1729
+ "",
1730
+ 'Example: list_domains() \u2192 { items: [{ hostname: "api.example.com", verified: true, sslStatus: "active", isPrimary: false, \u2026 }] }'
1729
1731
  ].join("\n"),
1730
1732
  input: {},
1731
1733
  handler: async (_args, ctx) => {
@@ -1801,6 +1803,54 @@ defineTool({
1801
1803
  });
1802
1804
  }
1803
1805
  });
1806
+ defineTool({
1807
+ name: "update_domain",
1808
+ category: "domains",
1809
+ description: [
1810
+ "Change a domain's settings \u2014 most usefully, nominate it as the service's PRIMARY hostname.",
1811
+ "",
1812
+ "When to use: a service answers on more than one hostname and you need to say which one is canonical \u2014 before setting an uptime check on it, after renaming a storefront, or when `${service.url}` is resolving to the wrong name. Also for toggling https on a domain or turning one into a redirect.",
1813
+ "",
1814
+ "WHY PRIMARY MATTERS. A service can answer on several hostnames that behave differently \u2014 the canonical one serves the page, a retired alias 301s to it \u2014 and three parts of the platform have to pick ONE of them: the address a service advertises to itself in `${service.url}`, the host its uptime check probes, and the hostname shown on the monitoring page. All three pick the primary, and fall back to the OLDEST domain when no primary is nominated. So a service with two hostnames and no primary has its uptime check pointed at whichever domain was added first, which may well be the alias that redirects \u2014 and `set_uptime_check` takes a path, never a host, so nominating the primary here is the only way to say which name is checked.",
1815
+ "",
1816
+ "One primary per service: promoting a domain demotes its sibling in the same write, so there is never a pair to choose between.",
1817
+ "",
1818
+ "Inputs:",
1819
+ " - domain_id: publicId of the domain (from list_domains).",
1820
+ " - isPrimary: make this the service's primary hostname.",
1821
+ " - sslEnabled: serve it over https. Applies on the next deploy, which this triggers.",
1822
+ " - redirectTo: send every request to an absolute http(s) URL instead of the service. Pass null to clear.",
1823
+ "",
1824
+ "Returns: { domain } \u2014 the updated row, including isPrimary.",
1825
+ "",
1826
+ 'Example: update_domain({ domain_id: "dom_xyz", isPrimary: true }) \u2192 { domain: { hostname: "shop.example.com", isPrimary: true } }'
1827
+ ].join("\n"),
1828
+ input: {
1829
+ domain_id: z9.string().describe("Domain publicId."),
1830
+ isPrimary: z9.boolean().optional(),
1831
+ sslEnabled: z9.boolean().optional(),
1832
+ redirectTo: z9.string().max(2e3).nullable().optional().describe("Absolute http(s) URL, or null to clear.")
1833
+ },
1834
+ handler: async (args2, ctx) => {
1835
+ const { domain_id: domainId, ...patch } = args2;
1836
+ const body = Object.fromEntries(
1837
+ Object.entries(patch).filter(([, v]) => v !== void 0)
1838
+ );
1839
+ if (Object.keys(body).length === 0) {
1840
+ return respond({
1841
+ summary: "Nothing to change \u2014 pass isPrimary, sslEnabled or redirectTo.",
1842
+ data: { ok: false }
1843
+ });
1844
+ }
1845
+ const teamId = await ctx.resolveTeamId();
1846
+ const response = await ctx.hoststack.domains.update(teamId, domainId, body);
1847
+ const domain = shapeDomain(response.domain);
1848
+ return respond({
1849
+ summary: body.isPrimary === true ? `${String(domain.hostname ?? domainId)} is now the primary hostname for its service \u2014 it is what \`\${service.url}\` resolves to and what the uptime check probes.` : `Updated ${String(domain.hostname ?? domainId)}.`,
1850
+ data: { domain }
1851
+ });
1852
+ }
1853
+ });
1804
1854
  defineTool({
1805
1855
  name: "remove_domain",
1806
1856
  category: "domains",
@@ -2156,6 +2206,15 @@ defineTool({
2156
2206
 
2157
2207
  // src/tools/analytics.ts
2158
2208
  import { z as z12 } from "zod";
2209
+
2210
+ // src/lib/format.ts
2211
+ var MCP_LOCALE = "en-IE";
2212
+ var COUNT_FORMAT = new Intl.NumberFormat(MCP_LOCALE);
2213
+ function formatCount(value) {
2214
+ return COUNT_FORMAT.format(value);
2215
+ }
2216
+
2217
+ // src/tools/analytics.ts
2159
2218
  async function resolveSiteIds(domains, teamId, api) {
2160
2219
  if (!domains || domains.length === 0) return void 0;
2161
2220
  const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
@@ -2174,6 +2233,10 @@ async function resolveSiteIds(domains, teamId, api) {
2174
2233
  }
2175
2234
  return ids.join(",");
2176
2235
  }
2236
+ var ALLOWED_ORIGINS_INPUT = z12.array(z12.string().trim().min(1).max(253)).max(20).describe(
2237
+ 'Extra origins allowed to post events, beyond the site domain and its subdomains (both are always allowed). Bare hostnames, e.g. ["staging.example.com", "localhost:5173"]. Replaces the whole list; pass [] to clear it.'
2238
+ );
2239
+ var RETENTION_DAYS_INPUT = z12.number().int().positive().describe("How many days of raw events to keep. Rollups outlive this.");
2177
2240
  defineTool({
2178
2241
  name: "list_analytics_sites",
2179
2242
  category: "analytics",
@@ -2209,7 +2272,9 @@ defineTool({
2209
2272
  "",
2210
2273
  "The ingest endpoint answers HTTP 204 to a real site key and to a typo alike \u2014 deliberately, so it cannot be used to enumerate keys \u2014 which means five completely different situations produce the same empty chart: a stale or mistyped key in the deployed snippet, an origin the site does not allow, a blown hourly quota, events blocked in the browser before they leave (CORS, ad blocker, CSP), and simply no visitors yet. This tool tells them apart.",
2211
2274
  "",
2212
- "Returns: health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), the site key the snippet must carry, allowed origins, and quota usage this hour.",
2275
+ "Returns: health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), refusedOrigins (which origins were turned away for bad_origin and how often, over the same window), the site key the snippet must carry, allowed origins, and quota usage this hour.",
2276
+ "",
2277
+ "Use refusedOrigins, not lastRefusalOrigin, to decide what to allow: lastRefusalOrigin is the newest refusal of ANY reason, and bots outnumber everything, so one crawler hit overwrites the origin behind a real outage. Feed what you find to update_analytics_site \u2014 and if the origin is NOT the user's, the site key is public by design and somebody has pasted it into their own page, so the answer is to rotate the key rather than allow them.",
2213
2278
  "",
2214
2279
  "health='never' means nothing has EVER reached ingest for this key, so the request is not leaving the browser \u2014 check the snippet is in the deployed HTML and tell the user to add `data-debug` to the script tag, which makes the tracker log its resolved endpoint and every accepted 204 to the console.",
2215
2280
  "",
@@ -2303,7 +2368,7 @@ defineTool({
2303
2368
  "the filters you passed were NOT applied \u2014 they only work on shorter ranges"
2304
2369
  );
2305
2370
  }
2306
- const summary = `${current.pageviews.toLocaleString()} pageviews from ${current.visitors.toLocaleString()} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
2371
+ const summary = `${formatCount(current.pageviews)} pageviews from ${formatCount(current.visitors)} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
2307
2372
  return respond({ summary, data: response });
2308
2373
  }
2309
2374
  });
@@ -2317,7 +2382,9 @@ defineTool({
2317
2382
  "",
2318
2383
  "If the domain is already attached to a service on this team, the new site links itself to that service and appears on its Analytics tab. Nothing is counted until the snippet is actually on the page \u2014 creating the site alone produces an empty dashboard, which is expected, not a fault.",
2319
2384
  "",
2320
- 'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
2385
+ 'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain), allowed_origins (optional extra origins beyond the domain and its subdomains), retention_days (optional raw-event retention).',
2386
+ "",
2387
+ "To change any of these later \u2014 or to attach the site to a service \u2014 use update_analytics_site. Creating is not the only chance to set them.",
2321
2388
  "",
2322
2389
  "Returns: the created site (id, domain, name, ingestKey, retentionDays) and the ready-made `snippet` to paste into the page \u2014 the key is in that snippet, so hand it over rather than describing it.",
2323
2390
  "",
@@ -2325,13 +2392,17 @@ defineTool({
2325
2392
  ].join("\n"),
2326
2393
  input: {
2327
2394
  domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
2328
- name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
2395
+ name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain."),
2396
+ allowed_origins: ALLOWED_ORIGINS_INPUT.optional(),
2397
+ retention_days: RETENTION_DAYS_INPUT.optional()
2329
2398
  },
2330
2399
  handler: async (args2, ctx) => {
2331
2400
  const teamId = await ctx.resolveTeamId();
2332
2401
  const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
2333
2402
  domain: args2.domain,
2334
- ...args2.name ? { name: args2.name } : {}
2403
+ ...args2.name ? { name: args2.name } : {},
2404
+ ...args2.allowed_origins ? { allowedOrigins: args2.allowed_origins } : {},
2405
+ ...args2.retention_days ? { retentionDays: args2.retention_days } : {}
2335
2406
  });
2336
2407
  const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
2337
2408
  return respond({
@@ -2340,6 +2411,53 @@ defineTool({
2340
2411
  });
2341
2412
  }
2342
2413
  });
2414
+ defineTool({
2415
+ name: "update_analytics_site",
2416
+ category: "analytics",
2417
+ description: [
2418
+ "Change an existing analytics site: its allowed origins, display name, retention, or which service it belongs to.",
2419
+ "",
2420
+ "When to use: events are being refused with `bad_origin` because they come from an origin the site does not list (check_analytics_site names it), a site needs a clearer name, retention should change, or the site should appear on a service's Analytics tab.",
2421
+ "",
2422
+ "Inputs: domain (required \u2014 the site to change, by its bare hostname), then any of allowed_origins, name, retention_days, service_id. Everything is optional except domain; only the fields you pass are touched.",
2423
+ "",
2424
+ "allowed_origins REPLACES the list rather than appending, so read the current one from list_analytics_sites first and send it back with your addition. The site domain and its subdomains are always allowed and never need listing.",
2425
+ "",
2426
+ "Returns: { site } \u2014 the site as it now stands.",
2427
+ "",
2428
+ "Example: update_analytics_site({ domain: 'example.com', allowed_origins: ['staging.example.com'] }) \u2192 { site: { domain: 'example.com', allowedOrigins: ['staging.example.com'], \u2026 } }."
2429
+ ].join("\n"),
2430
+ input: {
2431
+ domain: z12.string().min(1).max(253).describe("Bare hostname of a site this team tracks."),
2432
+ allowed_origins: ALLOWED_ORIGINS_INPUT.optional(),
2433
+ name: z12.string().trim().min(1).max(100).optional().describe("New display name."),
2434
+ retention_days: RETENTION_DAYS_INPUT.optional(),
2435
+ service_id: z12.number().int().positive().nullable().optional().describe("Numeric service id to attach this site to, or null to detach it.")
2436
+ },
2437
+ handler: async (args2, ctx) => {
2438
+ const teamId = await ctx.resolveTeamId();
2439
+ const siteId = await resolveSiteIds([args2.domain], teamId, ctx.api);
2440
+ const patch = {};
2441
+ if (args2.allowed_origins !== void 0) patch["allowedOrigins"] = args2.allowed_origins;
2442
+ if (args2.name !== void 0) patch["name"] = args2.name;
2443
+ if (args2.retention_days !== void 0) patch["retentionDays"] = args2.retention_days;
2444
+ if (args2.service_id !== void 0) patch["serviceId"] = args2.service_id;
2445
+ if (Object.keys(patch).length === 0) {
2446
+ throw new Error(
2447
+ "Nothing to update. Pass at least one of allowed_origins, name, retention_days or service_id."
2448
+ );
2449
+ }
2450
+ const site = await ctx.api.patch(
2451
+ `/api/analytics/${teamId}/sites/${siteId}`,
2452
+ patch
2453
+ );
2454
+ const changed = Object.keys(patch).join(", ");
2455
+ return respond({
2456
+ summary: `Updated ${site.domain} (${changed}).`,
2457
+ data: { site: shape(site) }
2458
+ });
2459
+ }
2460
+ });
2343
2461
  defineTool({
2344
2462
  name: "verify_site_domain",
2345
2463
  category: "analytics",
@@ -2385,6 +2503,42 @@ defineTool({
2385
2503
  });
2386
2504
  }
2387
2505
  });
2506
+ defineTool({
2507
+ name: "get_site_uptime_check",
2508
+ category: "analytics",
2509
+ description: [
2510
+ "Read a site's uptime check without touching it.",
2511
+ "",
2512
+ 'When to use: answer "has it probed yet?", "is it up?", "how many failures in a row?" \u2014 the site counterpart of get_uptime_check.',
2513
+ "",
2514
+ 'READ THIS BEFORE REACHING FOR set_site_uptime_check TO INSPECT ONE. The setter RESETS the accumulated state it returns (status back to "unknown", consecutiveFailures to 0, lastCheckedAt to null), because a check whose shape just changed has not observed the new shape failing. So using it to look at a check is what stops the check ever showing a probe: every read restarts the measurement, and a working check reads as one that never runs. This tool exists because that trap cost a real investigation an afternoon.',
2515
+ "",
2516
+ "Returns: { check } \u2014 enabled, path, method, expectedStatus, timeoutMs, intervalSeconds, failureThreshold, plus live state: status ('up' | 'down' | 'unknown'), consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt. `null` when the site has no check.",
2517
+ "",
2518
+ "Example: get_site_uptime_check({ siteId: 12 }) \u2192 { check: { status: 'up', lastStatusCode: 200, lastCheckedAt: '2026-09-09T05:27:34Z' } }."
2519
+ ].join("\n"),
2520
+ input: {
2521
+ siteId: z12.number().int().positive()
2522
+ },
2523
+ handler: async (args2, ctx) => {
2524
+ const teamId = await ctx.resolveTeamId();
2525
+ const response = await ctx.api.get(
2526
+ `/api/analytics/${teamId}/sites/${args2.siteId}/uptime-check`
2527
+ );
2528
+ if (!response.check) {
2529
+ return respond({
2530
+ summary: "No uptime check on this site \u2014 nothing is watching it.",
2531
+ data: { check: null }
2532
+ });
2533
+ }
2534
+ const check = shape(response.check);
2535
+ const lastCheckedAt = check.lastCheckedAt;
2536
+ return respond({
2537
+ summary: `Uptime check is ${String(check.status ?? "unknown")}${lastCheckedAt ? `, last probed ${String(lastCheckedAt)}` : ", not probed yet"}.`,
2538
+ data: { check }
2539
+ });
2540
+ }
2541
+ });
2388
2542
  defineTool({
2389
2543
  name: "set_site_uptime_check",
2390
2544
  category: "analytics",
@@ -2393,7 +2547,9 @@ defineTool({
2393
2547
  "",
2394
2548
  "When to use: the user has a site running somewhere else (their own box, another provider) and wants to know when it goes down. This is the one observability capability an off-platform site cannot provide for itself: analytics is a script tag and error reporting is an HTTP POST, but an outside-in probe has to come from outside.",
2395
2549
  "",
2396
- "For a site that IS a HostStack service, use set_uptime_check with its serviceId instead \u2014 that one follows the service if its domain changes.",
2550
+ "Works on a site HostStack DOES host too, and that is not a worse option: this check names ONE hostname \u2014 the site's own proven domain \u2014 where set_uptime_check follows whichever of the service's domains is primary at probe time. A service answering on a canonical host plus a 301 alias has no single correct expectedStatus, so a site check per hostname is how each one gets asserted, and nothing in the prober treats a service-backed site differently. Use set_uptime_check when the check should follow the service's domain; use this one when a named host is the point.",
2551
+ "",
2552
+ 'To READ a check, call get_site_uptime_check. Changing the shape of a check RESETS its accumulated state (status to "unknown", consecutiveFailures to 0, lastCheckedAt to null) \u2014 the new shape has not been measured yet, so carrying failures forward would alert about a condition nobody observed. A write that resolves to the shape ALREADY STORED changes nothing and leaves that state alone. The inputs are defaults rather than a partial edit, though: omitting intervalSeconds on a check currently set to 30 resolves to 60, which IS a change.',
2397
2553
  "",
2398
2554
  "REQUIRES a proven domain. Call verify_site_domain first if `domainProven` is false; an unverified target is refused with 400. This is not paperwork: an uptime check makes the control plane fetch the hostname every interval, forever, from our IP, so it must be a name the team has shown it owns.",
2399
2555
  "",
@@ -2422,9 +2578,11 @@ defineTool({
2422
2578
  `/api/analytics/${teamId}/sites/${siteId}/uptime-check`,
2423
2579
  body
2424
2580
  );
2581
+ const check = shape(response.check);
2582
+ const probed = check["lastCheckedAt"] !== null && check["lastCheckedAt"] !== void 0;
2425
2583
  return respond({
2426
- summary: "Uptime check saved. It will start reporting within a minute or two.",
2427
- data: { check: shape(response.check) }
2584
+ summary: probed ? `Uptime check saved \u2014 the shape was already stored, so nothing was reset: still ${String(check["status"])}, last probed ${String(check["lastCheckedAt"])}.` : 'Uptime check saved, and not probed yet \u2014 "unknown" with no lastCheckedAt is the state of a check that has not run once, not a broken one. It reports within a minute or two: read it with get_site_uptime_check, never by saving it again.',
2585
+ data: { check }
2428
2586
  });
2429
2587
  }
2430
2588
  });
@@ -2852,11 +3010,17 @@ var NOTIFICATION_EVENTS = [
2852
3010
  "service.suspended",
2853
3011
  "service.resumed",
2854
3012
  "service.restart_failed",
2855
- "service.auto_suspended",
3013
+ "service.no_running_container",
3014
+ "service.health_check_failed",
2856
3015
  "service.acme_cert_failed",
2857
3016
  "service.resource_alert",
3017
+ "service.pressure_sustained",
3018
+ "service.pressure_recovered",
2858
3019
  "service.uptime_down",
2859
3020
  "service.uptime_recovered",
3021
+ "watchdog.reported_down",
3022
+ "watchdog.reported_recovered",
3023
+ "watchdog.silent",
2860
3024
  "error.issue_new",
2861
3025
  "error.issue_regressed",
2862
3026
  "git.auth_failed",
@@ -2868,7 +3032,13 @@ var NOTIFICATION_EVENTS = [
2868
3032
  "devenv.task.needs_input",
2869
3033
  "devenv.task.finished",
2870
3034
  "database.backup_failed",
3035
+ "database.backup_overdue",
3036
+ "database.failed",
2871
3037
  "database.restore_failed",
3038
+ "volume.backup_failed",
3039
+ "volume.backup_overdue",
3040
+ "domain.registrant_verification_lapsed",
3041
+ "service.auto_restarted",
2872
3042
  "machine.offline",
2873
3043
  "machine.online",
2874
3044
  "billing.invoice",
@@ -3288,6 +3458,8 @@ defineTool({
3288
3458
  ' - task_id: "task_\u2026" publicId or numeric id.',
3289
3459
  ' - status: "done" or "idea". Only these two; `queued`/`running`/`failed`/`cancelled` belong to the runner and are rejected.',
3290
3460
  " - title / body / service_id: optional edits.",
3461
+ ' - provider: which agent runs it \u2014 "claude", "codex" or "opencode". null clears the pin and lets the box decide.',
3462
+ ' - permission_mode: how that agent is allowed to act \u2014 "default" (ask before acting), "acceptEdits" (edit files without asking) or "bypassPermissions" (no prompts at all). Only set bypassPermissions when the person asked for it: it is the box-wide yolo setting scoped to one task.',
3291
3463
  "",
3292
3464
  "Only mark a task done when it is actually resolved - the backlog is what someone reads to decide what still needs doing.",
3293
3465
  "",
@@ -3300,7 +3472,11 @@ defineTool({
3300
3472
  status: z17.enum(["idea", "done"]).optional().describe("The only two a person may set. The runner owns the rest of the lifecycle."),
3301
3473
  title: z17.string().min(1).max(200).optional().describe("New title."),
3302
3474
  body: z17.string().max(2e4).optional().describe("New prompt body."),
3303
- service_id: z17.union([z17.number().int().positive(), z17.string()]).nullable().optional().describe("Pin to a dev box, or null to unpin it back to a loose idea.")
3475
+ service_id: z17.union([z17.number().int().positive(), z17.string()]).nullable().optional().describe("Pin to a dev box, or null to unpin it back to a loose idea."),
3476
+ provider: z17.enum(["claude", "codex", "opencode"]).nullable().optional().describe("Which agent runs this task, or null to leave it to the box."),
3477
+ permission_mode: z17.enum(["acceptEdits", "bypassPermissions", "default"]).optional().describe(
3478
+ "How the agent may act. bypassPermissions asks for nothing \u2014 set it only on request."
3479
+ )
3304
3480
  },
3305
3481
  handler: async (args2, ctx) => {
3306
3482
  const teamId = await ctx.resolveTeamId();
@@ -3309,7 +3485,10 @@ defineTool({
3309
3485
  ...args2.status ? { status: args2.status } : {},
3310
3486
  ...args2.title ? { title: args2.title } : {},
3311
3487
  ...args2.body !== void 0 ? { body: args2.body } : {},
3312
- ...serviceId !== void 0 ? { serviceId } : {}
3488
+ ...serviceId !== void 0 ? { serviceId } : {},
3489
+ // null is meaningful on provider (unpin), so compare to undefined.
3490
+ ...args2.provider !== void 0 ? { provider: args2.provider } : {},
3491
+ ...args2.permission_mode !== void 0 ? { permissionMode: args2.permission_mode } : {}
3313
3492
  });
3314
3493
  const data = shape(response.task);
3315
3494
  return respond({
@@ -4091,10 +4270,12 @@ defineTool({
4091
4270
  "",
4092
4271
  "This resizes a cloud Dev Box, NOT a project deploy environment.",
4093
4272
  "",
4094
- "When to use: a Dev Box OOM-killed (see exitReason/recommendedSize from list_dev_environments), or you just want more headroom. This changes the SIZE TIER \u2014 unlike per-config memory/CPU overrides, which are clamped to the current tier and so cannot grow a box past it.",
4273
+ "When to use: a Dev Box OOM-killed (see exitReason/recommendedSize from list_dev_environments), or you just want more headroom. This changes the SIZE TIER.",
4095
4274
  "",
4096
4275
  "How it applies: the new tier's memory + CPU take effect LIVE on the running container (no recreate, no dropped shell sessions); a larger disk takes effect on the next recreate (suspend\u2192resume). Dev boxes are floored to the OOM-safe minimum size server-side.",
4097
4276
  "",
4277
+ "The tier is not always the container's limit, so check `effectiveMemoryMb` from list_dev_environments rather than assuming: a dedicated (Pro) tier resolves ~1.5 GB below nominal for agent headroom, and a per-service memory override REPLACES the tier figure rather than merely clamping to it. An UPSIZE now retires an override that would hold the box below its new tier; a re-assert of the SAME tier does not, so on a box already at the top tier a resize cannot lift an override \u2014 clear it with a config PATCH of memoryMb to 512 (the legacy floor, which means \"no override\"), or use the Remove-cap button on the box's Settings \u2192 General tab. `memoryPinnedBelowTier: true` is how you tell that case apart from a box that is genuinely maxed out.",
4278
+ "",
4098
4279
  "Inputs:",
4099
4280
  " - service_id: the box to resize \u2014 numeric id or publicId.",
4100
4281
  ' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
@@ -4132,7 +4313,9 @@ defineTool({
4132
4313
  "",
4133
4314
  `When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
4134
4315
  "",
4135
- 'Returns: { items: [{ ...service, devUrl, databases, exitReason, recommendedSize }] } where `databases` lists the companion engines wired into the box (e.g. ["postgres","redis"]). `exitReason` is "oom_killed" / "crashed" / null for the box\'s last container exit; when it is "oom_killed", `recommendedSize` is the next tier up to rescale to (use resize_dev_environment).',
4316
+ 'Returns: { items: [{ ...service, devUrl, databases, exitReason, recommendedSize, effectiveMemoryMb, memoryPinnedBelowTier }] } where `databases` lists the companion engines wired into the box (e.g. ["postgres","redis"]). `exitReason` is "oom_killed" / "crashed" / null for the box\'s last container exit; when it is "oom_killed", `recommendedSize` is the next tier up to rescale to (use resize_dev_environment).',
4317
+ "",
4318
+ '`effectiveMemoryMb` is the container\'s REAL ceiling \u2014 what `/sys/fs/cgroup/memory.max` reads inside the box \u2014 and is the number to size any in-box work against. Do NOT derive it from `plan`: a dedicated (Pro) tier sits ~1.5 GB below nominal, and a per-service override replaces the tier figure outright. When `memoryPinnedBelowTier` is true an override, not the tier, is the cap, so `recommendedSize: null` means "nothing bigger to sell you" rather than "nothing you can do" \u2014 see resize_dev_environment for how to lift it.',
4136
4319
  "",
4137
4320
  "Example: list_dev_environments() \u2192 every dev box for the active team."
4138
4321
  ].join("\n"),
@@ -4149,7 +4332,12 @@ defineTool({
4149
4332
  devUrl: env.devUrl ?? null,
4150
4333
  databases: env.databases ?? [],
4151
4334
  exitReason: env.exitReason ?? null,
4152
- recommendedSize: env.recommendedSize ?? null
4335
+ recommendedSize: env.recommendedSize ?? null,
4336
+ // The number an agent should size its work against. Absent it,
4337
+ // the only honest way to learn a box's ceiling was to shell in
4338
+ // and read the cgroup — so every box rediscovered its own.
4339
+ effectiveMemoryMb: env.effectiveMemoryMb ?? null,
4340
+ memoryPinnedBelowTier: env.memoryPinnedBelowTier ?? false
4153
4341
  }))
4154
4342
  }
4155
4343
  });
@@ -4335,7 +4523,7 @@ defineTool({
4335
4523
  "Inputs:",
4336
4524
  ' - service_id: publicId of the service (e.g. "svc_abc123").',
4337
4525
  "",
4338
- 'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
4526
+ 'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, allowSearchIndexing, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
4339
4527
  "",
4340
4528
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
4341
4529
  ].join("\n"),
@@ -4394,11 +4582,15 @@ defineTool({
4394
4582
  ' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
4395
4583
  " - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
4396
4584
  "",
4397
- "Resolution: \u22647d \u2192 raw samples (~minute granularity), \u226430d \u2192 hourly pre-aggregates, >30d \u2192 daily. Up to ~500 points returned.",
4585
+ "Resolution: \u22647d \u2192 raw samples (one per ~30s), >7d \u2192 hourly pre-aggregates, >30d \u2192 daily. At most 300 points are returned, so a window wider than ~2.5h is thinned to fit.",
4586
+ "",
4587
+ "`cpuPercent` is the PEAK of whatever the point covers, and 100 means one host CORE \u2014 not 100% of the service. A `micro` (500 millicores) is capped by its cgroup at ~50, so ~50 IS that plan pegged. To compare against a `service.pressure_sustained` alert, which reports a share of the plan allowance, multiply by 1000/cpuMillicores (the alert ships that denominator in its metadata).",
4588
+ "",
4589
+ "Memory is the reading at that point, NOT a peak \u2014 so a memory number here can sit below an alert that averaged the hour.",
4398
4590
  "",
4399
4591
  "Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
4400
4592
  "",
4401
- 'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
4593
+ 'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 ~120 points for the last hour.'
4402
4594
  ].join("\n"),
4403
4595
  input: {
4404
4596
  service_id: z19.string().describe("Service publicId."),
@@ -4476,6 +4668,7 @@ defineTool({
4476
4668
  " - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
4477
4669
  ' - health_check_path (optional): HTTP path the platform GETs to verify liveness (e.g. "/health"). Pass null for TCP-only check.',
4478
4670
  " - health_check_enabled (optional): boolean \u2014 toggle health checking on/off.",
4671
+ " - allow_search_indexing (optional): boolean \u2014 let search engines index the free *.hoststack.dev platform URL. Off by default; the platform URL is served with X-Robots-Tag: noindex so a site does not rank on a hostname it does not own. Custom domains are always indexable and unaffected. Applies on the next deploy.",
4479
4672
  " - health_check_interval (optional): integer 5\u2013300 seconds \u2014 how often the check runs.",
4480
4673
  " - health_check_timeout (optional): integer 1\u201360 seconds \u2014 single-attempt timeout.",
4481
4674
  ' - health_check_grace_period_sec (optional): integer 1\u20131800 seconds \u2014 startup tolerance before failures count. RAISE THIS (e.g. 180) when the agent reports "Health check timed out" on a cold-boot app (Bun + Vite SSR typically need 90\u2013180s).',
@@ -4490,7 +4683,9 @@ defineTool({
4490
4683
  " - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
4491
4684
  " - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
4492
4685
  " - scale_cpu_threshold, scale_memory_threshold (optional): integer 10\u2013100 \u2014 autoscale trigger percentage.",
4493
- ' - log_filter_rules (optional): list of { pattern, action } rules applied to runtime logs at query time. Pattern matches the message by case-insensitive substring; action is "drop" (filter out) or "downgrade" (flip stderr \u2192 stdout so it stops looking like an error). Pass [] to clear all rules. Capped at 50 rules.',
4686
+ " - scale_requests_per_sec_threshold (optional): integer 1\u201310000 \u2014 autoscale on request rate instead of (or alongside) CPU and memory. Pass null to stop scaling on request rate.",
4687
+ " - build_timeout_seconds (optional): integer 60\u20131800 \u2014 how long a build may run before it is killed. Raise it for a service whose build legitimately takes longer than the default; pass null to go back to the default.",
4688
+ ` - log_filter_rules (optional): list of { pattern, action } rules applied at read time \u2014 to get_service_logs (tail AND count) and to the dashboard's live stream alike. Pattern matches the message by case-insensitive substring, literally (a % or _ is not a wildcard); action is "drop" (filter out) or "downgrade" (flip stderr \u2192 stdout so it stops looking like an error). Stored lines are never altered, so removing a rule re-exposes everything it was hiding. Pass [] to clear all rules. Capped at 50 rules.`,
4494
4689
  "",
4495
4690
  "Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
4496
4691
  "",
@@ -4507,6 +4702,9 @@ defineTool({
4507
4702
  auto_deploy: z19.boolean().optional().describe("Auto-deploy on push."),
4508
4703
  health_check_path: z19.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
4509
4704
  health_check_enabled: z19.boolean().optional().describe("Toggle health checking on/off."),
4705
+ allow_search_indexing: z19.boolean().optional().describe(
4706
+ "Let search engines index the free *.hoststack.dev platform URL (off by default). Custom domains are always indexable. Applies on the next deploy."
4707
+ ),
4510
4708
  health_check_interval: z19.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
4511
4709
  health_check_timeout: z19.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
4512
4710
  health_check_grace_period_sec: z19.number().int().min(1).max(1800).optional().describe(
@@ -4527,6 +4725,8 @@ defineTool({
4527
4725
  max_instances: z19.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
4528
4726
  scale_cpu_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
4529
4727
  scale_memory_threshold: z19.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
4728
+ scale_requests_per_sec_threshold: z19.number().int().min(1).max(1e4).nullable().optional().describe("Autoscale request-rate trigger (1\u201310000 req/s). null stops using it."),
4729
+ build_timeout_seconds: z19.number().int().min(60).max(1800).nullable().optional().describe("Kill a build that runs longer than this (60\u20131800s). null = default."),
4530
4730
  log_filter_rules: z19.array(
4531
4731
  z19.object({
4532
4732
  pattern: z19.string().min(1).max(200),
@@ -4553,6 +4753,8 @@ defineTool({
4553
4753
  const configUpdate = {};
4554
4754
  if (args2.health_check_enabled !== void 0)
4555
4755
  configUpdate["healthCheckEnabled"] = args2.health_check_enabled;
4756
+ if (args2.allow_search_indexing !== void 0)
4757
+ configUpdate["allowSearchIndexing"] = args2.allow_search_indexing;
4556
4758
  if (args2.health_check_interval !== void 0)
4557
4759
  configUpdate["healthCheckInterval"] = args2.health_check_interval;
4558
4760
  if (args2.health_check_timeout !== void 0)
@@ -4579,6 +4781,10 @@ defineTool({
4579
4781
  configUpdate["scaleCpuThreshold"] = args2.scale_cpu_threshold;
4580
4782
  if (args2.scale_memory_threshold !== void 0)
4581
4783
  configUpdate["scaleMemoryThreshold"] = args2.scale_memory_threshold;
4784
+ if (args2.scale_requests_per_sec_threshold !== void 0)
4785
+ configUpdate["scaleRequestsPerSecThreshold"] = args2.scale_requests_per_sec_threshold;
4786
+ if (args2.build_timeout_seconds !== void 0)
4787
+ configUpdate["buildTimeoutSeconds"] = args2.build_timeout_seconds;
4582
4788
  if (args2.log_filter_rules !== void 0) {
4583
4789
  configUpdate["logFilterRules"] = args2.log_filter_rules;
4584
4790
  }
@@ -4703,7 +4909,9 @@ defineTool({
4703
4909
  ' - stream (optional): "stdout" | "stderr". Omit to combine.',
4704
4910
  " - level (optional): real log level \u2014 trace/debug/info/warn/error/fatal. For structured JSON logs (pino, bunyan, OpenTelemetry severity) this filters on the parsed inner level field. For plain text logs it falls back to a stream-alias hint (info/debug \u2192 stdout, warn/error/fatal \u2192 stderr).",
4705
4911
  " - search (optional): case-insensitive substring grep, \u2264100 chars.",
4706
- ' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
4912
+ ' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling. It counts exactly what the same call would return, so `count` and the entry count agree whenever count <= lines.',
4913
+ "",
4914
+ "If the service has log_filter_rules with action='drop' (see update_service_config), the lines they match are excluded from BOTH the tail and the count \u2014 so a count lower than the raw log volume is the rules working, not lines going missing. `stream` selects on the stream shown in the response, which a 'downgrade' rule may have flipped from stderr to stdout.",
4707
4915
  "",
4708
4916
  "Returns: { logs: LogEntry[] | string } when count_only is false. Each entry has { timestamp, level?, stream, message }. `level` is the parsed inner level when the message is a structured JSON envelope (pino numeric or string), and undefined for plain-text logs. `stream` is always one of stdout/stderr. Or { count: number } when count_only is true.",
4709
4917
  "",
@@ -4841,9 +5049,11 @@ defineTool({
4841
5049
  "",
4842
5050
  "Inputs: serviceId (required).",
4843
5051
  "",
4844
- 'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp).',
5052
+ 'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp), and lastTargetUrl.',
5053
+ "",
5054
+ "WHICH HOSTNAME IS PROBED: `lastTargetUrl` is the URL the last probe actually dialled \u2014 read it here rather than inferring it. A check names a PATH and the platform picks the host: the service's primary domain, or the OLDEST domain when no primary is nominated, which on a renamed host is usually the retired alias. So a service answering on two names that behave differently (canonical serves, alias 301s) can have a check that asserts the wrong status code without anything being wrong with the service. If lastTargetUrl is not the name you meant to watch, nominate the right one with update_domain({ domain_id, isPrimary: true }) \u2014 set_uptime_check takes a path and never a host.",
4845
5055
  "",
4846
- "Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z' } }."
5056
+ "Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z', lastTargetUrl: 'https://grundfast.dk/healthz' } }."
4847
5057
  ].join("\n"),
4848
5058
  input: { serviceId: z20.number().int().positive() },
4849
5059
  handler: async (args2, ctx) => {
@@ -4876,9 +5086,9 @@ defineTool({
4876
5086
  "",
4877
5087
  "For a site HostStack does NOT host, there is no serviceId to pass: use set_site_uptime_check with its analytics siteId instead. That path needs the domain proven first (verify_site_domain), because the host it probes comes from the site row rather than from a domain the platform already vouches for.",
4878
5088
  "",
4879
- "IMPORTANT: changing the shape of a check RESETS its accumulated state (status, consecutive failures, open alert). A check whose path or expected status just changed has not observed the new check failing, so carrying failures forward would alert about a condition that was never measured. Pass the full shape you want, not a partial edit of an unknown current state \u2014 read it with get_uptime_check first if that matters.",
5089
+ "IMPORTANT: changing the shape of a check RESETS its accumulated state (status, consecutive failures, open alert). A check whose path or expected status just changed has not observed the new check failing, so carrying failures forward would alert about a condition that was never measured. A write that resolves to the shape already stored changes nothing and leaves that state alone \u2014 but the inputs are defaults, not a partial edit, so omitting intervalSeconds on a check set to 30 resolves to 60 and IS a change. Read a check with get_uptime_check; never re-save one in order to look at it.",
4880
5090
  "",
4881
- "`path` is a path, not a URL: the host comes from the service's primary domain at probe time, so moving the service to a new domain moves the check with it.",
5091
+ "`path` is a path, not a URL: the host comes from the service's primary domain at probe time, so moving the service to a new domain moves the check with it. On a service with SEVERAL domains that is a choice, and this tool does not make it \u2014 nominate the hostname to watch with update_domain({ domain_id, isPrimary: true }), or the platform falls back to the OLDEST domain, which after a rename is usually the alias that redirects. Check which one is live with get_uptime_check's lastTargetUrl before trusting an expectedStatus. When ONE named hostname has to be asserted whatever the platform picks \u2014 a canonical host and a 301 alias cannot share an expectedStatus \u2014 give that hostname its own analytics site and check it with set_site_uptime_check.",
4882
5092
  "",
4883
5093
  "Method is GET or HEAD only. A probe fires unattended every interval forever, so it has to be safe to repeat \u2014 a check that could POST would be a scheduled writer against the team's own API.",
4884
5094
  "",
@@ -4907,9 +5117,11 @@ defineTool({
4907
5117
  `/api/services/${teamId}/${serviceId}/uptime-check`,
4908
5118
  body
4909
5119
  );
5120
+ const check = shape(response.check);
5121
+ const probed = check["lastCheckedAt"] !== null && check["lastCheckedAt"] !== void 0;
4910
5122
  return respond({
4911
- summary: `Uptime check saved. It will start reporting within a minute or two.`,
4912
- data: { check: shape(response.check) }
5123
+ summary: probed ? `Uptime check saved \u2014 the shape was already stored, so nothing was reset: still ${String(check["status"])}, last probed ${String(check["lastCheckedAt"])}.` : 'Uptime check saved, and not probed yet \u2014 "unknown" with no lastCheckedAt is the state of a check that has not run once, not a broken one. It reports within a minute or two: read it with get_uptime_check, never by saving it again.',
5124
+ data: { check }
4913
5125
  });
4914
5126
  }
4915
5127
  });
@@ -5023,6 +5235,7 @@ defineTool({
5023
5235
  " - volume_id: publicId of the volume to update.",
5024
5236
  " - mount_path (optional): new in-container mount path.",
5025
5237
  " - size_gb (optional): new size in GB (must be \u2265 current).",
5238
+ " - backup_enabled (optional): whether this volume is backed up. Turning it off stops future backups; it does not delete the ones already taken.",
5026
5239
  "",
5027
5240
  "Returns: { volume: Volume } \u2014 the updated record.",
5028
5241
  "",
@@ -5032,13 +5245,15 @@ defineTool({
5032
5245
  service_id: z21.string().describe("Service publicId."),
5033
5246
  volume_id: z21.string().describe("Volume publicId (e.g. vol_\u2026)."),
5034
5247
  mount_path: z21.string().startsWith("/").max(500).optional().describe("New mount path."),
5035
- size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
5248
+ size_gb: z21.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`),
5249
+ backup_enabled: z21.boolean().optional().describe("Whether to keep backing this volume up. Past backups are kept either way.")
5036
5250
  },
5037
5251
  handler: async (args2, ctx) => {
5038
5252
  const teamId = await ctx.resolveTeamId();
5039
5253
  const input = {};
5040
5254
  if (args2.mount_path !== void 0) input.mountPath = args2.mount_path;
5041
5255
  if (args2.size_gb !== void 0) input.sizeGb = args2.size_gb;
5256
+ if (args2.backup_enabled !== void 0) input.backupEnabled = args2.backup_enabled;
5042
5257
  if (Object.keys(input).length === 0) {
5043
5258
  return respond({ summary: "No fields to update.", data: {} });
5044
5259
  }