@hoststack.dev/mcp 0.13.1 → 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.
package/dist/index.js CHANGED
@@ -2,6 +2,10 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { HostStack } from "@hoststack.dev/sdk";
4
4
 
5
+ // src/version.ts
6
+ var MCP_VERSION = true ? "0.16.0" : "0.0.0-dev";
7
+ var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
8
+
5
9
  // src/api-client.ts
6
10
  var ApiClient = class {
7
11
  constructor(apiKey, baseUrl) {
@@ -11,7 +15,8 @@ var ApiClient = class {
11
15
  get headers() {
12
16
  return {
13
17
  Authorization: `Bearer ${this.apiKey}`,
14
- "Content-Type": "application/json"
18
+ "Content-Type": "application/json",
19
+ "User-Agent": USER_AGENT
15
20
  };
16
21
  }
17
22
  async get(path, params) {
@@ -58,8 +63,8 @@ var ApiClient = class {
58
63
  }
59
64
  async handle(res) {
60
65
  if (!res.ok) {
61
- const error = await res.json().catch(() => ({ error: res.statusText }));
62
- throw new Error(error.error ?? `API error: ${res.status}`);
66
+ const body = await res.json().catch(() => ({ error: res.statusText }));
67
+ throw new Error(formatApiError(body, res.status));
63
68
  }
64
69
  if (res.status === 204) {
65
70
  return void 0;
@@ -67,6 +72,31 @@ var ApiClient = class {
67
72
  return res.json();
68
73
  }
69
74
  };
75
+ function formatApiError(body, status) {
76
+ const fallback = `API error: ${status}`;
77
+ if (!body || typeof body !== "object") return fallback;
78
+ const err = body.error;
79
+ if (typeof err === "string") return err.trim() ? err : fallback;
80
+ const issues = Array.isArray(err) ? err : err && typeof err === "object" && Array.isArray(err.issues) ? err.issues : null;
81
+ if (issues && issues.length > 0) {
82
+ const rendered = issues.map((issue) => {
83
+ if (!issue || typeof issue !== "object") return String(issue);
84
+ const { path, message } = issue;
85
+ const field = Array.isArray(path) && path.length > 0 ? path.join(".") : null;
86
+ const text = typeof message === "string" ? message : "invalid value";
87
+ return field ? `${field}: ${text}` : text;
88
+ }).join("; ");
89
+ return `Invalid request \u2014 ${rendered}`;
90
+ }
91
+ if (err !== void 0) {
92
+ try {
93
+ return `${fallback}: ${JSON.stringify(err)}`;
94
+ } catch {
95
+ return fallback;
96
+ }
97
+ }
98
+ return fallback;
99
+ }
70
100
 
71
101
  // src/prompts/registry.ts
72
102
  var prompts = [];
@@ -592,11 +622,176 @@ defineTool({
592
622
 
593
623
  // src/tools/databases.ts
594
624
  import { z as z5 } from "zod";
625
+ var DATABASE_VERSIONS = {
626
+ postgres: { default: "18", supported: ["18", "17", "16", "15"] },
627
+ redis: { default: "8", supported: ["8", "7", "6"] },
628
+ mysql: { default: "8.4", supported: ["8.4", "8.0", "5.7"] },
629
+ mariadb: { default: "11.4", supported: ["11.4", "10.11"] },
630
+ mongodb: { default: "8", supported: ["8", "7", "6"] }
631
+ };
632
+ var DB_ENGINES = ["postgres", "redis", "mysql", "mariadb", "mongodb"];
633
+ var VERSION_HELP = Object.keys(DATABASE_VERSIONS).map(
634
+ (e) => `${e}: ${DATABASE_VERSIONS[e].supported.join("/")} (default ${DATABASE_VERSIONS[e].default})`
635
+ ).join("; ");
636
+ defineTool({
637
+ name: "create_database",
638
+ category: "databases",
639
+ description: [
640
+ "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.",
641
+ "",
642
+ '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.',
643
+ "",
644
+ '*** 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.',
645
+ "",
646
+ "How a database reaches your app (the whole flow \u2014 3 calls, no secrets handled):",
647
+ " 1. create_database({ project_id, name, engine }) \u2014 provisions it.",
648
+ ' 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.',
649
+ " 3. trigger_deploy({ service_id }) \u2014 on that deploy the platform injects the connection info as env vars.",
650
+ "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.",
651
+ "",
652
+ "Inputs:",
653
+ ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
654
+ " - name: database name (1\u2013100 chars).",
655
+ ` - engine: ${DB_ENGINES.join(" | ")}.`,
656
+ ` - version (optional): engine version. Supported \u2014 ${VERSION_HELP}. Omit to get the default (recommended).`,
657
+ ' - plan (optional): "micro" | "starter" | "standard" | "pro" (default "starter"). "starter" is the smallest always-on tier that includes backups.',
658
+ " - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
659
+ " - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
660
+ " - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
661
+ "",
662
+ '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).',
663
+ "",
664
+ 'Provisioning is async: the row comes back immediately, usually as `creating`. Poll get_database until `status === "available"` before linking or connecting.',
665
+ "",
666
+ 'Example: create_database({ project_id: "prj_abc", name: "app-db", engine: "postgres" }) \u2192 { database: { id: 42, publicId: "db_\u2026", status: "creating" } }'
667
+ ].join("\n"),
668
+ input: {
669
+ project_id: z5.union([z5.number().int().positive(), z5.string()]).describe('Target project \u2014 numeric id or publicId ("prj_\u2026").'),
670
+ name: z5.string().min(1).max(100).describe("Database name (1\u2013100 chars)."),
671
+ engine: z5.enum(DB_ENGINES).describe("Database engine."),
672
+ version: z5.string().max(20).optional().describe(`Engine version. ${VERSION_HELP}. Omit for the engine default.`),
673
+ plan: z5.enum(["micro", "starter", "standard", "pro"]).optional().describe('Plan tier (memory/CPU). Default "starter".'),
674
+ environment_id: z5.union([z5.number().int().positive(), z5.string()]).optional().describe("Environment to bind to. Defaults to the project Production env."),
675
+ postgis: z5.boolean().optional().describe("Postgres only \u2014 enable the PostGIS extension image."),
676
+ pgvector: z5.boolean().optional().describe(
677
+ "Postgres only \u2014 enable the pgvector extension image. Exclusive with postgis."
678
+ )
679
+ },
680
+ handler: async (args, ctx) => {
681
+ const teamId = await ctx.resolveTeamId();
682
+ const projectId = await ctx.hoststack.resolveId(args.project_id, {
683
+ kind: "project",
684
+ teamId
685
+ });
686
+ const input = {
687
+ name: args.name,
688
+ engine: args.engine,
689
+ projectId
690
+ };
691
+ if (args.version !== void 0) input.version = args.version;
692
+ if (args.plan !== void 0) input.plan = args.plan;
693
+ if (args.environment_id !== void 0) {
694
+ input.environmentId = await ctx.hoststack.resolveId(args.environment_id, {
695
+ kind: "environment",
696
+ teamId
697
+ });
698
+ }
699
+ if (args.postgis !== void 0) input.postgis = args.postgis;
700
+ if (args.pgvector !== void 0) input.pgvector = args.pgvector;
701
+ const response = await ctx.hoststack.databases.create(teamId, input);
702
+ const data = { database: shapeDatabase(response.database) };
703
+ const db = response.database;
704
+ return respond({
705
+ summary: `Created ${args.engine} database "${args.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.`,
706
+ data
707
+ });
708
+ }
709
+ });
710
+ defineTool({
711
+ name: "delete_database",
712
+ category: "databases",
713
+ description: [
714
+ "Permanently delete a managed database \u2014 the container, its volume, and ALL data it holds.",
715
+ "",
716
+ '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.',
717
+ "",
718
+ "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.",
719
+ "",
720
+ "Inputs:",
721
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
722
+ "",
723
+ "Returns: { ok: true }.",
724
+ "",
725
+ 'Example: delete_database({ database_id: "db_abc" }) \u2192 { ok: true }'
726
+ ].join("\n"),
727
+ input: {
728
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to permanently delete.")
729
+ },
730
+ handler: async (args, ctx) => {
731
+ const teamId = await ctx.resolveTeamId();
732
+ await ctx.hoststack.databases.delete(teamId, args.database_id);
733
+ return respond({
734
+ summary: `Deleted database ${args.database_id} and all of its data. This cannot be undone.`
735
+ });
736
+ }
737
+ });
738
+ defineTool({
739
+ name: "suspend_database",
740
+ category: "databases",
741
+ description: [
742
+ "Suspend a managed database \u2014 stops its container while KEEPING the volume and all data. The reversible alternative to delete_database.",
743
+ "",
744
+ "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.",
745
+ "",
746
+ "Inputs:",
747
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
748
+ "",
749
+ "Returns: { ok: true }.",
750
+ "",
751
+ 'Example: suspend_database({ database_id: "db_abc" }) \u2192 { ok: true }'
752
+ ].join("\n"),
753
+ input: {
754
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to suspend.")
755
+ },
756
+ handler: async (args, ctx) => {
757
+ const teamId = await ctx.resolveTeamId();
758
+ await ctx.hoststack.databases.suspend(teamId, args.database_id);
759
+ return respond({
760
+ summary: `Suspended database ${args.database_id}. Data is preserved; resume_database restores it on the same URL.`
761
+ });
762
+ }
763
+ });
764
+ defineTool({
765
+ name: "resume_database",
766
+ category: "databases",
767
+ description: [
768
+ "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.",
769
+ "",
770
+ "When to use: undoing a suspend_database, or bringing back a database that was suspended for non-payment once billing is resolved.",
771
+ "",
772
+ "Inputs:",
773
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
774
+ "",
775
+ 'Returns: { ok: true }. Poll get_database until `status === "available"`.',
776
+ "",
777
+ 'Example: resume_database({ database_id: "db_abc" }) \u2192 { ok: true }'
778
+ ].join("\n"),
779
+ input: {
780
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to resume.")
781
+ },
782
+ handler: async (args, ctx) => {
783
+ const teamId = await ctx.resolveTeamId();
784
+ await ctx.hoststack.databases.resume(teamId, args.database_id);
785
+ return respond({
786
+ summary: `Resume dispatched for ${args.database_id}. Poll get_database until status=available.`
787
+ });
788
+ }
789
+ });
595
790
  defineTool({
596
791
  name: "list_databases",
597
792
  category: "databases",
598
793
  description: [
599
- "List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB, Meilisearch, NATS) inside a project.",
794
+ "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.",
600
795
  "",
601
796
  "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.",
602
797
  "",
@@ -781,6 +976,34 @@ defineTool({
781
976
  });
782
977
  }
783
978
  });
979
+ defineTool({
980
+ name: "restart_database",
981
+ category: "databases",
982
+ description: [
983
+ "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.",
984
+ "",
985
+ "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.",
986
+ "",
987
+ "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.",
988
+ "",
989
+ "Inputs:",
990
+ " - database_id: publicId of the database to restart.",
991
+ "",
992
+ "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.",
993
+ "",
994
+ 'Example: restart_database({ database_id: "db_abc" }) \u2192 { ok: true }'
995
+ ].join("\n"),
996
+ input: {
997
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to restart.")
998
+ },
999
+ handler: async (args, ctx) => {
1000
+ const teamId = await ctx.resolveTeamId();
1001
+ await ctx.hoststack.databases.restart(teamId, args.database_id);
1002
+ return respond({
1003
+ summary: `Restart dispatched for ${args.database_id}. It will be briefly unavailable while the container bounces.`
1004
+ });
1005
+ }
1006
+ });
784
1007
  defineTool({
785
1008
  name: "get_database_cluster",
786
1009
  category: "databases",
@@ -906,7 +1129,7 @@ defineTool({
906
1129
  description: [
907
1130
  "Cancel a running deploy. Stops the build/deploy pipeline mid-flight; the previously live revision keeps serving traffic.",
908
1131
  "",
909
- "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.",
1132
+ "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.",
910
1133
  "",
911
1134
  "Inputs:",
912
1135
  " - service_id: publicId of the service.",
@@ -1054,11 +1277,21 @@ defineTool({
1054
1277
 
1055
1278
  // src/tools/dns-records.ts
1056
1279
  import { z as z7 } from "zod";
1057
- var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "ALIAS"];
1058
- async function resolveZonePublicId(api, teamId, input) {
1280
+ var DNS_RECORD_TYPES = [
1281
+ "A",
1282
+ "AAAA",
1283
+ "CNAME",
1284
+ "MX",
1285
+ "TXT",
1286
+ "NS",
1287
+ "SRV",
1288
+ "CAA",
1289
+ "ALIAS"
1290
+ ];
1291
+ async function resolveZonePublicId(hoststack, teamId, input) {
1059
1292
  if (input.zone_id) {
1060
- const { zones: zones2 } = await api.get(`/api/dns-zones/${teamId}`);
1061
- const match = zones2.find((z15) => z15.publicId === input.zone_id);
1293
+ const { zones: zones2 } = await hoststack.dns.listZones(teamId);
1294
+ const match = zones2.find((z16) => z16.publicId === input.zone_id);
1062
1295
  if (!match) {
1063
1296
  throw new Error(`Zone ${input.zone_id} not found on this team.`);
1064
1297
  }
@@ -1067,12 +1300,12 @@ async function resolveZonePublicId(api, teamId, input) {
1067
1300
  if (!input.domain) {
1068
1301
  throw new Error("Provide either zone_id or domain.");
1069
1302
  }
1070
- const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1303
+ const { zones } = await hoststack.dns.listZones(teamId);
1071
1304
  const fqdn = input.domain.toLowerCase().replace(/\.$/, "");
1072
1305
  const labels = fqdn.split(".");
1073
1306
  for (let i = 0; i < labels.length - 1; i++) {
1074
1307
  const candidate = labels.slice(i).join(".");
1075
- const match = zones.find((z15) => z15.domainName.toLowerCase() === candidate);
1308
+ const match = zones.find((z16) => z16.domainName.toLowerCase() === candidate);
1076
1309
  if (match && match.status !== "deleting") {
1077
1310
  return { publicId: match.publicId, domainName: match.domainName };
1078
1311
  }
@@ -1096,7 +1329,7 @@ defineTool({
1096
1329
  input: {},
1097
1330
  handler: async (_args, ctx) => {
1098
1331
  const teamId = await ctx.resolveTeamId();
1099
- const response = await ctx.api.get(`/api/dns-zones/${teamId}`);
1332
+ const response = await ctx.hoststack.dns.listZones(teamId);
1100
1333
  const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
1101
1334
  const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.`;
1102
1335
  return respond({ summary, data: { items } });
@@ -1125,10 +1358,8 @@ defineTool({
1125
1358
  const zoneInput = {};
1126
1359
  if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
1127
1360
  if (args.domain !== void 0) zoneInput.domain = args.domain;
1128
- const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1129
- const response = await ctx.api.get(
1130
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1131
- );
1361
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1362
+ const response = await ctx.hoststack.dns.listRecords(teamId, zone.publicId);
1132
1363
  const items = Array.isArray(response.records) ? response.records.map(shape) : [];
1133
1364
  const summary = items.length === 0 ? `Zone ${zone.domainName} has no records yet.` : `Returned ${items.length} record${items.length === 1 ? "" : "s"} for ${zone.domainName}.`;
1134
1365
  return respond({
@@ -1157,11 +1388,9 @@ defineTool({
1157
1388
  },
1158
1389
  handler: async (args, ctx) => {
1159
1390
  const teamId = await ctx.resolveTeamId();
1160
- const { zones } = await ctx.api.get(`/api/dns-zones/${teamId}`);
1391
+ const { zones } = await ctx.hoststack.dns.listZones(teamId);
1161
1392
  for (const zone of zones) {
1162
- const { records } = await ctx.api.get(
1163
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1164
- );
1393
+ const { records } = await ctx.hoststack.dns.listRecords(teamId, zone.publicId);
1165
1394
  const match = records.find((r) => r.publicId === args.record_id);
1166
1395
  if (match) {
1167
1396
  return respond({
@@ -1217,7 +1446,7 @@ defineTool({
1217
1446
  const zoneInput = {};
1218
1447
  if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
1219
1448
  if (args.domain !== void 0) zoneInput.domain = args.domain;
1220
- const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1449
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1221
1450
  const body = {
1222
1451
  type: args.type,
1223
1452
  name: args.name,
@@ -1225,10 +1454,7 @@ defineTool({
1225
1454
  };
1226
1455
  if (args.ttl !== void 0) body.ttl = args.ttl;
1227
1456
  if (args.priority !== void 0) body.priority = args.priority;
1228
- const response = await ctx.api.post(
1229
- `/api/dns-zones/${teamId}/${zone.publicId}/records`,
1230
- body
1231
- );
1457
+ const response = await ctx.hoststack.dns.createRecord(teamId, zone.publicId, body);
1232
1458
  return respond({
1233
1459
  summary: `Created ${args.type} ${args.name} on ${zone.domainName}.`,
1234
1460
  data: {
@@ -1268,7 +1494,7 @@ defineTool({
1268
1494
  return respondError(`Priority is required for ${args.type} records (0\u201365535).`);
1269
1495
  }
1270
1496
  const teamId = await ctx.resolveTeamId();
1271
- const target = await findRecordZone(ctx.api, teamId, args.record_id);
1497
+ const target = await findRecordZone(ctx.hoststack, teamId, args.record_id);
1272
1498
  if (!target) {
1273
1499
  return respondError(
1274
1500
  `Record ${args.record_id} not found on any zone owned by this team.`
@@ -1281,8 +1507,10 @@ defineTool({
1281
1507
  };
1282
1508
  if (args.ttl !== void 0) body.ttl = args.ttl;
1283
1509
  if (args.priority !== void 0) body.priority = args.priority;
1284
- const response = await ctx.api.put(
1285
- `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`,
1510
+ const response = await ctx.hoststack.dns.updateRecord(
1511
+ teamId,
1512
+ target.zone.publicId,
1513
+ args.record_id,
1286
1514
  body
1287
1515
  );
1288
1516
  return respond({
@@ -1317,27 +1545,66 @@ defineTool({
1317
1545
  },
1318
1546
  handler: async (args, ctx) => {
1319
1547
  const teamId = await ctx.resolveTeamId();
1320
- const target = await findRecordZone(ctx.api, teamId, args.record_id);
1548
+ const target = await findRecordZone(ctx.hoststack, teamId, args.record_id);
1321
1549
  if (!target) {
1322
1550
  return respondError(
1323
1551
  `Record ${args.record_id} not found on any zone owned by this team.`
1324
1552
  );
1325
1553
  }
1326
- await ctx.api.delete(
1327
- `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`
1328
- );
1554
+ await ctx.hoststack.dns.deleteRecord(teamId, target.zone.publicId, args.record_id);
1329
1555
  return respond({
1330
1556
  summary: `Deleted record ${args.record_id} from ${target.zone.domainName}.`,
1331
1557
  data: { ok: true }
1332
1558
  });
1333
1559
  }
1334
1560
  });
1335
- async function findRecordZone(api, teamId, recordPublicId) {
1336
- const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1337
- for (const zone of zones) {
1338
- const { records } = await api.get(
1339
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1561
+ defineTool({
1562
+ name: "resync_dns_record",
1563
+ category: "dns",
1564
+ description: [
1565
+ '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.',
1566
+ "",
1567
+ `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.`,
1568
+ "",
1569
+ "Inputs:",
1570
+ ' - record_id: record publicId (e.g. "dnr_abc").',
1571
+ "",
1572
+ 'Returns: { record: Record } \u2014 the record with its refreshed status ("active" on success).',
1573
+ "",
1574
+ 'Example: resync_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { status: "active", ... } }'
1575
+ ].join("\n"),
1576
+ input: {
1577
+ record_id: z7.string().describe("Record publicId.")
1578
+ },
1579
+ handler: async (args, ctx) => {
1580
+ const teamId = await ctx.resolveTeamId();
1581
+ const target = await findRecordZone(ctx.hoststack, teamId, args.record_id);
1582
+ if (!target) {
1583
+ return respondError(
1584
+ `Record ${args.record_id} not found on any zone owned by this team.`
1585
+ );
1586
+ }
1587
+ const response = await ctx.hoststack.dns.resyncRecord(
1588
+ teamId,
1589
+ target.zone.publicId,
1590
+ args.record_id
1340
1591
  );
1592
+ return respond({
1593
+ summary: `Re-synced record ${args.record_id} on ${target.zone.domainName} (status: ${response.record.status}).`,
1594
+ data: {
1595
+ zone: {
1596
+ publicId: target.zone.publicId,
1597
+ domainName: target.zone.domainName
1598
+ },
1599
+ record: shape(response.record)
1600
+ }
1601
+ });
1602
+ }
1603
+ });
1604
+ async function findRecordZone(hoststack, teamId, recordPublicId) {
1605
+ const { zones } = await hoststack.dns.listZones(teamId);
1606
+ for (const zone of zones) {
1607
+ const { records } = await hoststack.dns.listRecords(teamId, zone.publicId);
1341
1608
  const match = records.find((r) => r.publicId === recordPublicId);
1342
1609
  if (match) return { zone, record: match };
1343
1610
  }
@@ -1397,9 +1664,10 @@ defineTool({
1397
1664
  };
1398
1665
  if (args.path_prefix !== void 0) input.pathPrefix = args.path_prefix;
1399
1666
  const response = await ctx.hoststack.domains.add(teamId, input);
1400
- const data = { domain: shapeDomain(response.domain) };
1667
+ const dnsSyncWarning = response.domain.dnsSyncWarning;
1668
+ const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
1401
1669
  return respond({
1402
- summary: `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
1670
+ summary: dnsSyncWarning ? `Added domain ${args.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
1403
1671
  data
1404
1672
  });
1405
1673
  }
@@ -1497,6 +1765,7 @@ defineTool({
1497
1765
  ' - key: env-var name (e.g. "DATABASE_URL").',
1498
1766
  " - value: new value (will be encrypted at rest if is_secret=true).",
1499
1767
  " - is_secret (optional): true marks the value as secret (masked on read). On create, defaults to true for safety. On update, omitting it leaves the existing flag untouched \u2014 pass it explicitly only when you want to change classification.",
1768
+ ' - target (optional): where the var is injected \u2014 "build", "runtime", or "both". On create, defaults to "both". On update, omitting it leaves the existing target untouched.',
1500
1769
  "",
1501
1770
  'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
1502
1771
  "",
@@ -1506,7 +1775,8 @@ defineTool({
1506
1775
  service_id: z9.string().describe("Service publicId."),
1507
1776
  key: z9.string().min(1).max(128).describe("Env-var key."),
1508
1777
  value: z9.string().describe("New value."),
1509
- is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
1778
+ is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
1779
+ target: z9.enum(["build", "runtime", "both"]).optional().describe("Injection target: build, runtime, or both. Default both.")
1510
1780
  },
1511
1781
  handler: async (args, ctx) => {
1512
1782
  const teamId = await ctx.resolveTeamId();
@@ -1515,6 +1785,7 @@ defineTool({
1515
1785
  if (match) {
1516
1786
  const updatePayload = { value: args.value };
1517
1787
  if (args.is_secret !== void 0) updatePayload.isSecret = args.is_secret;
1788
+ if (args.target !== void 0) updatePayload.target = args.target;
1518
1789
  const response2 = await ctx.hoststack.envVars.update(
1519
1790
  teamId,
1520
1791
  args.service_id,
@@ -1530,7 +1801,8 @@ defineTool({
1530
1801
  const response = await ctx.hoststack.envVars.create(teamId, args.service_id, {
1531
1802
  key: args.key,
1532
1803
  value: args.value,
1533
- isSecret: args.is_secret ?? true
1804
+ isSecret: args.is_secret ?? true,
1805
+ ...args.target !== void 0 ? { target: args.target } : {}
1534
1806
  });
1535
1807
  const data = {
1536
1808
  envVar: shapeEnvVar(response.envVar),
@@ -1586,7 +1858,7 @@ defineTool({
1586
1858
  "",
1587
1859
  "Inputs:",
1588
1860
  " - service_id: publicId of the service.",
1589
- " - env_vars: array of { key, value, is_secret? }. is_secret defaults to true per row.",
1861
+ ' - env_vars: array of { key, value, is_secret?, target? }. is_secret defaults to FALSE per row (a row is stored in the clear unless you set is_secret:true); target defaults to "both". Note this differs from set_env_var, which defaults a newly-created var to secret.',
1590
1862
  "",
1591
1863
  "Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
1592
1864
  "",
@@ -1598,7 +1870,8 @@ defineTool({
1598
1870
  z9.object({
1599
1871
  key: z9.string().min(1).max(128),
1600
1872
  value: z9.string(),
1601
- is_secret: z9.boolean().optional()
1873
+ is_secret: z9.boolean().optional(),
1874
+ target: z9.enum(["build", "runtime", "both"]).optional()
1602
1875
  })
1603
1876
  ).max(500).describe("Array of env-var rows. Hard cap 500.")
1604
1877
  },
@@ -1611,6 +1884,7 @@ defineTool({
1611
1884
  value: v.value
1612
1885
  };
1613
1886
  if (v.is_secret !== void 0) row.isSecret = v.is_secret;
1887
+ if (v.target !== void 0) row.target = v.target;
1614
1888
  return row;
1615
1889
  })
1616
1890
  };
@@ -1632,6 +1906,10 @@ defineTool({
1632
1906
  "",
1633
1907
  "When to use: the user wants to see what envs exist before creating a service in one or promoting a deploy. Every project has at least Production.",
1634
1908
  "",
1909
+ // §2.2: these are project deployment environments, not cloud Dev Boxes — point agents at the
1910
+ // right surface so the two "dev" concepts stay distinct (bidirectional cross-reference).
1911
+ "NOTE \u2014 these are project deployment environments (Production / Staging / Preview), NOT agentic cloud Dev Boxes. To list Dev Boxes use list_dev_environments; to create one use create_dev_environment / create_standalone_dev_environment / spin_up_dev_environment.",
1912
+ "",
1635
1913
  "Inputs:",
1636
1914
  ' - project_id: project publicId (e.g. "prj_abc123").',
1637
1915
  "",
@@ -1658,10 +1936,15 @@ defineTool({
1658
1936
  "",
1659
1937
  "When to use: the user wants to add an env so they can run a sibling service alongside production for testing or staging before release.",
1660
1938
  "",
1939
+ // §2.1/§2.2: steer callers away from the agentic cloud Dev Box tools (which live in
1940
+ // services.ts) — an environment is a deployment target, NOT a Dev Box. Bidirectional with
1941
+ // the cross-references those dev-box tools point back here.
1942
+ 'NOTE \u2014 this is NOT a cloud Dev Box. An environment is a project deployment target (Production / Staging / Preview); type="development" just gives services a -dev hostname suffix. For an agentic cloud Dev Box (a terminal you drive coding agents in) use create_dev_environment / create_standalone_dev_environment / spin_up_dev_environment instead.',
1943
+ "",
1661
1944
  "Inputs:",
1662
1945
  " - project_id: project publicId.",
1663
1946
  " - name: human-readable name (1\u201364 chars). Shown in the env switcher.",
1664
- ' - type: "production" | "staging" | "development" | "preview". Determines the hostname suffix on services in this env (production stays clean; others get -staging / -dev / -preview).',
1947
+ ' - type: "production" | "staging" | "development" | "preview". Determines the hostname suffix on services in this env (production stays clean; others get -staging / -dev / -preview). type="development" creates a PROJECT deployment environment (a -dev hostname suffix), NOT an agentic Dev Box \u2014 for a cloud dev box use create_dev_environment / create_standalone_dev_environment / spin_up_dev_environment.',
1665
1948
  " - is_protected (optional): require admin role for destructive actions in this env. Default false.",
1666
1949
  "",
1667
1950
  "Returns: { environment: Environment }.",
@@ -1671,7 +1954,10 @@ defineTool({
1671
1954
  input: {
1672
1955
  project_id: z10.string().describe("Project publicId."),
1673
1956
  name: z10.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
1674
- type: z10.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
1957
+ type: z10.enum(["production", "staging", "development", "preview"]).describe(
1958
+ // §2.1: "development" here is a project deployment env, NOT a cloud Dev Box.
1959
+ 'Environment type \u2014 drives the hostname suffix. type="development" creates a PROJECT deployment environment (a -dev hostname suffix), NOT an agentic Dev Box \u2014 for a cloud dev box use create_dev_environment / create_standalone_dev_environment / spin_up_dev_environment.'
1960
+ ),
1675
1961
  is_protected: z10.boolean().optional().describe("Require admin role for destructive actions. Default false.")
1676
1962
  },
1677
1963
  handler: async (args, ctx) => {
@@ -1762,7 +2048,57 @@ defineTool({
1762
2048
  }
1763
2049
  });
1764
2050
 
2051
+ // src/tools/github.ts
2052
+ defineTool({
2053
+ name: "sync_github_repos",
2054
+ category: "github",
2055
+ description: [
2056
+ "Re-sync the repository list from GitHub for every connected GitHub App installation.",
2057
+ "",
2058
+ "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.)",
2059
+ "",
2060
+ "No inputs.",
2061
+ "",
2062
+ "Returns: { installations: number, repos: [{ account, count }], totalRepos } \u2014 the accounts synced and how many repos each now exposes.",
2063
+ "",
2064
+ 'Example: sync_github_repos({}) \u2192 { installations: 1, repos: [{ account: "acme", count: 12 }], totalRepos: 12 }'
2065
+ ].join("\n"),
2066
+ input: {},
2067
+ handler: async (_args, ctx) => {
2068
+ const teamId = await ctx.resolveTeamId();
2069
+ const { installations } = await ctx.api.get(
2070
+ `/api/github/${teamId}/installations`
2071
+ );
2072
+ if (installations.length === 0) {
2073
+ return respondError(
2074
+ "No GitHub installations connected. Install the HostStack GitHub App from the dashboard (Settings \u2192 GitHub) first."
2075
+ );
2076
+ }
2077
+ const perAccount = [];
2078
+ let totalRepos = 0;
2079
+ for (const inst of installations) {
2080
+ const { repos } = await ctx.api.post(
2081
+ `/api/github/${teamId}/installations/${inst.id}/sync`
2082
+ );
2083
+ perAccount.push({ account: inst.accountLogin, count: repos.length });
2084
+ totalRepos += repos.length;
2085
+ }
2086
+ return respond({
2087
+ summary: `Synced ${totalRepos} repositories across ${installations.length} installation(s).`,
2088
+ data: { installations: installations.length, repos: perAccount, totalRepos }
2089
+ });
2090
+ }
2091
+ });
2092
+
1765
2093
  // src/tools/meta.ts
2094
+ var DEV_ENV_TOOL_NAMES = [
2095
+ "create_dev_environment",
2096
+ "create_standalone_dev_environment",
2097
+ "spin_up_dev_environment",
2098
+ "list_dev_environments",
2099
+ "resize_dev_environment",
2100
+ "delete_dev_environment"
2101
+ ];
1766
2102
  defineTool({
1767
2103
  name: "get_me",
1768
2104
  category: "meta",
@@ -1787,6 +2123,36 @@ defineTool({
1787
2123
  return respond({ summary: `Authenticated as ${userEmail}${teamLabel}.`, data });
1788
2124
  }
1789
2125
  });
2126
+ defineTool({
2127
+ name: "describe_mcp",
2128
+ category: "meta",
2129
+ description: [
2130
+ "Report this MCP build: its package version, total tool count, and the list of Dev Box tools it exposes. Read-only \u2014 touches no account data.",
2131
+ "",
2132
+ "When to use: at the start of a session to confirm the live MCP build, OR when a Dev Box tool you expect (create_dev_environment, create_standalone_dev_environment, spin_up_dev_environment, list_dev_environments, resize_dev_environment, delete_dev_environment) seems to be missing. A STALE npx/npm cache can silently present an old tool set with no other signal \u2014 call this and check `devEnvToolsPresent`; if it is false, the cached build predates the Dev Box tools and the user should refresh it (e.g. `npx --yes @hoststack.dev/mcp@latest` / clear the npx cache).",
2133
+ "",
2134
+ 'Note on terminology: a "Dev Box" (a.k.a. Cloud Dev Box) is an agentic cloud box you code inside (create_dev_environment & friends). It is NOT a project "environment" (create_environment), which is a deployment target like Production / Staging / Preview. This tool only reports the Dev Box tool set.',
2135
+ "",
2136
+ "Inputs: none.",
2137
+ "",
2138
+ "Returns: { version, toolCount, devEnvTools: string[], devEnvToolsPresent: boolean } \u2014 version is the @hoststack.dev/mcp package version, toolCount is every registered tool, devEnvTools is the expected Dev Box tool names, and devEnvToolsPresent is true only if all of them are actually registered in this build.",
2139
+ "",
2140
+ 'Example: describe_mcp() \u2192 { version: "0.14.0", toolCount: 30, devEnvTools: ["create_dev_environment", \u2026], devEnvToolsPresent: true }'
2141
+ ].join("\n"),
2142
+ input: {},
2143
+ handler: async (_args, _ctx) => {
2144
+ const registered = new Set(listToolDefinitions().map((t) => t.name));
2145
+ const devEnvToolsPresent = DEV_ENV_TOOL_NAMES.every((name) => registered.has(name));
2146
+ const data = {
2147
+ version: MCP_VERSION,
2148
+ toolCount: registered.size,
2149
+ devEnvTools: [...DEV_ENV_TOOL_NAMES],
2150
+ devEnvToolsPresent
2151
+ };
2152
+ const summary = devEnvToolsPresent ? `hoststack MCP v${MCP_VERSION} \u2014 ${data.toolCount} tools, all ${DEV_ENV_TOOL_NAMES.length} Dev Box tools present.` : `hoststack MCP v${MCP_VERSION} \u2014 ${data.toolCount} tools, but Dev Box tools are MISSING (stale build \u2014 refresh the npx/npm cache).`;
2153
+ return respond({ summary, data });
2154
+ }
2155
+ });
1790
2156
 
1791
2157
  // src/tools/notifications.ts
1792
2158
  import { z as z11 } from "zod";
@@ -1802,7 +2168,10 @@ var NOTIFICATION_EVENTS = [
1802
2168
  "service.restart_failed",
1803
2169
  "service.auto_suspended",
1804
2170
  "service.acme_cert_failed",
1805
- "git.auth_failed"
2171
+ "service.resource_alert",
2172
+ "git.auth_failed",
2173
+ "cron.execution_failed",
2174
+ "workflow.failed"
1806
2175
  ];
1807
2176
  defineTool({
1808
2177
  name: "list_notification_channels",
@@ -1841,7 +2210,7 @@ defineTool({
1841
2210
  " - webhook_url: Slack/Discord webhook URL OR email address.",
1842
2211
  " - 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).",
1843
2212
  "",
1844
- "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.",
2213
+ "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.",
1845
2214
  "",
1846
2215
  "Returns: { channel: Channel }.",
1847
2216
  "",
@@ -2096,9 +2465,159 @@ defineTool({
2096
2465
  }
2097
2466
  });
2098
2467
 
2099
- // src/tools/services.ts
2468
+ // src/tools/resource-links.ts
2100
2469
  import { z as z13 } from "zod";
2101
- var DEV_ENV_IMAGE = "hoststack/dev-env:latest";
2470
+ var RESOURCE_LINK_TYPES = [
2471
+ "database",
2472
+ "object_storage",
2473
+ "queue",
2474
+ "search",
2475
+ "email_domain"
2476
+ ];
2477
+ var INJECTION_CONTRACT = [
2478
+ "What a link injects at deploy time:",
2479
+ ' - 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.',
2480
+ " - 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.",
2481
+ " - The conventional names are set with `??=` \u2014 an env var you set yourself via set_env_var ALWAYS wins over the injected one."
2482
+ ].join("\n");
2483
+ defineTool({
2484
+ name: "list_managed_resources",
2485
+ category: "resource-links",
2486
+ description: [
2487
+ "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.",
2488
+ "",
2489
+ '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".',
2490
+ "",
2491
+ "Team-wide, not project-scoped. Use list_databases when you specifically want databases in one project.",
2492
+ "",
2493
+ "Returns: { items: ManagedResource[] } \u2014 id (numeric, for linking), publicId, type, name, status, region, createdAt.",
2494
+ "",
2495
+ 'Example: list_managed_resources() \u2192 { items: [{ id: 42, type: "database", name: "app-db", status: "available" }, { id: 8, type: "object_storage", name: "uploads", \u2026 }] }'
2496
+ ].join("\n"),
2497
+ input: {},
2498
+ handler: async (_args, ctx) => {
2499
+ const teamId = await ctx.resolveTeamId();
2500
+ const response = await ctx.hoststack.serviceResourceLinks.listManagedResources(teamId);
2501
+ const data = shapeList(response, "resources", shape);
2502
+ const byType = /* @__PURE__ */ new Map();
2503
+ for (const item of data.items) {
2504
+ const type = item && typeof item === "object" && "type" in item ? String(item.type) : "unknown";
2505
+ byType.set(type, (byType.get(type) ?? 0) + 1);
2506
+ }
2507
+ const breakdown = [...byType.entries()].map(([t, n]) => `${n} ${t}`).join(", ");
2508
+ const summary = data.items.length === 0 ? "No managed resources on this team yet." : `${data.items.length} managed resource${data.items.length === 1 ? "" : "s"}: ${breakdown}.`;
2509
+ return respond({ summary, data });
2510
+ }
2511
+ });
2512
+ defineTool({
2513
+ name: "list_service_resources",
2514
+ category: "resource-links",
2515
+ description: [
2516
+ "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.",
2517
+ "",
2518
+ '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.',
2519
+ "",
2520
+ INJECTION_CONTRACT,
2521
+ "",
2522
+ "Inputs:",
2523
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
2524
+ "",
2525
+ "Returns: { items: ServiceResourceLink[] } \u2014 id (the linkId), resourceType, resourceId (numeric id of the linked resource), alias, createdAt.",
2526
+ "",
2527
+ 'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
2528
+ ].join("\n"),
2529
+ input: {
2530
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
2531
+ },
2532
+ handler: async (args, ctx) => {
2533
+ const teamId = await ctx.resolveTeamId();
2534
+ const response = await ctx.hoststack.serviceResourceLinks.list(teamId, args.service_id);
2535
+ const data = shapeList(response, "links", shape);
2536
+ const summary = data.items.length === 0 ? `No resources linked to service ${args.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 ${args.service_id}.`;
2537
+ return respond({ summary, data });
2538
+ }
2539
+ });
2540
+ defineTool({
2541
+ name: "link_resource_to_service",
2542
+ category: "resource-links",
2543
+ description: [
2544
+ "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.",
2545
+ "",
2546
+ "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.",
2547
+ "",
2548
+ '*** 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.',
2549
+ "",
2550
+ INJECTION_CONTRACT,
2551
+ "",
2552
+ "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.",
2553
+ "",
2554
+ "Inputs:",
2555
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the consuming service.',
2556
+ ` - resource_type: ${RESOURCE_LINK_TYPES.join(" | ")}.`,
2557
+ ' - resource_id: the NUMERIC id of the resource \u2014 e.g. the `id` field from create_database / list_databases, NOT the "db_\u2026" publicId.',
2558
+ ' - 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.',
2559
+ "",
2560
+ "Returns: { link: ServiceResourceLink } \u2014 including `id`, the linkId used to unlink later.",
2561
+ "",
2562
+ "Fails with 409 if this exact resource is already linked to the service, or if the alias is already taken on it.",
2563
+ "",
2564
+ '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" } }'
2565
+ ].join("\n"),
2566
+ input: {
2567
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
2568
+ resource_type: z13.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
2569
+ resource_id: z13.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
2570
+ alias: z13.string().min(1).max(48).regex(
2571
+ /^[A-Z][A-Z0-9_]*$/,
2572
+ "Alias must be uppercase letters, digits and underscores, starting with a letter."
2573
+ ).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
2574
+ },
2575
+ handler: async (args, ctx) => {
2576
+ const teamId = await ctx.resolveTeamId();
2577
+ const response = await ctx.hoststack.serviceResourceLinks.create(teamId, args.service_id, {
2578
+ resourceType: args.resource_type,
2579
+ resourceId: args.resource_id,
2580
+ alias: args.alias
2581
+ });
2582
+ const data = { link: shape(response.link) };
2583
+ return respond({
2584
+ summary: `Linked ${args.resource_type} ${args.resource_id} to service ${args.service_id} as "${args.alias}". Call trigger_deploy({ service_id: "${args.service_id}" }) to inject it \u2014 the running container will not see the vars until then.`,
2585
+ data
2586
+ });
2587
+ }
2588
+ });
2589
+ defineTool({
2590
+ name: "unlink_resource_from_service",
2591
+ category: "resource-links",
2592
+ description: [
2593
+ "Remove a resource link from a service. The resource itself is NOT deleted \u2014 only the binding.",
2594
+ "",
2595
+ "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.",
2596
+ "",
2597
+ "Inputs:",
2598
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
2599
+ " - link_id: numeric linkId from list_service_resources (the `id` field on the link, not the resource id).",
2600
+ "",
2601
+ "Returns: { ok: true }.",
2602
+ "",
2603
+ 'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
2604
+ ].join("\n"),
2605
+ input: {
2606
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
2607
+ link_id: z13.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
2608
+ },
2609
+ handler: async (args, ctx) => {
2610
+ const teamId = await ctx.resolveTeamId();
2611
+ await ctx.hoststack.serviceResourceLinks.delete(teamId, args.service_id, args.link_id);
2612
+ return respond({
2613
+ summary: `Unlinked resource link ${args.link_id} from service ${args.service_id}. The resource still exists; its env vars vanish on the next deploy.`
2614
+ });
2615
+ }
2616
+ });
2617
+
2618
+ // src/tools/services.ts
2619
+ import { z as z14 } from "zod";
2620
+ var DEV_ENV_IMAGE = "registry.hoststack.dev/hoststack/dev-env:latest";
2102
2621
  var DEV_ENV_VOLUME = { name: "workspace", mountPath: "/workspace", sizeGb: 10 };
2103
2622
  var SERVICE_TYPES = [
2104
2623
  "web_service",
@@ -2111,11 +2630,19 @@ var SERVICE_PLANS = [
2111
2630
  "pico",
2112
2631
  "nano",
2113
2632
  "micro",
2114
- "starter",
2633
+ "small",
2115
2634
  "standard",
2635
+ "large",
2636
+ "xlarge",
2116
2637
  "pro_standard",
2117
2638
  "pro_large"
2118
2639
  ];
2640
+ var DEV_ENV_MIN_SIZE = "standard";
2641
+ function coerceDevEnvSize(value) {
2642
+ const minIdx = SERVICE_PLANS.indexOf(DEV_ENV_MIN_SIZE);
2643
+ const idx = value ? SERVICE_PLANS.indexOf(value) : -1;
2644
+ return idx >= minIdx ? value : DEV_ENV_MIN_SIZE;
2645
+ }
2119
2646
  defineTool({
2120
2647
  name: "list_services",
2121
2648
  category: "services",
@@ -2124,25 +2651,32 @@ defineTool({
2124
2651
  "",
2125
2652
  "When to use: the agent needs to find a service by name, check what is deployed, or pick a target for a follow-up tool (logs, deploys, env vars). This is the canonical way to resolve a publicId from a human-friendly name.",
2126
2653
  "",
2654
+ "Note: agentic Dev Boxes (cloud dev environments) are EXCLUDED by default. Pass dev_environment:true to include them, or use list_dev_environments for the dedicated, annotated dev-box listing.",
2655
+ "",
2127
2656
  "Inputs (all optional):",
2128
2657
  ' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
2129
2658
  ' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
2130
2659
  ' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
2131
2660
  ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
2661
+ " - dev_environment: include Dev Boxes in the results (excluded by default).",
2132
2662
  "",
2133
2663
  "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
2134
2664
  "",
2135
2665
  'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
2136
2666
  ].join("\n"),
2137
2667
  input: {
2138
- project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2139
- environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2140
- status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2141
- type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
2668
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2669
+ environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2670
+ status: z14.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2671
+ type: z14.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
2672
+ dev_environment: z14.boolean().optional().describe(
2673
+ "Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
2674
+ )
2142
2675
  },
2143
2676
  handler: async (args, ctx) => {
2144
2677
  const teamId = await ctx.resolveTeamId();
2145
2678
  const filters = {};
2679
+ if (args.dev_environment) filters.devEnvironment = true;
2146
2680
  if (args.project_id !== void 0) {
2147
2681
  const resolved = await ctx.hoststack.resolveId(args.project_id, {
2148
2682
  kind: "project",
@@ -2165,7 +2699,8 @@ defineTool({
2165
2699
  args.project_id !== void 0 ? `project=${args.project_id}` : null,
2166
2700
  args.environment_id !== void 0 ? `env=${args.environment_id}` : null,
2167
2701
  args.status ? `status=${args.status}` : null,
2168
- args.type ? `type=${args.type}` : null
2702
+ args.type ? `type=${args.type}` : null,
2703
+ args.dev_environment ? "devBoxes=included" : null
2169
2704
  ].filter(Boolean).join(", ");
2170
2705
  const summary = data.items.length === 0 ? filterDesc ? `No services match the given filters (${filterDesc}).` : "No services yet \u2014 create one with create_service (or create_dev_environment for an AI dev box), or via the dashboard." : `Found ${data.items.length} service${data.items.length === 1 ? "" : "s"}${filterDesc ? ` matching ${filterDesc}` : ""}.`;
2171
2706
  return respond({ summary, data });
@@ -2179,6 +2714,8 @@ defineTool({
2179
2714
  "",
2180
2715
  "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).",
2181
2716
  "",
2717
+ '*** 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.',
2718
+ "",
2182
2719
  "Inputs:",
2183
2720
  ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
2184
2721
  " - name: service name (1\u2013100 chars).",
@@ -2199,21 +2736,23 @@ defineTool({
2199
2736
  'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
2200
2737
  ].join("\n"),
2201
2738
  input: {
2202
- project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2203
- name: z13.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
2204
- type: z13.enum(SERVICE_TYPES).describe("Service type."),
2205
- docker_image: z13.string().max(500).optional().describe("Pre-built image ref. Mutually exclusive with github_repo_id."),
2206
- github_repo_id: z13.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
2207
- branch: z13.string().max(200).optional().describe('Git branch (default "main").'),
2208
- install_command: z13.string().max(1e3).optional().describe("Install shell command."),
2209
- build_command: z13.string().max(1e3).optional().describe("Build shell command."),
2210
- start_command: z13.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
2211
- cron_schedule: z13.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
2212
- publish_path: z13.string().max(500).optional().describe("Static-site output dir."),
2213
- runtime: z13.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
2214
- plan: z13.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2215
- environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
2216
- auto_deploy: z13.boolean().optional().describe("Trigger the first deploy immediately (default true).")
2739
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
2740
+ name: z14.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
2741
+ type: z14.enum(SERVICE_TYPES).describe("Service type."),
2742
+ docker_image: z14.string().max(500).optional().describe(
2743
+ "Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
2744
+ ),
2745
+ github_repo_id: z14.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
2746
+ branch: z14.string().max(200).optional().describe('Git branch (default "main").'),
2747
+ install_command: z14.string().max(1e3).optional().describe("Install shell command."),
2748
+ build_command: z14.string().max(1e3).optional().describe("Build shell command."),
2749
+ start_command: z14.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
2750
+ cron_schedule: z14.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
2751
+ publish_path: z14.string().max(500).optional().describe("Static-site output dir."),
2752
+ runtime: z14.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
2753
+ plan: z14.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2754
+ environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
2755
+ auto_deploy: z14.boolean().optional().describe("Trigger the first deploy immediately (default true).")
2217
2756
  },
2218
2757
  handler: async (args, ctx) => {
2219
2758
  const teamId = await ctx.resolveTeamId();
@@ -2256,14 +2795,16 @@ defineTool({
2256
2795
  name: "create_dev_environment",
2257
2796
  category: "services",
2258
2797
  description: [
2259
- "Spin up an AI dev environment in one call: a private service from the agentic dev-env image (Claude Code + Codex + OpenCode + MCPs preinstalled), with a persistent /workspace volume and the MCP API keys set, then fire the first deploy.",
2798
+ 'Spin up an AI Dev Box in one call: a cloud "Dev Box" (an agentic container from the dev-env image \u2014 Claude Code + Codex + OpenCode + MCPs preinstalled) inside a project, with a persistent /workspace volume and the MCP API keys set, then fire the first deploy.',
2260
2799
  "",
2261
- `When to use: the user wants a cloud terminal / dev box they can drive coding agents in (from a desk or a phone). This mirrors the dashboard's "AI Dev Environment" wizard preset \u2014 create (deploy deferred) \u2192 set keys \u2192 attach /workspace \u2192 deploy, so the first container boots with its volume and keys already in place.`,
2800
+ 'This makes a cloud Dev Box, NOT a project deploy environment. A project "environment" (create_environment \u2192 Production / Staging / Preview) is a deployment target; this is a workspace you code in. If you actually want a -dev deploy target, use create_environment instead.',
2801
+ "",
2802
+ `When to use: the user wants a cloud terminal / Dev Box they can drive coding agents in (from a desk or a phone). This mirrors the dashboard's "AI Dev Environment" wizard preset \u2014 create (deploy deferred) \u2192 set keys \u2192 attach /workspace \u2192 deploy, so the first container boots with its volume and keys already in place.`,
2262
2803
  "",
2263
2804
  "Inputs:",
2264
2805
  ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
2265
2806
  ' - name (optional): service name (default "dev-environment").',
2266
- ' - plan (optional): service size (default "standard" \u2014 2 GB, the smallest that fits a coding agent + build).',
2807
+ ' - plan (optional): box size. Defaults to "standard" (2 GB) \u2014 the OOM-safe floor (DEV_ENV_MIN_SIZE); a coding agent + build OOMs below it. A smaller plan is clamped up to "standard".',
2267
2808
  " - disk_gb (optional): /workspace volume size in GB (default 10, 1\u2013100).",
2268
2809
  " - hoststack_api_key (optional): sets HOSTSTACK_API_KEY so the hoststack MCP works inside the container.",
2269
2810
  " - poststack_api_key (optional): sets POSTSTACK_API_KEY so the poststack MCP works inside the container.",
@@ -2274,18 +2815,18 @@ defineTool({
2274
2815
  'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
2275
2816
  ].join("\n"),
2276
2817
  input: {
2277
- project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2278
- name: z13.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2279
- plan: z13.enum(SERVICE_PLANS).optional().describe(
2280
- 'Service size (default "standard" \u2014 2 GB; smallest that fits a coding agent).'
2818
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
2819
+ name: z14.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2820
+ plan: z14.enum(SERVICE_PLANS).optional().describe(
2821
+ 'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
2281
2822
  ),
2282
- disk_gb: z13.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2283
- hoststack_api_key: z13.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2284
- poststack_api_key: z13.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
2285
- repo_url: z13.string().max(500).optional().describe(
2823
+ disk_gb: z14.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2824
+ hoststack_api_key: z14.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2825
+ poststack_api_key: z14.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
2826
+ repo_url: z14.string().max(500).optional().describe(
2286
2827
  "Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
2287
2828
  ),
2288
- branch: z13.string().max(200).optional().describe("Branch to clone (with repo_url).")
2829
+ branch: z14.string().max(200).optional().describe("Branch to clone (with repo_url).")
2289
2830
  },
2290
2831
  handler: async (args, ctx) => {
2291
2832
  const teamId = await ctx.resolveTeamId();
@@ -2295,68 +2836,94 @@ defineTool({
2295
2836
  });
2296
2837
  const name = args.name ?? "dev-environment";
2297
2838
  const sizeGb = args.disk_gb ?? DEV_ENV_VOLUME.sizeGb;
2839
+ const plan = coerceDevEnvSize(args.plan);
2298
2840
  const createInput = {
2299
2841
  name,
2300
2842
  type: "private_service",
2301
2843
  projectId,
2302
2844
  dockerImage: DEV_ENV_IMAGE,
2303
- autoDeploy: false
2845
+ autoDeploy: false,
2846
+ plan
2304
2847
  };
2305
- if (args.plan !== void 0) createInput.plan = args.plan;
2306
2848
  const created = await ctx.hoststack.services.create(teamId, createInput);
2307
2849
  const service = created.service;
2308
- const envVars = [];
2309
- if (args.hoststack_api_key)
2310
- envVars.push({
2311
- key: "HOSTSTACK_API_KEY",
2312
- value: args.hoststack_api_key,
2313
- isSecret: true
2314
- });
2315
- if (args.poststack_api_key)
2316
- envVars.push({
2317
- key: "POSTSTACK_API_KEY",
2318
- value: args.poststack_api_key,
2319
- isSecret: true
2320
- });
2321
- if (args.repo_url) {
2322
- envVars.push({
2323
- key: "HOSTSTACK_DEVENV_REPO_URL",
2324
- value: args.repo_url,
2325
- isSecret: false
2326
- });
2327
- if (args.branch)
2850
+ const rollback = async () => {
2851
+ try {
2852
+ await ctx.hoststack.services.tearDownDevEnvironment(teamId, service.id);
2853
+ } catch {
2854
+ try {
2855
+ await ctx.hoststack.services.delete(teamId, service.id);
2856
+ } catch {
2857
+ }
2858
+ }
2859
+ };
2860
+ try {
2861
+ const envVars = [];
2862
+ if (args.hoststack_api_key)
2328
2863
  envVars.push({
2329
- key: "HOSTSTACK_DEVENV_BRANCH",
2330
- value: args.branch,
2864
+ key: "HOSTSTACK_API_KEY",
2865
+ value: args.hoststack_api_key,
2866
+ isSecret: true
2867
+ });
2868
+ if (args.poststack_api_key)
2869
+ envVars.push({
2870
+ key: "POSTSTACK_API_KEY",
2871
+ value: args.poststack_api_key,
2872
+ isSecret: true
2873
+ });
2874
+ if (args.repo_url) {
2875
+ envVars.push({
2876
+ key: "HOSTSTACK_DEVENV_REPO_URL",
2877
+ value: args.repo_url,
2331
2878
  isSecret: false
2332
2879
  });
2333
- }
2334
- if (envVars.length > 0) {
2335
- await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2336
- }
2337
- let volumeAttached = false;
2338
- try {
2339
- await ctx.hoststack.volumes.create(teamId, service.id, {
2340
- name: DEV_ENV_VOLUME.name,
2341
- mountPath: DEV_ENV_VOLUME.mountPath,
2342
- sizeGb
2880
+ if (args.branch)
2881
+ envVars.push({
2882
+ key: "HOSTSTACK_DEVENV_BRANCH",
2883
+ value: args.branch,
2884
+ isSecret: false
2885
+ });
2886
+ }
2887
+ if (envVars.length > 0) {
2888
+ await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2889
+ }
2890
+ try {
2891
+ await ctx.hoststack.volumes.create(teamId, service.id, {
2892
+ name: DEV_ENV_VOLUME.name,
2893
+ mountPath: DEV_ENV_VOLUME.mountPath,
2894
+ sizeGb
2895
+ });
2896
+ } catch (error) {
2897
+ const reason = error instanceof Error ? error.message : String(error);
2898
+ await rollback();
2899
+ return respondError(
2900
+ `Failed to attach the /workspace volume for Dev Box "${name}" \u2014 aborted and rolled back rather than deploy a box that would lose all work on recreate. Volume error: ${reason}`,
2901
+ { volumeError: reason, rolledBack: true }
2902
+ );
2903
+ }
2904
+ const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2905
+ const deployId = deploy.deploy?.id ?? null;
2906
+ return respond({
2907
+ summary: `Created AI Dev Box "${name}" (${service.publicId}) with a /workspace volume \u2014 deploying. Open it in the dashboard's Dev Boxes section (or the service's Terminal tab) once running.`,
2908
+ data: { service: shapeService(service), volumeAttached: true, deployId }
2343
2909
  });
2344
- volumeAttached = true;
2345
- } catch {
2910
+ } catch (error) {
2911
+ const reason = error instanceof Error ? error.message : String(error);
2912
+ await rollback();
2913
+ return respondError(
2914
+ `Failed to provision Dev Box "${name}" \u2014 rolled back the partially-created box. Error: ${reason}`,
2915
+ { rolledBack: true }
2916
+ );
2346
2917
  }
2347
- const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2348
- const deployId = deploy.deploy?.id ?? null;
2349
- return respond({
2350
- summary: `Created AI dev environment "${name}" (${service.publicId})${volumeAttached ? " with a /workspace volume" : ""} \u2014 deploying. Open it in the dashboard's Development section (or the service's Terminal tab) once running.`,
2351
- data: { service: shapeService(service), volumeAttached, deployId }
2352
- });
2353
2918
  }
2354
2919
  });
2355
2920
  defineTool({
2356
2921
  name: "spin_up_dev_environment",
2357
2922
  category: "services",
2358
2923
  description: [
2359
- "Spin up an agentic dev environment FROM an existing service so you can reproduce a bug, fix it, view it, and ship it. Creates a dev box (Claude/Codex/OpenCode + MCPs) that RUNS a clone of the app: the repo is auto-cloned into /workspace, the service env-vars are copied, and its linked database is cloned (so the box never touches the prod DB). The box gets an unguessable public dev URL and seamless `git push`.",
2924
+ "Spin up an agentic Dev Box FROM an existing service so you can reproduce a bug, fix it, view it, and ship it. Creates a cloud Dev Box (Claude/Codex/OpenCode + MCPs) that RUNS a clone of the app: the repo is auto-cloned into /workspace, the service env-vars are copied, and its linked database is cloned (so the box never touches the prod DB). The box gets an unguessable public dev URL and seamless `git push`.",
2925
+ "",
2926
+ "This makes a cloud Dev Box, NOT a project deploy environment \u2014 for a -dev/-staging deploy target use create_environment instead.",
2360
2927
  "",
2361
2928
  'When to use: "spin up a dev environment for <service>", "I found a bug on <service>, give me a box to fix it". Distinct from create_dev_environment, which makes a BARE box not tied to any app.',
2362
2929
  "",
@@ -2370,9 +2937,9 @@ defineTool({
2370
2937
  '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.'
2371
2938
  ].join("\n"),
2372
2939
  input: {
2373
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
2374
- include_database_clone: z13.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
2375
- name: z13.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
2940
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
2941
+ include_database_clone: z14.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
2942
+ name: z14.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
2376
2943
  },
2377
2944
  handler: async (args, ctx) => {
2378
2945
  const teamId = await ctx.resolveTeamId();
@@ -2399,7 +2966,9 @@ defineTool({
2399
2966
  name: "delete_dev_environment",
2400
2967
  category: "services",
2401
2968
  description: [
2402
- "Tear down an agentic dev environment in one call: removes the dev box AND cascade-deletes its cloned database, its /workspace volume, and the auto-created `development` environment if it is now empty.",
2969
+ "Tear down an agentic Dev Box in one call: removes the cloud Dev Box AND cascade-deletes its cloned database, its /workspace volume, and the auto-created `development` environment if it is now empty.",
2970
+ "",
2971
+ "This deletes a cloud Dev Box, NOT a project deploy environment \u2014 to remove a -dev/-staging deploy target use delete_environment instead.",
2403
2972
  "",
2404
2973
  'When to use: "delete / tear down the dev environment", once you have shipped the fix and no longer need the box.',
2405
2974
  "",
@@ -2411,7 +2980,7 @@ defineTool({
2411
2980
  'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
2412
2981
  ].join("\n"),
2413
2982
  input: {
2414
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
2983
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
2415
2984
  },
2416
2985
  handler: async (args, ctx) => {
2417
2986
  const teamId = await ctx.resolveTeamId();
@@ -2430,23 +2999,27 @@ defineTool({
2430
2999
  name: "resize_dev_environment",
2431
3000
  category: "services",
2432
3001
  description: [
2433
- "Resize a dev box (or any service) to a different size tier \u2014 the supported way to give it more memory/CPU/disk headroom (e.g. when ESLint/tsc OOMs).",
3002
+ "Resize a cloud Dev Box (or any service) to a different size tier \u2014 the supported way to give it more memory/CPU/disk headroom (e.g. when ESLint/tsc OOMs).",
2434
3003
  "",
2435
- "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.",
3004
+ "This resizes a cloud Dev Box, NOT a project deploy environment.",
3005
+ "",
3006
+ "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.",
2436
3007
  "",
2437
3008
  "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.",
2438
3009
  "",
2439
3010
  "Inputs:",
2440
3011
  " - service_id: the box to resize \u2014 numeric id or publicId.",
2441
- ' - size: target tier (e.g. "standard", "large", "xlarge").',
3012
+ ' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
2442
3013
  "",
2443
3014
  "Returns: { service } with the new plan.",
2444
3015
  "",
2445
3016
  'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
2446
3017
  ].join("\n"),
2447
3018
  input: {
2448
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The box to resize \u2014 numeric id or publicId."),
2449
- size: z13.string().min(1).describe('Target size tier, e.g. "standard", "large", "xlarge".')
3019
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The box to resize \u2014 numeric id or publicId."),
3020
+ size: z14.enum(SERVICE_PLANS).describe(
3021
+ 'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
3022
+ )
2450
3023
  },
2451
3024
  handler: async (args, ctx) => {
2452
3025
  const teamId = await ctx.resolveTeamId();
@@ -2465,7 +3038,9 @@ defineTool({
2465
3038
  name: "list_dev_environments",
2466
3039
  category: "services",
2467
3040
  description: [
2468
- `List the team's dev environments (the dashboard "Development" section), each annotated with the companion services attached to it.`,
3041
+ `List the team's cloud Dev Boxes (the dashboard "Dev Boxes" / Development section), each annotated with the companion services attached to it.`,
3042
+ "",
3043
+ "These are cloud Dev Boxes, NOT project deploy environments \u2014 to list a project's deploy targets (Production / Staging / Preview) use list_environments instead.",
2469
3044
  "",
2470
3045
  `When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
2471
3046
  "",
@@ -2492,22 +3067,62 @@ defineTool({
2492
3067
  });
2493
3068
  }
2494
3069
  });
3070
+ defineTool({
3071
+ name: "list_templates",
3072
+ category: "services",
3073
+ description: [
3074
+ "List the canonical Dev Box (AI dev environment) preset so an agent can see, without guessing, what a `create_standalone_dev_environment` / `create_dev_environment` actually provisions: the container image, the persistent /workspace volume (mount + default size), the OOM-safe size floor, and the companion database options.",
3075
+ "",
3076
+ "When to use: before creating a Dev Box, to confirm the image, the default /workspace size, the minimum plan, or which companion engines can be attached \u2014 instead of hand-duplicating those constants.",
3077
+ "",
3078
+ 'Returns: { templates: [{ id, name, image, volume: { name, mountPath, sizeGb }, minPlan, companions }] }. Today there is one preset ("dev-environment").',
3079
+ "",
3080
+ '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"] }] }'
3081
+ ].join("\n"),
3082
+ input: {},
3083
+ handler: async () => {
3084
+ const templates = [
3085
+ {
3086
+ id: "dev-environment",
3087
+ name: "AI Dev Environment",
3088
+ image: DEV_ENV_IMAGE,
3089
+ volume: {
3090
+ name: DEV_ENV_VOLUME.name,
3091
+ mountPath: DEV_ENV_VOLUME.mountPath,
3092
+ sizeGb: DEV_ENV_VOLUME.sizeGb
3093
+ },
3094
+ /** OOM-safe plan floor — a coding agent + build needs at least this. */
3095
+ minPlan: DEV_ENV_MIN_SIZE,
3096
+ /** Companion engines you can attach (fresh + empty), like local `make db-up`. */
3097
+ companions: ["postgres", "redis", "meilisearch"]
3098
+ }
3099
+ ];
3100
+ return respond({
3101
+ summary: `${templates.length} Dev Box template${templates.length === 1 ? "" : "s"} available (image ${DEV_ENV_IMAGE}, /workspace ${DEV_ENV_VOLUME.sizeGb} GB, floor "${DEV_ENV_MIN_SIZE}").`,
3102
+ data: { templates }
3103
+ });
3104
+ }
3105
+ });
2495
3106
  defineTool({
2496
3107
  name: "create_standalone_dev_environment",
2497
3108
  category: "services",
2498
3109
  description: [
2499
- "Create a STANDALONE dev environment in one call: a cloud box (Claude Code + Codex + OpenCode + MCPs) on a persistent /workspace, from a connected GitHub repo, an arbitrary clone URL, or blank \u2014 with optional companion Postgres / Redis / Meilisearch wired into its env (mirrors a local `make db-up`). The box lives in the team's hidden Development home, NOT under a project.",
3110
+ "Create a STANDALONE Dev Box in one call: a cloud box (Claude Code + Codex + OpenCode + MCPs) on a persistent /workspace, from a connected GitHub repo, an arbitrary clone URL, or blank \u2014 with optional companion Postgres / Redis / Meilisearch wired into its env (mirrors a local `make db-up`). The box lives in the team's hidden Development home, NOT under a project.",
3111
+ "",
3112
+ "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.",
3113
+ "",
3114
+ '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.',
2500
3115
  "",
2501
3116
  '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).',
2502
3117
  "",
2503
3118
  "Inputs:",
2504
- " - name: the dev box name.",
3119
+ ' - 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.',
2505
3120
  ' - 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).',
2506
3121
  " - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
2507
3122
  " - clone_url (for url): an http(s) git clone URL.",
2508
3123
  " - branch (optional): branch to clone.",
2509
3124
  ' - databases (optional): companion services to attach \u2014 any of "postgres", "redis", "meilisearch".',
2510
- ' - plan (optional): box size (default "micro").',
3125
+ ' - plan (optional): box size. Defaults to "standard" (2 GB) \u2014 the OOM-safe floor (DEV_ENV_MIN_SIZE); a smaller plan is floored up to "standard" server-side.',
2511
3126
  " - agent_accounts (optional): bind specific saved agent logins by account id per provider. OMIT to auto-inherit the box owner's default logins (claude/codex/opencode), so `claude` is already authenticated on first boot \u2014 no manual login.",
2512
3127
  "",
2513
3128
  "Returns: { service, devUrl, deployId } \u2014 deploying. Once live: open the Terminal tab, run `claude`, start the dev server on $PORT, view at https://<devUrl>. Tear down with delete_dev_environment.",
@@ -2515,17 +3130,21 @@ defineTool({
2515
3130
  'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
2516
3131
  ].join("\n"),
2517
3132
  input: {
2518
- name: z13.string().min(1).max(100).describe("Dev box name."),
2519
- source_kind: z13.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
2520
- github_repo_id: z13.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
2521
- clone_url: z13.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
2522
- branch: z13.string().min(1).max(255).optional().describe("Branch to clone."),
2523
- databases: z13.array(z13.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
2524
- plan: z13.enum(SERVICE_PLANS).optional().describe('Box size (default "micro").'),
2525
- agent_accounts: z13.array(
2526
- z13.object({
2527
- provider: z13.enum(["claude", "codex", "opencode"]),
2528
- account_id: z13.number().int().positive()
3133
+ name: z14.string().min(1).max(100).optional().describe(
3134
+ '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.'
3135
+ ),
3136
+ source_kind: z14.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
3137
+ github_repo_id: z14.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
3138
+ clone_url: z14.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
3139
+ branch: z14.string().min(1).max(255).optional().describe("Branch to clone."),
3140
+ databases: z14.array(z14.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
3141
+ plan: z14.enum(SERVICE_PLANS).optional().describe(
3142
+ 'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
3143
+ ),
3144
+ agent_accounts: z14.array(
3145
+ z14.object({
3146
+ provider: z14.enum(["claude", "codex", "opencode"]),
3147
+ account_id: z14.number().int().positive()
2529
3148
  })
2530
3149
  ).max(3).optional().describe(
2531
3150
  "Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
@@ -2562,7 +3181,7 @@ defineTool({
2562
3181
  source = { kind: "blank" };
2563
3182
  }
2564
3183
  const input = {
2565
- name: args.name,
3184
+ ...args.name ? { name: args.name } : {},
2566
3185
  source,
2567
3186
  ...args.databases ? { databases: args.databases } : {},
2568
3187
  ...args.plan ? { plan: args.plan } : {},
@@ -2595,12 +3214,12 @@ defineTool({
2595
3214
  "Inputs:",
2596
3215
  ' - service_id: publicId of the service (e.g. "svc_abc123").',
2597
3216
  "",
2598
- "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.",
3217
+ '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.',
2599
3218
  "",
2600
3219
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
2601
3220
  ].join("\n"),
2602
3221
  input: {
2603
- service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
3222
+ service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
2604
3223
  },
2605
3224
  handler: async (args, ctx) => {
2606
3225
  const teamId = await ctx.resolveTeamId();
@@ -2632,7 +3251,7 @@ defineTool({
2632
3251
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
2633
3252
  ].join("\n"),
2634
3253
  input: {
2635
- service_id: z13.string().describe("Service publicId.")
3254
+ service_id: z14.string().describe("Service publicId.")
2636
3255
  },
2637
3256
  handler: async (args, ctx) => {
2638
3257
  const teamId = await ctx.resolveTeamId();
@@ -2661,9 +3280,9 @@ defineTool({
2661
3280
  'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
2662
3281
  ].join("\n"),
2663
3282
  input: {
2664
- service_id: z13.string().describe("Service publicId."),
2665
- from: z13.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
2666
- to: z13.string().optional().describe("ISO-8601 upper bound; defaults to now.")
3283
+ service_id: z14.string().describe("Service publicId."),
3284
+ from: z14.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
3285
+ to: z14.string().optional().describe("ISO-8601 upper bound; defaults to now.")
2667
3286
  },
2668
3287
  handler: async (args, ctx) => {
2669
3288
  const teamId = await ctx.resolveTeamId();
@@ -2707,8 +3326,8 @@ defineTool({
2707
3326
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
2708
3327
  ].join("\n"),
2709
3328
  input: {
2710
- service_id: z13.string().describe("Service publicId."),
2711
- name: z13.string().min(1).max(60).describe("New service name (1\u201360 chars).")
3329
+ service_id: z14.string().describe("Service publicId."),
3330
+ name: z14.string().min(1).max(60).describe("New service name (1\u201360 chars).")
2712
3331
  },
2713
3332
  handler: async (args, ctx) => {
2714
3333
  const teamId = await ctx.resolveTeamId();
@@ -2745,6 +3364,7 @@ defineTool({
2745
3364
  " - port (optional): integer 1\u201365535 \u2014 container port the platform forwards traffic to.",
2746
3365
  ' - protocol (optional): "http" | "tcp".',
2747
3366
  ' - restart_policy (optional): "always" | "on-failure" | "no".',
3367
+ ` - 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.`,
2748
3368
  " - pre_deploy_command (optional): shell command run before the new release accepts traffic (typical use: migrations).",
2749
3369
  " - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
2750
3370
  " - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
@@ -2756,37 +3376,40 @@ defineTool({
2756
3376
  'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
2757
3377
  ].join("\n"),
2758
3378
  input: {
2759
- service_id: z13.string().describe("Service publicId."),
2760
- install_command: z13.string().nullable().optional().describe("Install shell command. Null clears."),
2761
- build_command: z13.string().nullable().optional().describe("Build shell command. Null clears."),
2762
- start_command: z13.string().nullable().optional().describe("Start shell command. Null clears."),
2763
- branch: z13.string().optional().describe("Git branch to track."),
2764
- root_directory: z13.string().optional().describe("Build context root."),
2765
- dockerfile_path: z13.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
2766
- auto_deploy: z13.boolean().optional().describe("Auto-deploy on push."),
2767
- health_check_path: z13.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
2768
- health_check_enabled: z13.boolean().optional().describe("Toggle health checking on/off."),
2769
- health_check_interval: z13.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
2770
- health_check_timeout: z13.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
2771
- health_check_grace_period_sec: z13.number().int().min(1).max(1800).optional().describe(
3379
+ service_id: z14.string().describe("Service publicId."),
3380
+ install_command: z14.string().nullable().optional().describe("Install shell command. Null clears."),
3381
+ build_command: z14.string().nullable().optional().describe("Build shell command. Null clears."),
3382
+ start_command: z14.string().nullable().optional().describe("Start shell command. Null clears."),
3383
+ branch: z14.string().optional().describe("Git branch to track."),
3384
+ root_directory: z14.string().optional().describe("Build context root."),
3385
+ dockerfile_path: z14.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
3386
+ auto_deploy: z14.boolean().optional().describe("Auto-deploy on push."),
3387
+ health_check_path: z14.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
3388
+ health_check_enabled: z14.boolean().optional().describe("Toggle health checking on/off."),
3389
+ health_check_interval: z14.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
3390
+ health_check_timeout: z14.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
3391
+ health_check_grace_period_sec: z14.number().int().min(1).max(1800).optional().describe(
2772
3392
  "Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
2773
3393
  ),
2774
- memory_mb: z13.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
2775
- cpu_shares: z13.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
2776
- disk_size_gb: z13.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
2777
- port: z13.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
2778
- protocol: z13.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
2779
- restart_policy: z13.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
2780
- pre_deploy_command: z13.string().optional().describe("Shell command run before the new release accepts traffic."),
2781
- instance_count: z13.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
2782
- min_instances: z13.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
2783
- max_instances: z13.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
2784
- scale_cpu_threshold: z13.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
2785
- scale_memory_threshold: z13.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
2786
- log_filter_rules: z13.array(
2787
- z13.object({
2788
- pattern: z13.string().min(1).max(200),
2789
- action: z13.enum(["drop", "downgrade"])
3394
+ memory_mb: z14.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
3395
+ cpu_shares: z14.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
3396
+ disk_size_gb: z14.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
3397
+ port: z14.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
3398
+ protocol: z14.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
3399
+ restart_policy: z14.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
3400
+ deploy_strategy: z14.enum(["rolling", "recreate"]).optional().describe(
3401
+ '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.'
3402
+ ),
3403
+ pre_deploy_command: z14.string().optional().describe("Shell command run before the new release accepts traffic."),
3404
+ instance_count: z14.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
3405
+ min_instances: z14.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
3406
+ max_instances: z14.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
3407
+ scale_cpu_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
3408
+ scale_memory_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
3409
+ log_filter_rules: z14.array(
3410
+ z14.object({
3411
+ pattern: z14.string().min(1).max(200),
3412
+ action: z14.enum(["drop", "downgrade"])
2790
3413
  })
2791
3414
  ).max(50).optional().describe(
2792
3415
  "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
@@ -2821,6 +3444,8 @@ defineTool({
2821
3444
  if (args.port !== void 0) configUpdate["port"] = args.port;
2822
3445
  if (args.protocol !== void 0) configUpdate["protocol"] = args.protocol;
2823
3446
  if (args.restart_policy !== void 0) configUpdate["restartPolicy"] = args.restart_policy;
3447
+ if (args.deploy_strategy !== void 0)
3448
+ configUpdate["deployStrategy"] = args.deploy_strategy;
2824
3449
  if (args.pre_deploy_command !== void 0)
2825
3450
  configUpdate["preDeployCommand"] = args.pre_deploy_command;
2826
3451
  if (args.instance_count !== void 0) {
@@ -2881,7 +3506,7 @@ defineTool({
2881
3506
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
2882
3507
  ].join("\n"),
2883
3508
  input: {
2884
- service_id: z13.string().describe("Service publicId.")
3509
+ service_id: z14.string().describe("Service publicId.")
2885
3510
  },
2886
3511
  handler: async (args, ctx) => {
2887
3512
  const teamId = await ctx.resolveTeamId();
@@ -2905,7 +3530,7 @@ defineTool({
2905
3530
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
2906
3531
  ].join("\n"),
2907
3532
  input: {
2908
- service_id: z13.string().describe("Service publicId.")
3533
+ service_id: z14.string().describe("Service publicId.")
2909
3534
  },
2910
3535
  handler: async (args, ctx) => {
2911
3536
  const teamId = await ctx.resolveTeamId();
@@ -2913,6 +3538,35 @@ defineTool({
2913
3538
  return respond({ summary: `Resumed service ${args.service_id}.`, data: { ok: true } });
2914
3539
  }
2915
3540
  });
3541
+ defineTool({
3542
+ name: "delete_service",
3543
+ category: "services",
3544
+ description: [
3545
+ "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.",
3546
+ "",
3547
+ "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.",
3548
+ "",
3549
+ "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.",
3550
+ "",
3551
+ "Inputs:",
3552
+ " - service_id: publicId of the service to delete.",
3553
+ "",
3554
+ "Returns: { ok: true }.",
3555
+ "",
3556
+ 'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
3557
+ ].join("\n"),
3558
+ input: {
3559
+ service_id: z14.string().describe("Service publicId.")
3560
+ },
3561
+ handler: async (args, ctx) => {
3562
+ const teamId = await ctx.resolveTeamId();
3563
+ await ctx.hoststack.services.delete(teamId, args.service_id);
3564
+ return respond({
3565
+ summary: `Deleted service ${args.service_id} and its attached resources. This cannot be undone.`,
3566
+ data: { ok: true }
3567
+ });
3568
+ }
3569
+ });
2916
3570
  defineTool({
2917
3571
  name: "get_service_logs",
2918
3572
  category: "logs",
@@ -2939,16 +3593,16 @@ defineTool({
2939
3593
  ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
2940
3594
  ].join("\n"),
2941
3595
  input: {
2942
- service_id: z13.string().describe("Service publicId."),
2943
- lines: z13.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
2944
- since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2945
- until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
2946
- stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
2947
- level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
3596
+ service_id: z14.string().describe("Service publicId."),
3597
+ lines: z14.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
3598
+ since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3599
+ until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3600
+ stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3601
+ level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
2948
3602
  "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)."
2949
3603
  ),
2950
- search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
2951
- count_only: z13.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
3604
+ search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
3605
+ count_only: z14.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
2952
3606
  },
2953
3607
  handler: async (args, ctx) => {
2954
3608
  const teamId = await ctx.resolveTeamId();
@@ -2997,14 +3651,14 @@ defineTool({
2997
3651
  '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 } } }.'
2998
3652
  ].join("\n"),
2999
3653
  input: {
3000
- service_ids: z13.array(z13.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
3001
- lines_per_service: z13.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
3002
- since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3003
- until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3004
- stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3005
- level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3006
- search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
3007
- count_only: z13.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3654
+ service_ids: z14.array(z14.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
3655
+ lines_per_service: z14.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
3656
+ since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3657
+ until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3658
+ stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3659
+ level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3660
+ search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
3661
+ count_only: z14.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3008
3662
  },
3009
3663
  handler: async (args, ctx) => {
3010
3664
  const teamId = await ctx.resolveTeamId();
@@ -3050,7 +3704,8 @@ defineTool({
3050
3704
  });
3051
3705
 
3052
3706
  // src/tools/volumes.ts
3053
- import { z as z14 } from "zod";
3707
+ import { z as z15 } from "zod";
3708
+ var MIN_VOLUME_SIZE_GB = 10;
3054
3709
  defineTool({
3055
3710
  name: "list_volumes",
3056
3711
  category: "volumes",
@@ -3067,7 +3722,7 @@ defineTool({
3067
3722
  'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
3068
3723
  ].join("\n"),
3069
3724
  input: {
3070
- service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
3725
+ service_id: z15.string().describe("Service publicId (e.g. svc_abc123).")
3071
3726
  },
3072
3727
  handler: async (args, ctx) => {
3073
3728
  const teamId = await ctx.resolveTeamId();
@@ -3089,17 +3744,24 @@ defineTool({
3089
3744
  " - service_id: publicId of the service to attach to.",
3090
3745
  " - name: lowercase alphanumeric + hyphens, \u226464 chars (used as the docker volume identifier \u2014 change with care once data is written).",
3091
3746
  ' - mount_path: in-container absolute path (e.g. "/var/data").',
3092
- " - size_gb: optional, 1\u2013100, default 1. Counts against your plan storage quota and is metered for billing.",
3747
+ " - 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.",
3093
3748
  "",
3094
- "Returns: { volume: Volume } \u2014 the created record.",
3749
+ '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.',
3095
3750
  "",
3096
- '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" } }'
3751
+ '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" } }'
3097
3752
  ].join("\n"),
3098
3753
  input: {
3099
- service_id: z14.string().describe("Service publicId."),
3100
- name: z14.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
3101
- mount_path: z14.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
3102
- size_gb: z14.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
3754
+ service_id: z15.string().describe("Service publicId."),
3755
+ name: z15.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
3756
+ mount_path: z15.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
3757
+ // 10 GB is the real floor: the block-storage backend rejects anything
3758
+ // smaller. Advertising 1 GB here (and defaulting to it) meant taking the
3759
+ // defaults produced a volume that provisioned with `Hetzner API error:
3760
+ // 422` on the NEXT deploy, with nothing tying the failure back to the
3761
+ // size. Reject it at the call instead.
3762
+ size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
3763
+ `Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
3764
+ )
3103
3765
  },
3104
3766
  handler: async (args, ctx) => {
3105
3767
  const teamId = await ctx.resolveTeamId();
@@ -3111,7 +3773,7 @@ defineTool({
3111
3773
  const response = await ctx.hoststack.volumes.create(teamId, args.service_id, input);
3112
3774
  const data = { volume: shape(response.volume) };
3113
3775
  return respond({
3114
- summary: `Attached volume "${args.name}" (${args.size_gb ?? 1}GB) at ${args.mount_path} on service ${args.service_id}.`,
3776
+ summary: `Attached volume "${args.name}" (${response.volume.sizeGb}GB) at ${args.mount_path} on service ${args.service_id}. Status is "${response.volume.status}" \u2014 deploy the service to provision and mount it.`,
3115
3777
  data
3116
3778
  });
3117
3779
  }
@@ -3135,10 +3797,10 @@ defineTool({
3135
3797
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
3136
3798
  ].join("\n"),
3137
3799
  input: {
3138
- service_id: z14.string().describe("Service publicId."),
3139
- volume_id: z14.string().describe("Volume publicId (e.g. vol_\u2026)."),
3140
- mount_path: z14.string().startsWith("/").max(500).optional().describe("New mount path."),
3141
- size_gb: z14.number().int().min(1).max(100).optional().describe("New size in GB.")
3800
+ service_id: z15.string().describe("Service publicId."),
3801
+ volume_id: z15.string().describe("Volume publicId (e.g. vol_\u2026)."),
3802
+ mount_path: z15.string().startsWith("/").max(500).optional().describe("New mount path."),
3803
+ 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).`)
3142
3804
  },
3143
3805
  handler: async (args, ctx) => {
3144
3806
  const teamId = await ctx.resolveTeamId();
@@ -3176,8 +3838,8 @@ defineTool({
3176
3838
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
3177
3839
  ].join("\n"),
3178
3840
  input: {
3179
- service_id: z14.string().describe("Service publicId."),
3180
- volume_id: z14.string().describe("Volume publicId.")
3841
+ service_id: z15.string().describe("Service publicId."),
3842
+ volume_id: z15.string().describe("Volume publicId.")
3181
3843
  },
3182
3844
  handler: async (args, ctx) => {
3183
3845
  const teamId = await ctx.resolveTeamId();
@@ -3191,7 +3853,7 @@ defineTool({
3191
3853
 
3192
3854
  // src/server-factory.ts
3193
3855
  var PACKAGE_NAME = "hoststack";
3194
- var PACKAGE_VERSION = "0.9.1";
3856
+ var PACKAGE_VERSION = MCP_VERSION;
3195
3857
  function createMcpServer(options) {
3196
3858
  const baseUrl = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
3197
3859
  const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl });