@hoststack.dev/mcp 0.15.0 → 0.16.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.
@@ -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.15.0" : "0.0.0-dev";
11
+ var MCP_VERSION = true ? "0.16.0" : "0.0.0-dev";
12
12
  var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
13
13
 
14
14
  // src/api-client.ts
@@ -68,8 +68,8 @@ var ApiClient = class {
68
68
  }
69
69
  async handle(res) {
70
70
  if (!res.ok) {
71
- const error = await res.json().catch(() => ({ error: res.statusText }));
72
- throw new Error(error.error ?? `API error: ${res.status}`);
71
+ const body = await res.json().catch(() => ({ error: res.statusText }));
72
+ throw new Error(formatApiError(body, res.status));
73
73
  }
74
74
  if (res.status === 204) {
75
75
  return void 0;
@@ -77,6 +77,31 @@ var ApiClient = class {
77
77
  return res.json();
78
78
  }
79
79
  };
80
+ function formatApiError(body, status) {
81
+ const fallback = `API error: ${status}`;
82
+ if (!body || typeof body !== "object") return fallback;
83
+ const err = body.error;
84
+ if (typeof err === "string") return err.trim() ? err : fallback;
85
+ const issues = Array.isArray(err) ? err : err && typeof err === "object" && Array.isArray(err.issues) ? err.issues : null;
86
+ if (issues && issues.length > 0) {
87
+ const rendered = issues.map((issue) => {
88
+ if (!issue || typeof issue !== "object") return String(issue);
89
+ const { path, message } = issue;
90
+ const field = Array.isArray(path) && path.length > 0 ? path.join(".") : null;
91
+ const text = typeof message === "string" ? message : "invalid value";
92
+ return field ? `${field}: ${text}` : text;
93
+ }).join("; ");
94
+ return `Invalid request \u2014 ${rendered}`;
95
+ }
96
+ if (err !== void 0) {
97
+ try {
98
+ return `${fallback}: ${JSON.stringify(err)}`;
99
+ } catch {
100
+ return fallback;
101
+ }
102
+ }
103
+ return fallback;
104
+ }
80
105
 
81
106
  // src/prompts/registry.ts
82
107
  var prompts = [];
@@ -596,11 +621,176 @@ defineTool({
596
621
 
597
622
  // src/tools/databases.ts
598
623
  import { z as z5 } from "zod";
624
+ var DATABASE_VERSIONS = {
625
+ postgres: { default: "18", supported: ["18", "17", "16", "15"] },
626
+ redis: { default: "8", supported: ["8", "7", "6"] },
627
+ mysql: { default: "8.4", supported: ["8.4", "8.0", "5.7"] },
628
+ mariadb: { default: "11.4", supported: ["11.4", "10.11"] },
629
+ mongodb: { default: "8", supported: ["8", "7", "6"] }
630
+ };
631
+ var DB_ENGINES = ["postgres", "redis", "mysql", "mariadb", "mongodb"];
632
+ var VERSION_HELP = Object.keys(DATABASE_VERSIONS).map(
633
+ (e) => `${e}: ${DATABASE_VERSIONS[e].supported.join("/")} (default ${DATABASE_VERSIONS[e].default})`
634
+ ).join("; ");
635
+ defineTool({
636
+ name: "create_database",
637
+ category: "databases",
638
+ description: [
639
+ "Provision a MANAGED database (Postgres, Redis, MySQL, MariaDB, MongoDB) in a project. This is the correct and ONLY supported way to add a database to a HostStack app.",
640
+ "",
641
+ 'When to use: any time an app needs a datastore \u2014 "add a database", "I need Postgres", "set up Redis", "give this service a DB". Also the right call when scaffolding a new app that will need persistence.',
642
+ "",
643
+ '*** Do NOT hand-roll a database as a service. *** Deploying `postgres:16` (or redis/mysql/mongo) via create_service with a docker_image is NOT how databases work on this platform: it gets no managed backups, no automated version upgrades, no HA/failover path, no credential rotation, no metrics, no persistent volume wired up, and nothing will inject its connection URL into your app. If the user says "add a database", "I need Postgres", "set up Redis" \u2014 call THIS tool.',
644
+ "",
645
+ "How a database reaches your app (the whole flow \u2014 3 calls, no secrets handled):",
646
+ " 1. create_database({ project_id, name, engine }) \u2014 provisions it.",
647
+ ' 2. link_resource_to_service({ service_id, resource_type: "database", resource_id: <database.id>, alias: "APP_DB" }) \u2014 binds it to the service that needs it.',
648
+ " 3. trigger_deploy({ service_id }) \u2014 on that deploy the platform injects the connection info as env vars.",
649
+ "After step 3 the container has `DATABASE_URL` (postgres/mysql/mariadb), `REDIS_URL` (redis) or `MONGO_URL` (mongodb) set automatically, plus alias-prefixed vars. An app reading `process.env.DATABASE_URL` needs zero code changes. You never have to fetch, generate or paste a password.",
650
+ "",
651
+ "Inputs:",
652
+ ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
653
+ " - name: database name (1\u2013100 chars).",
654
+ ` - engine: ${DB_ENGINES.join(" | ")}.`,
655
+ ` - version (optional): engine version. Supported \u2014 ${VERSION_HELP}. Omit to get the default (recommended).`,
656
+ ' - plan (optional): "micro" | "starter" | "standard" | "pro" (default "starter"). "starter" is the smallest always-on tier that includes backups.',
657
+ " - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
658
+ " - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
659
+ " - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
660
+ "",
661
+ '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).',
662
+ "",
663
+ 'Provisioning is async: the row comes back immediately, usually as `creating`. Poll get_database until `status === "available"` before linking or connecting.',
664
+ "",
665
+ 'Example: create_database({ project_id: "prj_abc", name: "app-db", engine: "postgres" }) \u2192 { database: { id: 42, publicId: "db_\u2026", status: "creating" } }'
666
+ ].join("\n"),
667
+ input: {
668
+ project_id: z5.union([z5.number().int().positive(), z5.string()]).describe('Target project \u2014 numeric id or publicId ("prj_\u2026").'),
669
+ name: z5.string().min(1).max(100).describe("Database name (1\u2013100 chars)."),
670
+ engine: z5.enum(DB_ENGINES).describe("Database engine."),
671
+ version: z5.string().max(20).optional().describe(`Engine version. ${VERSION_HELP}. Omit for the engine default.`),
672
+ plan: z5.enum(["micro", "starter", "standard", "pro"]).optional().describe('Plan tier (memory/CPU). Default "starter".'),
673
+ environment_id: z5.union([z5.number().int().positive(), z5.string()]).optional().describe("Environment to bind to. Defaults to the project Production env."),
674
+ postgis: z5.boolean().optional().describe("Postgres only \u2014 enable the PostGIS extension image."),
675
+ pgvector: z5.boolean().optional().describe(
676
+ "Postgres only \u2014 enable the pgvector extension image. Exclusive with postgis."
677
+ )
678
+ },
679
+ handler: async (args2, ctx) => {
680
+ const teamId = await ctx.resolveTeamId();
681
+ const projectId = await ctx.hoststack.resolveId(args2.project_id, {
682
+ kind: "project",
683
+ teamId
684
+ });
685
+ const input = {
686
+ name: args2.name,
687
+ engine: args2.engine,
688
+ projectId
689
+ };
690
+ if (args2.version !== void 0) input.version = args2.version;
691
+ if (args2.plan !== void 0) input.plan = args2.plan;
692
+ if (args2.environment_id !== void 0) {
693
+ input.environmentId = await ctx.hoststack.resolveId(args2.environment_id, {
694
+ kind: "environment",
695
+ teamId
696
+ });
697
+ }
698
+ if (args2.postgis !== void 0) input.postgis = args2.postgis;
699
+ if (args2.pgvector !== void 0) input.pgvector = args2.pgvector;
700
+ const response = await ctx.hoststack.databases.create(teamId, input);
701
+ const data = { database: shapeDatabase(response.database) };
702
+ const db = response.database;
703
+ return respond({
704
+ summary: `Created ${args2.engine} database "${args2.name}" (${db.publicId ?? "unknown"}, numeric id ${db.id ?? "?"}) \u2014 status ${db.status ?? "creating"}. Poll get_database until status=available, then link_resource_to_service to inject its URL into a service.`,
705
+ data
706
+ });
707
+ }
708
+ });
709
+ defineTool({
710
+ name: "delete_database",
711
+ category: "databases",
712
+ description: [
713
+ "Permanently delete a managed database \u2014 the container, its volume, and ALL data it holds.",
714
+ "",
715
+ 'When to use: the user has explicitly asked to destroy a database they no longer want. This is irreversible and takes the data with it \u2014 confirm with the user before calling, and never call it to "clean up" as a side effect of another task. If the goal is to stop paying for an idle database while keeping the data, use suspend_database instead.',
716
+ "",
717
+ "Any service still linked to this database will lose the injected connection vars on its next deploy, and will fail at runtime if it depends on them. Check which services consume it first \u2014 the dashboard shows this under the database's Linked Services tab.",
718
+ "",
719
+ "Inputs:",
720
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
721
+ "",
722
+ "Returns: { ok: true }.",
723
+ "",
724
+ 'Example: delete_database({ database_id: "db_abc" }) \u2192 { ok: true }'
725
+ ].join("\n"),
726
+ input: {
727
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to permanently delete.")
728
+ },
729
+ handler: async (args2, ctx) => {
730
+ const teamId = await ctx.resolveTeamId();
731
+ await ctx.hoststack.databases.delete(teamId, args2.database_id);
732
+ return respond({
733
+ summary: `Deleted database ${args2.database_id} and all of its data. This cannot be undone.`
734
+ });
735
+ }
736
+ });
737
+ defineTool({
738
+ name: "suspend_database",
739
+ category: "databases",
740
+ description: [
741
+ "Suspend a managed database \u2014 stops its container while KEEPING the volume and all data. The reversible alternative to delete_database.",
742
+ "",
743
+ "When to use: a staging or preview database that nobody is using, to stop burning its compute tier. Connections fail while suspended; resume_database brings it back on the same connection URL.",
744
+ "",
745
+ "Inputs:",
746
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
747
+ "",
748
+ "Returns: { ok: true }.",
749
+ "",
750
+ 'Example: suspend_database({ database_id: "db_abc" }) \u2192 { ok: true }'
751
+ ].join("\n"),
752
+ input: {
753
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to suspend.")
754
+ },
755
+ handler: async (args2, ctx) => {
756
+ const teamId = await ctx.resolveTeamId();
757
+ await ctx.hoststack.databases.suspend(teamId, args2.database_id);
758
+ return respond({
759
+ summary: `Suspended database ${args2.database_id}. Data is preserved; resume_database restores it on the same URL.`
760
+ });
761
+ }
762
+ });
763
+ defineTool({
764
+ name: "resume_database",
765
+ category: "databases",
766
+ description: [
767
+ "Resume a suspended managed database \u2014 restarts its container against the existing volume. The connection URL is unchanged, so linked services do NOT need a redeploy.",
768
+ "",
769
+ "When to use: undoing a suspend_database, or bringing back a database that was suspended for non-payment once billing is resolved.",
770
+ "",
771
+ "Inputs:",
772
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
773
+ "",
774
+ 'Returns: { ok: true }. Poll get_database until `status === "available"`.',
775
+ "",
776
+ 'Example: resume_database({ database_id: "db_abc" }) \u2192 { ok: true }'
777
+ ].join("\n"),
778
+ input: {
779
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to resume.")
780
+ },
781
+ handler: async (args2, ctx) => {
782
+ const teamId = await ctx.resolveTeamId();
783
+ await ctx.hoststack.databases.resume(teamId, args2.database_id);
784
+ return respond({
785
+ summary: `Resume dispatched for ${args2.database_id}. Poll get_database until status=available.`
786
+ });
787
+ }
788
+ });
599
789
  defineTool({
600
790
  name: "list_databases",
601
791
  category: "databases",
602
792
  description: [
603
- "List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB, Meilisearch, NATS) inside a project.",
793
+ "List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB) inside a project. Meilisearch and NATS are NOT databases \u2014 they are separate managed resources (search / queue) and do not appear here; see list_service_resources for what a service consumes.",
604
794
  "",
605
795
  "When to use: an agent needs to know what data stores exist in a project before connecting a service or running a migration. Pair with list_projects to discover project IDs.",
606
796
  "",
@@ -785,6 +975,34 @@ defineTool({
785
975
  });
786
976
  }
787
977
  });
978
+ defineTool({
979
+ name: "restart_database",
980
+ category: "databases",
981
+ description: [
982
+ "Restart a managed database in place \u2014 `docker restart` of its container: same container, same volume, same connection URL. The database is briefly unavailable (a few seconds) while it bounces; no redeploy of connected services is needed.",
983
+ "",
984
+ "When to use: a database is wedged (stuck connection, needs to reload a config change, leaking memory) and a clean bounce is the fix \u2014 the database-equivalent of restarting a service.",
985
+ "",
986
+ "The database must be `available`. A suspended database has no running container (resume it first \u2192 409); a creating/migrating one is mid-operation (409). For a Patroni HA cluster this bounces the current leader, which triggers a normal failover while replicas keep serving reads.",
987
+ "",
988
+ "Inputs:",
989
+ " - database_id: publicId of the database to restart.",
990
+ "",
991
+ "Returns: { ok: true } once the restart command reaches an agent (dispatched optimistically, like a service restart). Poll get_service_logs / get_database to confirm it came back.",
992
+ "",
993
+ 'Example: restart_database({ database_id: "db_abc" }) \u2192 { ok: true }'
994
+ ].join("\n"),
995
+ input: {
996
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to restart.")
997
+ },
998
+ handler: async (args2, ctx) => {
999
+ const teamId = await ctx.resolveTeamId();
1000
+ await ctx.hoststack.databases.restart(teamId, args2.database_id);
1001
+ return respond({
1002
+ summary: `Restart dispatched for ${args2.database_id}. It will be briefly unavailable while the container bounces.`
1003
+ });
1004
+ }
1005
+ });
788
1006
  defineTool({
789
1007
  name: "get_database_cluster",
790
1008
  category: "databases",
@@ -910,7 +1128,7 @@ defineTool({
910
1128
  description: [
911
1129
  "Cancel a running deploy. Stops the build/deploy pipeline mid-flight; the previously live revision keeps serving traffic.",
912
1130
  "",
913
- "When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Has no effect on already-finished deploys.",
1131
+ "When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Also the right call for a deploy stuck in `deploying` because the new container is crash-looping against its health check \u2014 that state is cancellable and cancelling it frees the service (and the build queue) immediately instead of waiting out the grace period. Has no effect on already-finished deploys.",
914
1132
  "",
915
1133
  "Inputs:",
916
1134
  " - service_id: publicId of the service.",
@@ -1072,7 +1290,7 @@ var DNS_RECORD_TYPES = [
1072
1290
  async function resolveZonePublicId(hoststack, teamId, input) {
1073
1291
  if (input.zone_id) {
1074
1292
  const { zones: zones2 } = await hoststack.dns.listZones(teamId);
1075
- const match = zones2.find((z15) => z15.publicId === input.zone_id);
1293
+ const match = zones2.find((z16) => z16.publicId === input.zone_id);
1076
1294
  if (!match) {
1077
1295
  throw new Error(`Zone ${input.zone_id} not found on this team.`);
1078
1296
  }
@@ -1086,7 +1304,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
1086
1304
  const labels = fqdn.split(".");
1087
1305
  for (let i = 0; i < labels.length - 1; i++) {
1088
1306
  const candidate = labels.slice(i).join(".");
1089
- const match = zones.find((z15) => z15.domainName.toLowerCase() === candidate);
1307
+ const match = zones.find((z16) => z16.domainName.toLowerCase() === candidate);
1090
1308
  if (match && match.status !== "deleting") {
1091
1309
  return { publicId: match.publicId, domainName: match.domainName };
1092
1310
  }
@@ -1339,6 +1557,49 @@ defineTool({
1339
1557
  });
1340
1558
  }
1341
1559
  });
1560
+ defineTool({
1561
+ name: "resync_dns_record",
1562
+ category: "dns",
1563
+ description: [
1564
+ 'Re-push a DNS record to PowerDNS without changing its value. Recovers a record stuck at status="failed" after a transient provider outage \u2014 including managedBy="hoststack" records (auto-created for a service/domain) that create/update/delete refuse to touch.',
1565
+ "",
1566
+ `When to use: a record shows status="failed" with a lastSyncError, or a freshly-attached domain's auto-created A record never went active. Safe and idempotent \u2014 a rejected re-push leaves the live RRset untouched (PowerDNS PATCH is atomic), so this can never drop a working record.`,
1567
+ "",
1568
+ "Inputs:",
1569
+ ' - record_id: record publicId (e.g. "dnr_abc").',
1570
+ "",
1571
+ 'Returns: { record: Record } \u2014 the record with its refreshed status ("active" on success).',
1572
+ "",
1573
+ 'Example: resync_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { status: "active", ... } }'
1574
+ ].join("\n"),
1575
+ input: {
1576
+ record_id: z7.string().describe("Record publicId.")
1577
+ },
1578
+ handler: async (args2, ctx) => {
1579
+ const teamId = await ctx.resolveTeamId();
1580
+ const target = await findRecordZone(ctx.hoststack, teamId, args2.record_id);
1581
+ if (!target) {
1582
+ return respondError(
1583
+ `Record ${args2.record_id} not found on any zone owned by this team.`
1584
+ );
1585
+ }
1586
+ const response = await ctx.hoststack.dns.resyncRecord(
1587
+ teamId,
1588
+ target.zone.publicId,
1589
+ args2.record_id
1590
+ );
1591
+ return respond({
1592
+ summary: `Re-synced record ${args2.record_id} on ${target.zone.domainName} (status: ${response.record.status}).`,
1593
+ data: {
1594
+ zone: {
1595
+ publicId: target.zone.publicId,
1596
+ domainName: target.zone.domainName
1597
+ },
1598
+ record: shape(response.record)
1599
+ }
1600
+ });
1601
+ }
1602
+ });
1342
1603
  async function findRecordZone(hoststack, teamId, recordPublicId) {
1343
1604
  const { zones } = await hoststack.dns.listZones(teamId);
1344
1605
  for (const zone of zones) {
@@ -1402,9 +1663,10 @@ defineTool({
1402
1663
  };
1403
1664
  if (args2.path_prefix !== void 0) input.pathPrefix = args2.path_prefix;
1404
1665
  const response = await ctx.hoststack.domains.add(teamId, input);
1405
- const data = { domain: shapeDomain(response.domain) };
1666
+ const dnsSyncWarning = response.domain.dnsSyncWarning;
1667
+ const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
1406
1668
  return respond({
1407
- summary: `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1669
+ summary: dnsSyncWarning ? `Added domain ${args2.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1408
1670
  data
1409
1671
  });
1410
1672
  }
@@ -1785,6 +2047,48 @@ defineTool({
1785
2047
  }
1786
2048
  });
1787
2049
 
2050
+ // src/tools/github.ts
2051
+ defineTool({
2052
+ name: "sync_github_repos",
2053
+ category: "github",
2054
+ description: [
2055
+ "Re-sync the repository list from GitHub for every connected GitHub App installation.",
2056
+ "",
2057
+ "When to use: right after pushing a brand-new repository, HostStack cannot see it until an installation re-sync runs \u2014 so create_service with a github_repo_id would fail. Call this first. (Same operation as the dashboard's refresh icon on the repo picker.)",
2058
+ "",
2059
+ "No inputs.",
2060
+ "",
2061
+ "Returns: { installations: number, repos: [{ account, count }], totalRepos } \u2014 the accounts synced and how many repos each now exposes.",
2062
+ "",
2063
+ 'Example: sync_github_repos({}) \u2192 { installations: 1, repos: [{ account: "acme", count: 12 }], totalRepos: 12 }'
2064
+ ].join("\n"),
2065
+ input: {},
2066
+ handler: async (_args, ctx) => {
2067
+ const teamId = await ctx.resolveTeamId();
2068
+ const { installations } = await ctx.api.get(
2069
+ `/api/github/${teamId}/installations`
2070
+ );
2071
+ if (installations.length === 0) {
2072
+ return respondError(
2073
+ "No GitHub installations connected. Install the HostStack GitHub App from the dashboard (Settings \u2192 GitHub) first."
2074
+ );
2075
+ }
2076
+ const perAccount = [];
2077
+ let totalRepos = 0;
2078
+ for (const inst of installations) {
2079
+ const { repos } = await ctx.api.post(
2080
+ `/api/github/${teamId}/installations/${inst.id}/sync`
2081
+ );
2082
+ perAccount.push({ account: inst.accountLogin, count: repos.length });
2083
+ totalRepos += repos.length;
2084
+ }
2085
+ return respond({
2086
+ summary: `Synced ${totalRepos} repositories across ${installations.length} installation(s).`,
2087
+ data: { installations: installations.length, repos: perAccount, totalRepos }
2088
+ });
2089
+ }
2090
+ });
2091
+
1788
2092
  // src/tools/meta.ts
1789
2093
  var DEV_ENV_TOOL_NAMES = [
1790
2094
  "create_dev_environment",
@@ -1863,7 +2167,10 @@ var NOTIFICATION_EVENTS = [
1863
2167
  "service.restart_failed",
1864
2168
  "service.auto_suspended",
1865
2169
  "service.acme_cert_failed",
1866
- "git.auth_failed"
2170
+ "service.resource_alert",
2171
+ "git.auth_failed",
2172
+ "cron.execution_failed",
2173
+ "workflow.failed"
1867
2174
  ];
1868
2175
  defineTool({
1869
2176
  name: "list_notification_channels",
@@ -1902,7 +2209,7 @@ defineTool({
1902
2209
  " - webhook_url: Slack/Discord webhook URL OR email address.",
1903
2210
  " - events: list of event names to subscribe to. Pass an empty list to create a channel that fires for nothing (manual subscribe later with update_notification_channel).",
1904
2211
  "",
1905
- "Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, git.auth_failed.",
2212
+ "Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, service.resource_alert, git.auth_failed, cron.execution_failed, workflow.failed.",
1906
2213
  "",
1907
2214
  "Returns: { channel: Channel }.",
1908
2215
  "",
@@ -2157,9 +2464,159 @@ defineTool({
2157
2464
  }
2158
2465
  });
2159
2466
 
2160
- // src/tools/services.ts
2467
+ // src/tools/resource-links.ts
2161
2468
  import { z as z13 } from "zod";
2162
- var DEV_ENV_IMAGE = "hoststack/dev-env:latest";
2469
+ var RESOURCE_LINK_TYPES = [
2470
+ "database",
2471
+ "object_storage",
2472
+ "queue",
2473
+ "search",
2474
+ "email_domain"
2475
+ ];
2476
+ var INJECTION_CONTRACT = [
2477
+ "What a link injects at deploy time:",
2478
+ ' - Alias-prefixed vars for every field of the resource (alias "APP_DB" \u2192 APP_DB_HOST, APP_DB_PORT, APP_DB_USER, APP_DB_PASSWORD, APP_DB_URL, \u2026). This is how one service consumes several resources of the same type without collisions.',
2479
+ " - PLUS the conventional name for the first linked resource of each engine class: `DATABASE_URL` (postgres/mysql/mariadb), `REDIS_URL` (redis), `MONGO_URL` (mongodb). So an app reading `process.env.DATABASE_URL` works with no code changes.",
2480
+ " - The conventional names are set with `??=` \u2014 an env var you set yourself via set_env_var ALWAYS wins over the injected one."
2481
+ ].join("\n");
2482
+ defineTool({
2483
+ name: "list_managed_resources",
2484
+ category: "resource-links",
2485
+ description: [
2486
+ "List EVERY managed resource the team owns in one call \u2014 databases, object storage buckets, queues, search indexes and email domains \u2014 from the unified `managed_resources` read-model.",
2487
+ "",
2488
+ 'When to use: to find the NUMERIC `id` that link_resource_to_service needs, especially for resource types with no dedicated list tool of their own (object storage, queues, search, email domains \u2014 list_databases only covers databases). Also a fast inventory answer for "what do we actually have running".',
2489
+ "",
2490
+ "Team-wide, not project-scoped. Use list_databases when you specifically want databases in one project.",
2491
+ "",
2492
+ "Returns: { items: ManagedResource[] } \u2014 id (numeric, for linking), publicId, type, name, status, region, createdAt.",
2493
+ "",
2494
+ 'Example: list_managed_resources() \u2192 { items: [{ id: 42, type: "database", name: "app-db", status: "available" }, { id: 8, type: "object_storage", name: "uploads", \u2026 }] }'
2495
+ ].join("\n"),
2496
+ input: {},
2497
+ handler: async (_args, ctx) => {
2498
+ const teamId = await ctx.resolveTeamId();
2499
+ const response = await ctx.hoststack.serviceResourceLinks.listManagedResources(teamId);
2500
+ const data = shapeList(response, "resources", shape);
2501
+ const byType = /* @__PURE__ */ new Map();
2502
+ for (const item of data.items) {
2503
+ const type = item && typeof item === "object" && "type" in item ? String(item.type) : "unknown";
2504
+ byType.set(type, (byType.get(type) ?? 0) + 1);
2505
+ }
2506
+ const breakdown = [...byType.entries()].map(([t, n]) => `${n} ${t}`).join(", ");
2507
+ const summary = data.items.length === 0 ? "No managed resources on this team yet." : `${data.items.length} managed resource${data.items.length === 1 ? "" : "s"}: ${breakdown}.`;
2508
+ return respond({ summary, data });
2509
+ }
2510
+ });
2511
+ defineTool({
2512
+ name: "list_service_resources",
2513
+ category: "resource-links",
2514
+ description: [
2515
+ "List the managed resources (databases, object storage, queues, search, email domains) linked to a service \u2014 i.e. which resources get injected into its container as env vars on deploy.",
2516
+ "",
2517
+ 'When to use: before adding a link (to avoid a duplicate or an alias collision), when debugging "why is DATABASE_URL empty in my app" (the answer is usually: no link exists, or the service was never redeployed after linking), or to find the linkId needed by unlink_resource_from_service.',
2518
+ "",
2519
+ INJECTION_CONTRACT,
2520
+ "",
2521
+ "Inputs:",
2522
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
2523
+ "",
2524
+ "Returns: { items: ServiceResourceLink[] } \u2014 id (the linkId), resourceType, resourceId (numeric id of the linked resource), alias, createdAt.",
2525
+ "",
2526
+ 'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
2527
+ ].join("\n"),
2528
+ input: {
2529
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
2530
+ },
2531
+ handler: async (args2, ctx) => {
2532
+ const teamId = await ctx.resolveTeamId();
2533
+ const response = await ctx.hoststack.serviceResourceLinks.list(teamId, args2.service_id);
2534
+ const data = shapeList(response, "links", shape);
2535
+ const summary = data.items.length === 0 ? `No resources linked to service ${args2.service_id}. Nothing is being injected \u2014 link a database with link_resource_to_service.` : `${data.items.length} resource${data.items.length === 1 ? "" : "s"} linked to service ${args2.service_id}.`;
2536
+ return respond({ summary, data });
2537
+ }
2538
+ });
2539
+ defineTool({
2540
+ name: "link_resource_to_service",
2541
+ category: "resource-links",
2542
+ description: [
2543
+ "Link a managed resource to a service so its connection details are injected into the container as environment variables. This is the step that connects a database to an app \u2014 creating the database alone does nothing for the app.",
2544
+ "",
2545
+ "When to use: immediately after create_database (or when pointing an existing service at an existing resource). This is step 2 of 3 in the managed-database flow: create_database \u2192 link_resource_to_service \u2192 trigger_deploy.",
2546
+ "",
2547
+ '*** The link takes effect on the NEXT DEPLOY. *** After calling this, call trigger_deploy({ service_id }) or the running container will not see the new vars. A service that "cannot connect to the database" right after linking almost always just needs that redeploy.',
2548
+ "",
2549
+ INJECTION_CONTRACT,
2550
+ "",
2551
+ "Because the platform injects the credentials directly into the container, you do NOT need to read the password, build a connection string by hand, or store it with set_env_var. Don't.",
2552
+ "",
2553
+ "Inputs:",
2554
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the consuming service.',
2555
+ ` - resource_type: ${RESOURCE_LINK_TYPES.join(" | ")}.`,
2556
+ ' - resource_id: the NUMERIC id of the resource \u2014 e.g. the `id` field from create_database / list_databases, NOT the "db_\u2026" publicId.',
2557
+ ' - alias: uppercase env-var prefix (A\u2013Z, 0\u20139, underscore; must start with a letter; \u226448 chars), e.g. "APP_DB", "CACHE", "CATALOG". Must be unique within the service.',
2558
+ "",
2559
+ "Returns: { link: ServiceResourceLink } \u2014 including `id`, the linkId used to unlink later.",
2560
+ "",
2561
+ "Fails with 409 if this exact resource is already linked to the service, or if the alias is already taken on it.",
2562
+ "",
2563
+ 'Example: link_resource_to_service({ service_id: "svc_abc", resource_type: "database", resource_id: 42, alias: "APP_DB" }) \u2192 { link: { id: 7, alias: "APP_DB" } }'
2564
+ ].join("\n"),
2565
+ input: {
2566
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
2567
+ resource_type: z13.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
2568
+ resource_id: z13.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
2569
+ alias: z13.string().min(1).max(48).regex(
2570
+ /^[A-Z][A-Z0-9_]*$/,
2571
+ "Alias must be uppercase letters, digits and underscores, starting with a letter."
2572
+ ).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
2573
+ },
2574
+ handler: async (args2, ctx) => {
2575
+ const teamId = await ctx.resolveTeamId();
2576
+ const response = await ctx.hoststack.serviceResourceLinks.create(teamId, args2.service_id, {
2577
+ resourceType: args2.resource_type,
2578
+ resourceId: args2.resource_id,
2579
+ alias: args2.alias
2580
+ });
2581
+ const data = { link: shape(response.link) };
2582
+ return respond({
2583
+ summary: `Linked ${args2.resource_type} ${args2.resource_id} to service ${args2.service_id} as "${args2.alias}". Call trigger_deploy({ service_id: "${args2.service_id}" }) to inject it \u2014 the running container will not see the vars until then.`,
2584
+ data
2585
+ });
2586
+ }
2587
+ });
2588
+ defineTool({
2589
+ name: "unlink_resource_from_service",
2590
+ category: "resource-links",
2591
+ description: [
2592
+ "Remove a resource link from a service. The resource itself is NOT deleted \u2014 only the binding.",
2593
+ "",
2594
+ "When to use: repointing a service at a different database, or detaching a resource a service no longer needs. The injected env vars disappear on the next deploy, so a service that still reads DATABASE_URL will break then, not immediately. Use delete_database if the intent is to destroy the data.",
2595
+ "",
2596
+ "Inputs:",
2597
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
2598
+ " - link_id: numeric linkId from list_service_resources (the `id` field on the link, not the resource id).",
2599
+ "",
2600
+ "Returns: { ok: true }.",
2601
+ "",
2602
+ 'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
2603
+ ].join("\n"),
2604
+ input: {
2605
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
2606
+ link_id: z13.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
2607
+ },
2608
+ handler: async (args2, ctx) => {
2609
+ const teamId = await ctx.resolveTeamId();
2610
+ await ctx.hoststack.serviceResourceLinks.delete(teamId, args2.service_id, args2.link_id);
2611
+ return respond({
2612
+ summary: `Unlinked resource link ${args2.link_id} from service ${args2.service_id}. The resource still exists; its env vars vanish on the next deploy.`
2613
+ });
2614
+ }
2615
+ });
2616
+
2617
+ // src/tools/services.ts
2618
+ import { z as z14 } from "zod";
2619
+ var DEV_ENV_IMAGE = "registry.hoststack.dev/hoststack/dev-env:latest";
2163
2620
  var DEV_ENV_VOLUME = { name: "workspace", mountPath: "/workspace", sizeGb: 10 };
2164
2621
  var SERVICE_TYPES = [
2165
2622
  "web_service",
@@ -2207,11 +2664,11 @@ defineTool({
2207
2664
  'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
2208
2665
  ].join("\n"),
2209
2666
  input: {
2210
- project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2211
- environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2212
- status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2213
- type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
2214
- dev_environment: z13.boolean().optional().describe(
2667
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2668
+ environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2669
+ status: z14.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2670
+ type: z14.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
2671
+ dev_environment: z14.boolean().optional().describe(
2215
2672
  "Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
2216
2673
  )
2217
2674
  },
@@ -2256,6 +2713,8 @@ defineTool({
2256
2713
  "",
2257
2714
  "When to use: the user wants to deploy something new. For a one-command AI dev environment specifically, prefer create_dev_environment (it also attaches the /workspace volume and sets the MCP keys).",
2258
2715
  "",
2716
+ '*** NOT for databases. *** If the user wants Postgres, Redis, MySQL, MariaDB or MongoDB, call create_database \u2014 do NOT create a service with docker_image "postgres:16" / "redis:7" / "mongo" / "mysql". A database deployed as a service is unmanaged: no backups, no version upgrades, no HA, no credential rotation, no metrics, no persistent volume, and nothing injects its URL into your app. `docker_image` here is for YOUR application images (or sidecars), not for datastores the platform already manages.',
2717
+ "",
2259
2718
  "Inputs:",
2260
2719
  ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
2261
2720
  " - name: service name (1\u2013100 chars).",
@@ -2276,21 +2735,23 @@ defineTool({
2276
2735
  'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
2277
2736
  ].join("\n"),
2278
2737
  input: {
2279
- project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2280
- name: z13.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
2281
- type: z13.enum(SERVICE_TYPES).describe("Service type."),
2282
- docker_image: z13.string().max(500).optional().describe("Pre-built image ref. Mutually exclusive with github_repo_id."),
2283
- github_repo_id: z13.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
2284
- branch: z13.string().max(200).optional().describe('Git branch (default "main").'),
2285
- install_command: z13.string().max(1e3).optional().describe("Install shell command."),
2286
- build_command: z13.string().max(1e3).optional().describe("Build shell command."),
2287
- start_command: z13.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
2288
- cron_schedule: z13.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
2289
- publish_path: z13.string().max(500).optional().describe("Static-site output dir."),
2290
- runtime: z13.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
2291
- plan: z13.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2292
- environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
2293
- auto_deploy: z13.boolean().optional().describe("Trigger the first deploy immediately (default true).")
2738
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
2739
+ name: z14.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
2740
+ type: z14.enum(SERVICE_TYPES).describe("Service type."),
2741
+ docker_image: z14.string().max(500).optional().describe(
2742
+ "Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
2743
+ ),
2744
+ github_repo_id: z14.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
2745
+ branch: z14.string().max(200).optional().describe('Git branch (default "main").'),
2746
+ install_command: z14.string().max(1e3).optional().describe("Install shell command."),
2747
+ build_command: z14.string().max(1e3).optional().describe("Build shell command."),
2748
+ start_command: z14.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
2749
+ cron_schedule: z14.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
2750
+ publish_path: z14.string().max(500).optional().describe("Static-site output dir."),
2751
+ runtime: z14.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
2752
+ plan: z14.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2753
+ environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
2754
+ auto_deploy: z14.boolean().optional().describe("Trigger the first deploy immediately (default true).")
2294
2755
  },
2295
2756
  handler: async (args2, ctx) => {
2296
2757
  const teamId = await ctx.resolveTeamId();
@@ -2353,18 +2814,18 @@ defineTool({
2353
2814
  'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
2354
2815
  ].join("\n"),
2355
2816
  input: {
2356
- project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2357
- name: z13.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2358
- plan: z13.enum(SERVICE_PLANS).optional().describe(
2817
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
2818
+ name: z14.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2819
+ plan: z14.enum(SERVICE_PLANS).optional().describe(
2359
2820
  'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
2360
2821
  ),
2361
- disk_gb: z13.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2362
- hoststack_api_key: z13.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2363
- poststack_api_key: z13.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
2364
- repo_url: z13.string().max(500).optional().describe(
2822
+ disk_gb: z14.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2823
+ hoststack_api_key: z14.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2824
+ poststack_api_key: z14.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
2825
+ repo_url: z14.string().max(500).optional().describe(
2365
2826
  "Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
2366
2827
  ),
2367
- branch: z13.string().max(200).optional().describe("Branch to clone (with repo_url).")
2828
+ branch: z14.string().max(200).optional().describe("Branch to clone (with repo_url).")
2368
2829
  },
2369
2830
  handler: async (args2, ctx) => {
2370
2831
  const teamId = await ctx.resolveTeamId();
@@ -2475,9 +2936,9 @@ defineTool({
2475
2936
  'Example: spin_up_dev_environment({ service_id: "svc_api" }) \u2192 a dev box running a clone of the api service (repo + env-vars + cloned DB) with a public dev URL.'
2476
2937
  ].join("\n"),
2477
2938
  input: {
2478
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
2479
- include_database_clone: z13.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
2480
- name: z13.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
2939
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
2940
+ include_database_clone: z14.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
2941
+ name: z14.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
2481
2942
  },
2482
2943
  handler: async (args2, ctx) => {
2483
2944
  const teamId = await ctx.resolveTeamId();
@@ -2518,7 +2979,7 @@ defineTool({
2518
2979
  'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
2519
2980
  ].join("\n"),
2520
2981
  input: {
2521
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
2982
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
2522
2983
  },
2523
2984
  handler: async (args2, ctx) => {
2524
2985
  const teamId = await ctx.resolveTeamId();
@@ -2554,8 +3015,8 @@ defineTool({
2554
3015
  'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
2555
3016
  ].join("\n"),
2556
3017
  input: {
2557
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The box to resize \u2014 numeric id or publicId."),
2558
- size: z13.enum(SERVICE_PLANS).describe(
3018
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The box to resize \u2014 numeric id or publicId."),
3019
+ size: z14.enum(SERVICE_PLANS).describe(
2559
3020
  'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
2560
3021
  )
2561
3022
  },
@@ -2615,7 +3076,7 @@ defineTool({
2615
3076
  "",
2616
3077
  'Returns: { templates: [{ id, name, image, volume: { name, mountPath, sizeGb }, minPlan, companions }] }. Today there is one preset ("dev-environment").',
2617
3078
  "",
2618
- 'Example: list_templates() \u2192 { templates: [{ id: "dev-environment", image: "hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] }] }'
3079
+ 'Example: list_templates() \u2192 { templates: [{ id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] }] }'
2619
3080
  ].join("\n"),
2620
3081
  input: {},
2621
3082
  handler: async () => {
@@ -2649,10 +3110,12 @@ defineTool({
2649
3110
  "",
2650
3111
  "This makes a cloud Dev Box, NOT a project deploy environment (create_environment \u2192 Production / Staging / Preview). It is a workspace you code in, not a deployment target.",
2651
3112
  "",
3113
+ 'Every Dev Box \u2014 with or without companions \u2014 also ships NATIVE Postgres 17, Redis and Meilisearch INSIDE the box, started with `dev-services up` (postgres :5432 as user "dev" with trust auth, redis :6379, meilisearch :7700). That is the no-Docker replacement for `make db-up` and is the right answer for local development inside the box; there is no Docker daemon available in there. The `companions` option is different \u2014 it attaches SEPARATE managed databases to the box\'s environment for when the box needs a real, persistent, backed-up datastore.',
3114
+ "",
2652
3115
  'When to use: "create a dev environment for <repo>", "spin me up a cloud dev box with a Postgres". Distinct from spin_up_dev_environment (which clones an EXISTING service) and create_dev_environment (a bare box in a chosen project).',
2653
3116
  "",
2654
3117
  "Inputs:",
2655
- " - name: the Dev Box name.",
3118
+ ' - name (optional): the Dev Box name. Omit it and the box is named after its source (the repo name, or "dev-box" when blank), with a numeric suffix if that name is taken \u2014 so "give me a dev box" needs no invented name.',
2656
3119
  ' - source_kind: "github_repo" (clone a connected repo \u2014 needs github_repo_id), "url" (clone any http(s) git URL \u2014 needs clone_url), or "blank" (empty box).',
2657
3120
  " - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
2658
3121
  " - clone_url (for url): an http(s) git clone URL.",
@@ -2666,19 +3129,21 @@ defineTool({
2666
3129
  'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
2667
3130
  ].join("\n"),
2668
3131
  input: {
2669
- name: z13.string().min(1).max(100).describe("Dev Box name."),
2670
- source_kind: z13.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
2671
- github_repo_id: z13.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
2672
- clone_url: z13.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
2673
- branch: z13.string().min(1).max(255).optional().describe("Branch to clone."),
2674
- databases: z13.array(z13.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
2675
- plan: z13.enum(SERVICE_PLANS).optional().describe(
3132
+ name: z14.string().min(1).max(100).optional().describe(
3133
+ 'Dev Box name. Omit to have it named after the source (the repo name, or "dev-box" for a blank one), de-duplicated against existing boxes.'
3134
+ ),
3135
+ source_kind: z14.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
3136
+ github_repo_id: z14.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
3137
+ clone_url: z14.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
3138
+ branch: z14.string().min(1).max(255).optional().describe("Branch to clone."),
3139
+ databases: z14.array(z14.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
3140
+ plan: z14.enum(SERVICE_PLANS).optional().describe(
2676
3141
  'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
2677
3142
  ),
2678
- agent_accounts: z13.array(
2679
- z13.object({
2680
- provider: z13.enum(["claude", "codex", "opencode"]),
2681
- account_id: z13.number().int().positive()
3143
+ agent_accounts: z14.array(
3144
+ z14.object({
3145
+ provider: z14.enum(["claude", "codex", "opencode"]),
3146
+ account_id: z14.number().int().positive()
2682
3147
  })
2683
3148
  ).max(3).optional().describe(
2684
3149
  "Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
@@ -2715,7 +3180,7 @@ defineTool({
2715
3180
  source = { kind: "blank" };
2716
3181
  }
2717
3182
  const input = {
2718
- name: args2.name,
3183
+ ...args2.name ? { name: args2.name } : {},
2719
3184
  source,
2720
3185
  ...args2.databases ? { databases: args2.databases } : {},
2721
3186
  ...args2.plan ? { plan: args2.plan } : {},
@@ -2748,12 +3213,12 @@ defineTool({
2748
3213
  "Inputs:",
2749
3214
  ' - service_id: publicId of the service (e.g. "svc_abc123").',
2750
3215
  "",
2751
- "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, preDeployCommand, min/maxInstances, scale thresholds.",
3216
+ '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.',
2752
3217
  "",
2753
3218
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
2754
3219
  ].join("\n"),
2755
3220
  input: {
2756
- service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
3221
+ service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
2757
3222
  },
2758
3223
  handler: async (args2, ctx) => {
2759
3224
  const teamId = await ctx.resolveTeamId();
@@ -2785,7 +3250,7 @@ defineTool({
2785
3250
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
2786
3251
  ].join("\n"),
2787
3252
  input: {
2788
- service_id: z13.string().describe("Service publicId.")
3253
+ service_id: z14.string().describe("Service publicId.")
2789
3254
  },
2790
3255
  handler: async (args2, ctx) => {
2791
3256
  const teamId = await ctx.resolveTeamId();
@@ -2814,9 +3279,9 @@ defineTool({
2814
3279
  'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
2815
3280
  ].join("\n"),
2816
3281
  input: {
2817
- service_id: z13.string().describe("Service publicId."),
2818
- from: z13.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
2819
- to: z13.string().optional().describe("ISO-8601 upper bound; defaults to now.")
3282
+ service_id: z14.string().describe("Service publicId."),
3283
+ from: z14.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
3284
+ to: z14.string().optional().describe("ISO-8601 upper bound; defaults to now.")
2820
3285
  },
2821
3286
  handler: async (args2, ctx) => {
2822
3287
  const teamId = await ctx.resolveTeamId();
@@ -2860,8 +3325,8 @@ defineTool({
2860
3325
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
2861
3326
  ].join("\n"),
2862
3327
  input: {
2863
- service_id: z13.string().describe("Service publicId."),
2864
- name: z13.string().min(1).max(60).describe("New service name (1\u201360 chars).")
3328
+ service_id: z14.string().describe("Service publicId."),
3329
+ name: z14.string().min(1).max(60).describe("New service name (1\u201360 chars).")
2865
3330
  },
2866
3331
  handler: async (args2, ctx) => {
2867
3332
  const teamId = await ctx.resolveTeamId();
@@ -2898,6 +3363,7 @@ defineTool({
2898
3363
  " - port (optional): integer 1\u201365535 \u2014 container port the platform forwards traffic to.",
2899
3364
  ' - protocol (optional): "http" | "tcp".',
2900
3365
  ' - restart_policy (optional): "always" | "on-failure" | "no".',
3366
+ ` - deploy_strategy (optional): "rolling" (default) | "recreate". SET THIS TO "recreate" for any single-container service that holds an exclusive lock on a mounted volume \u2014 a database running inside the container, an embedded queue, anything with a lockfile or a fixed host port on shared storage. Under the default rolling strategy those services CANNOT deploy at all: the new container can't open the datadir the outgoing container still holds, so it exits; because it never goes healthy the old one is never stopped; because the old one is never stopped the lock is never released. The deploy deadlocks for the whole grace period and then reports "Health check timed out". "recreate" stops the old container first, at the cost of a brief outage.`,
2901
3367
  " - pre_deploy_command (optional): shell command run before the new release accepts traffic (typical use: migrations).",
2902
3368
  " - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
2903
3369
  " - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
@@ -2909,37 +3375,40 @@ defineTool({
2909
3375
  'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
2910
3376
  ].join("\n"),
2911
3377
  input: {
2912
- service_id: z13.string().describe("Service publicId."),
2913
- install_command: z13.string().nullable().optional().describe("Install shell command. Null clears."),
2914
- build_command: z13.string().nullable().optional().describe("Build shell command. Null clears."),
2915
- start_command: z13.string().nullable().optional().describe("Start shell command. Null clears."),
2916
- branch: z13.string().optional().describe("Git branch to track."),
2917
- root_directory: z13.string().optional().describe("Build context root."),
2918
- dockerfile_path: z13.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
2919
- auto_deploy: z13.boolean().optional().describe("Auto-deploy on push."),
2920
- health_check_path: z13.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
2921
- health_check_enabled: z13.boolean().optional().describe("Toggle health checking on/off."),
2922
- health_check_interval: z13.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
2923
- health_check_timeout: z13.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
2924
- health_check_grace_period_sec: z13.number().int().min(1).max(1800).optional().describe(
3378
+ service_id: z14.string().describe("Service publicId."),
3379
+ install_command: z14.string().nullable().optional().describe("Install shell command. Null clears."),
3380
+ build_command: z14.string().nullable().optional().describe("Build shell command. Null clears."),
3381
+ start_command: z14.string().nullable().optional().describe("Start shell command. Null clears."),
3382
+ branch: z14.string().optional().describe("Git branch to track."),
3383
+ root_directory: z14.string().optional().describe("Build context root."),
3384
+ dockerfile_path: z14.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
3385
+ auto_deploy: z14.boolean().optional().describe("Auto-deploy on push."),
3386
+ health_check_path: z14.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
3387
+ health_check_enabled: z14.boolean().optional().describe("Toggle health checking on/off."),
3388
+ health_check_interval: z14.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
3389
+ health_check_timeout: z14.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
3390
+ health_check_grace_period_sec: z14.number().int().min(1).max(1800).optional().describe(
2925
3391
  "Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
2926
3392
  ),
2927
- memory_mb: z13.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
2928
- cpu_shares: z13.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
2929
- disk_size_gb: z13.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
2930
- port: z13.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
2931
- protocol: z13.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
2932
- restart_policy: z13.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
2933
- pre_deploy_command: z13.string().optional().describe("Shell command run before the new release accepts traffic."),
2934
- instance_count: z13.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
2935
- min_instances: z13.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
2936
- max_instances: z13.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
2937
- scale_cpu_threshold: z13.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
2938
- scale_memory_threshold: z13.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
2939
- log_filter_rules: z13.array(
2940
- z13.object({
2941
- pattern: z13.string().min(1).max(200),
2942
- action: z13.enum(["drop", "downgrade"])
3393
+ memory_mb: z14.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
3394
+ cpu_shares: z14.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
3395
+ disk_size_gb: z14.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
3396
+ port: z14.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
3397
+ protocol: z14.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
3398
+ restart_policy: z14.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
3399
+ deploy_strategy: z14.enum(["rolling", "recreate"]).optional().describe(
3400
+ 'How a deploy replaces the container. "rolling" (default) = start new, wait for healthy, switch traffic, stop old (zero downtime). "recreate" = stop old first, then start new (brief outage) \u2014 required for a container holding an exclusive lock on a mounted volume, which cannot deploy at all under rolling.'
3401
+ ),
3402
+ pre_deploy_command: z14.string().optional().describe("Shell command run before the new release accepts traffic."),
3403
+ instance_count: z14.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
3404
+ min_instances: z14.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
3405
+ max_instances: z14.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
3406
+ scale_cpu_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
3407
+ scale_memory_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
3408
+ log_filter_rules: z14.array(
3409
+ z14.object({
3410
+ pattern: z14.string().min(1).max(200),
3411
+ action: z14.enum(["drop", "downgrade"])
2943
3412
  })
2944
3413
  ).max(50).optional().describe(
2945
3414
  "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
@@ -2974,6 +3443,8 @@ defineTool({
2974
3443
  if (args2.port !== void 0) configUpdate["port"] = args2.port;
2975
3444
  if (args2.protocol !== void 0) configUpdate["protocol"] = args2.protocol;
2976
3445
  if (args2.restart_policy !== void 0) configUpdate["restartPolicy"] = args2.restart_policy;
3446
+ if (args2.deploy_strategy !== void 0)
3447
+ configUpdate["deployStrategy"] = args2.deploy_strategy;
2977
3448
  if (args2.pre_deploy_command !== void 0)
2978
3449
  configUpdate["preDeployCommand"] = args2.pre_deploy_command;
2979
3450
  if (args2.instance_count !== void 0) {
@@ -3034,7 +3505,7 @@ defineTool({
3034
3505
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
3035
3506
  ].join("\n"),
3036
3507
  input: {
3037
- service_id: z13.string().describe("Service publicId.")
3508
+ service_id: z14.string().describe("Service publicId.")
3038
3509
  },
3039
3510
  handler: async (args2, ctx) => {
3040
3511
  const teamId = await ctx.resolveTeamId();
@@ -3058,7 +3529,7 @@ defineTool({
3058
3529
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
3059
3530
  ].join("\n"),
3060
3531
  input: {
3061
- service_id: z13.string().describe("Service publicId.")
3532
+ service_id: z14.string().describe("Service publicId.")
3062
3533
  },
3063
3534
  handler: async (args2, ctx) => {
3064
3535
  const teamId = await ctx.resolveTeamId();
@@ -3066,6 +3537,35 @@ defineTool({
3066
3537
  return respond({ summary: `Resumed service ${args2.service_id}.`, data: { ok: true } });
3067
3538
  }
3068
3539
  });
3540
+ defineTool({
3541
+ name: "delete_service",
3542
+ category: "services",
3543
+ description: [
3544
+ "Permanently delete a service: stops and removes its containers, releases its hostname and routes, and cascade-deletes its attached volumes, env vars, domains and deploy history. Irreversible.",
3545
+ "",
3546
+ "When to use: the user explicitly asks to delete or remove a service, or you are cleaning up a service that was created by mistake or is no longer needed. Without this, a broken or abandoned service could only be removed from the dashboard \u2014 so a service that failed to come up had to be left in place and worked around by creating another one.",
3547
+ "",
3548
+ "ALWAYS confirm with the user before calling this on anything that has served traffic. To stop a service temporarily without losing it, use suspend_service instead. To tear down an agentic Dev Box (which also cascades its cloned database), use delete_dev_environment.",
3549
+ "",
3550
+ "Inputs:",
3551
+ " - service_id: publicId of the service to delete.",
3552
+ "",
3553
+ "Returns: { ok: true }.",
3554
+ "",
3555
+ 'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
3556
+ ].join("\n"),
3557
+ input: {
3558
+ service_id: z14.string().describe("Service publicId.")
3559
+ },
3560
+ handler: async (args2, ctx) => {
3561
+ const teamId = await ctx.resolveTeamId();
3562
+ await ctx.hoststack.services.delete(teamId, args2.service_id);
3563
+ return respond({
3564
+ summary: `Deleted service ${args2.service_id} and its attached resources. This cannot be undone.`,
3565
+ data: { ok: true }
3566
+ });
3567
+ }
3568
+ });
3069
3569
  defineTool({
3070
3570
  name: "get_service_logs",
3071
3571
  category: "logs",
@@ -3092,16 +3592,16 @@ defineTool({
3092
3592
  ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
3093
3593
  ].join("\n"),
3094
3594
  input: {
3095
- service_id: z13.string().describe("Service publicId."),
3096
- lines: z13.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
3097
- since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3098
- until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3099
- stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3100
- level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
3595
+ service_id: z14.string().describe("Service publicId."),
3596
+ lines: z14.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
3597
+ since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3598
+ until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3599
+ stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3600
+ level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
3101
3601
  "Filter by structured JSON log level (pino/bunyan/severity). Falls back to a stream-alias hint for plain-text logs (info/debug\u2192stdout, warn/error/fatal\u2192stderr)."
3102
3602
  ),
3103
- search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
3104
- count_only: z13.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
3603
+ search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
3604
+ count_only: z14.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
3105
3605
  },
3106
3606
  handler: async (args2, ctx) => {
3107
3607
  const teamId = await ctx.resolveTeamId();
@@ -3150,14 +3650,14 @@ defineTool({
3150
3650
  'Example: get_service_logs_bulk({ service_ids: ["svc_api", "svc_worker"], level: "error", since: "-15m", count_only: true }) \u2192 { results: { svc_api: { count: 0 }, svc_worker: { count: 12 } } }.'
3151
3651
  ].join("\n"),
3152
3652
  input: {
3153
- service_ids: z13.array(z13.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
3154
- lines_per_service: z13.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
3155
- since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3156
- until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3157
- stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3158
- level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3159
- search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
3160
- count_only: z13.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3653
+ service_ids: z14.array(z14.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
3654
+ lines_per_service: z14.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
3655
+ since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3656
+ until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3657
+ stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3658
+ level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3659
+ search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
3660
+ count_only: z14.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3161
3661
  },
3162
3662
  handler: async (args2, ctx) => {
3163
3663
  const teamId = await ctx.resolveTeamId();
@@ -3203,7 +3703,8 @@ defineTool({
3203
3703
  });
3204
3704
 
3205
3705
  // src/tools/volumes.ts
3206
- import { z as z14 } from "zod";
3706
+ import { z as z15 } from "zod";
3707
+ var MIN_VOLUME_SIZE_GB = 10;
3207
3708
  defineTool({
3208
3709
  name: "list_volumes",
3209
3710
  category: "volumes",
@@ -3220,7 +3721,7 @@ defineTool({
3220
3721
  'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
3221
3722
  ].join("\n"),
3222
3723
  input: {
3223
- service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
3724
+ service_id: z15.string().describe("Service publicId (e.g. svc_abc123).")
3224
3725
  },
3225
3726
  handler: async (args2, ctx) => {
3226
3727
  const teamId = await ctx.resolveTeamId();
@@ -3242,17 +3743,24 @@ defineTool({
3242
3743
  " - service_id: publicId of the service to attach to.",
3243
3744
  " - name: lowercase alphanumeric + hyphens, \u226464 chars (used as the docker volume identifier \u2014 change with care once data is written).",
3244
3745
  ' - mount_path: in-container absolute path (e.g. "/var/data").',
3245
- " - size_gb: optional, 1\u2013100, default 1. Counts against your plan storage quota and is metered for billing.",
3746
+ " - size_gb: optional, 10\u2013100, default 10. 10 GB is the platform minimum (the underlying block storage cannot provision anything smaller) \u2014 a smaller value is rejected here rather than failing the next deploy. Metered for billing.",
3246
3747
  "",
3247
- "Returns: { volume: Volume } \u2014 the created record.",
3748
+ 'Returns: { volume: Volume } \u2014 the created record. A durable volume comes back `status: "pending"`; the disk is created and attached on the next deploy, which flips it to "active". A deploy is required before the mount exists.',
3248
3749
  "",
3249
- 'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" } }'
3750
+ 'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "pending" } }'
3250
3751
  ].join("\n"),
3251
3752
  input: {
3252
- service_id: z14.string().describe("Service publicId."),
3253
- name: z14.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
3254
- mount_path: z14.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
3255
- size_gb: z14.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
3753
+ service_id: z15.string().describe("Service publicId."),
3754
+ name: z15.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
3755
+ mount_path: z15.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
3756
+ // 10 GB is the real floor: the block-storage backend rejects anything
3757
+ // smaller. Advertising 1 GB here (and defaulting to it) meant taking the
3758
+ // defaults produced a volume that provisioned with `Hetzner API error:
3759
+ // 422` on the NEXT deploy, with nothing tying the failure back to the
3760
+ // size. Reject it at the call instead.
3761
+ size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
3762
+ `Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
3763
+ )
3256
3764
  },
3257
3765
  handler: async (args2, ctx) => {
3258
3766
  const teamId = await ctx.resolveTeamId();
@@ -3264,7 +3772,7 @@ defineTool({
3264
3772
  const response = await ctx.hoststack.volumes.create(teamId, args2.service_id, input);
3265
3773
  const data = { volume: shape(response.volume) };
3266
3774
  return respond({
3267
- summary: `Attached volume "${args2.name}" (${args2.size_gb ?? 1}GB) at ${args2.mount_path} on service ${args2.service_id}.`,
3775
+ summary: `Attached volume "${args2.name}" (${response.volume.sizeGb}GB) at ${args2.mount_path} on service ${args2.service_id}. Status is "${response.volume.status}" \u2014 deploy the service to provision and mount it.`,
3268
3776
  data
3269
3777
  });
3270
3778
  }
@@ -3288,10 +3796,10 @@ defineTool({
3288
3796
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
3289
3797
  ].join("\n"),
3290
3798
  input: {
3291
- service_id: z14.string().describe("Service publicId."),
3292
- volume_id: z14.string().describe("Volume publicId (e.g. vol_\u2026)."),
3293
- mount_path: z14.string().startsWith("/").max(500).optional().describe("New mount path."),
3294
- size_gb: z14.number().int().min(1).max(100).optional().describe("New size in GB.")
3799
+ service_id: z15.string().describe("Service publicId."),
3800
+ volume_id: z15.string().describe("Volume publicId (e.g. vol_\u2026)."),
3801
+ mount_path: z15.string().startsWith("/").max(500).optional().describe("New mount path."),
3802
+ size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
3295
3803
  },
3296
3804
  handler: async (args2, ctx) => {
3297
3805
  const teamId = await ctx.resolveTeamId();
@@ -3329,8 +3837,8 @@ defineTool({
3329
3837
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
3330
3838
  ].join("\n"),
3331
3839
  input: {
3332
- service_id: z14.string().describe("Service publicId."),
3333
- volume_id: z14.string().describe("Volume publicId.")
3840
+ service_id: z15.string().describe("Service publicId."),
3841
+ volume_id: z15.string().describe("Volume publicId.")
3334
3842
  },
3335
3843
  handler: async (args2, ctx) => {
3336
3844
  const teamId = await ctx.resolveTeamId();