@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.
@@ -7,6 +7,10 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { HostStack } from "@hoststack.dev/sdk";
9
9
 
10
+ // src/version.ts
11
+ var MCP_VERSION = true ? "0.16.0" : "0.0.0-dev";
12
+ var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
13
+
10
14
  // src/api-client.ts
11
15
  var ApiClient = class {
12
16
  constructor(apiKey2, baseUrl2) {
@@ -16,7 +20,8 @@ var ApiClient = class {
16
20
  get headers() {
17
21
  return {
18
22
  Authorization: `Bearer ${this.apiKey}`,
19
- "Content-Type": "application/json"
23
+ "Content-Type": "application/json",
24
+ "User-Agent": USER_AGENT
20
25
  };
21
26
  }
22
27
  async get(path, params) {
@@ -63,8 +68,8 @@ var ApiClient = class {
63
68
  }
64
69
  async handle(res) {
65
70
  if (!res.ok) {
66
- const error = await res.json().catch(() => ({ error: res.statusText }));
67
- throw new Error(error.error ?? `API error: ${res.status}`);
71
+ const body = await res.json().catch(() => ({ error: res.statusText }));
72
+ throw new Error(formatApiError(body, res.status));
68
73
  }
69
74
  if (res.status === 204) {
70
75
  return void 0;
@@ -72,6 +77,31 @@ var ApiClient = class {
72
77
  return res.json();
73
78
  }
74
79
  };
80
+ function formatApiError(body, status) {
81
+ const fallback = `API error: ${status}`;
82
+ if (!body || typeof body !== "object") return fallback;
83
+ const err = body.error;
84
+ if (typeof err === "string") return err.trim() ? err : fallback;
85
+ const issues = Array.isArray(err) ? err : err && typeof err === "object" && Array.isArray(err.issues) ? err.issues : null;
86
+ if (issues && issues.length > 0) {
87
+ const rendered = issues.map((issue) => {
88
+ if (!issue || typeof issue !== "object") return String(issue);
89
+ const { path, message } = issue;
90
+ const field = Array.isArray(path) && path.length > 0 ? path.join(".") : null;
91
+ const text = typeof message === "string" ? message : "invalid value";
92
+ return field ? `${field}: ${text}` : text;
93
+ }).join("; ");
94
+ return `Invalid request \u2014 ${rendered}`;
95
+ }
96
+ if (err !== void 0) {
97
+ try {
98
+ return `${fallback}: ${JSON.stringify(err)}`;
99
+ } catch {
100
+ return fallback;
101
+ }
102
+ }
103
+ return fallback;
104
+ }
75
105
 
76
106
  // src/prompts/registry.ts
77
107
  var prompts = [];
@@ -134,6 +164,9 @@ function defineTool(def) {
134
164
  handler: def.handler
135
165
  });
136
166
  }
167
+ function listToolDefinitions() {
168
+ return tools;
169
+ }
137
170
  function attachTools(server2, ctx, sink) {
138
171
  const register = server2.tool.bind(server2);
139
172
  for (const def of tools) {
@@ -588,11 +621,176 @@ defineTool({
588
621
 
589
622
  // src/tools/databases.ts
590
623
  import { z as z5 } from "zod";
624
+ var DATABASE_VERSIONS = {
625
+ postgres: { default: "18", supported: ["18", "17", "16", "15"] },
626
+ redis: { default: "8", supported: ["8", "7", "6"] },
627
+ mysql: { default: "8.4", supported: ["8.4", "8.0", "5.7"] },
628
+ mariadb: { default: "11.4", supported: ["11.4", "10.11"] },
629
+ mongodb: { default: "8", supported: ["8", "7", "6"] }
630
+ };
631
+ var DB_ENGINES = ["postgres", "redis", "mysql", "mariadb", "mongodb"];
632
+ var VERSION_HELP = Object.keys(DATABASE_VERSIONS).map(
633
+ (e) => `${e}: ${DATABASE_VERSIONS[e].supported.join("/")} (default ${DATABASE_VERSIONS[e].default})`
634
+ ).join("; ");
635
+ defineTool({
636
+ name: "create_database",
637
+ category: "databases",
638
+ description: [
639
+ "Provision a MANAGED database (Postgres, Redis, MySQL, MariaDB, MongoDB) in a project. This is the correct and ONLY supported way to add a database to a HostStack app.",
640
+ "",
641
+ 'When to use: any time an app needs a datastore \u2014 "add a database", "I need Postgres", "set up Redis", "give this service a DB". Also the right call when scaffolding a new app that will need persistence.',
642
+ "",
643
+ '*** Do NOT hand-roll a database as a service. *** Deploying `postgres:16` (or redis/mysql/mongo) via create_service with a docker_image is NOT how databases work on this platform: it gets no managed backups, no automated version upgrades, no HA/failover path, no credential rotation, no metrics, no persistent volume wired up, and nothing will inject its connection URL into your app. If the user says "add a database", "I need Postgres", "set up Redis" \u2014 call THIS tool.',
644
+ "",
645
+ "How a database reaches your app (the whole flow \u2014 3 calls, no secrets handled):",
646
+ " 1. create_database({ project_id, name, engine }) \u2014 provisions it.",
647
+ ' 2. link_resource_to_service({ service_id, resource_type: "database", resource_id: <database.id>, alias: "APP_DB" }) \u2014 binds it to the service that needs it.',
648
+ " 3. trigger_deploy({ service_id }) \u2014 on that deploy the platform injects the connection info as env vars.",
649
+ "After step 3 the container has `DATABASE_URL` (postgres/mysql/mariadb), `REDIS_URL` (redis) or `MONGO_URL` (mongodb) set automatically, plus alias-prefixed vars. An app reading `process.env.DATABASE_URL` needs zero code changes. You never have to fetch, generate or paste a password.",
650
+ "",
651
+ "Inputs:",
652
+ ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
653
+ " - name: database name (1\u2013100 chars).",
654
+ ` - engine: ${DB_ENGINES.join(" | ")}.`,
655
+ ` - version (optional): engine version. Supported \u2014 ${VERSION_HELP}. Omit to get the default (recommended).`,
656
+ ' - plan (optional): "micro" | "starter" | "standard" | "pro" (default "starter"). "starter" is the smallest always-on tier that includes backups.',
657
+ " - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
658
+ " - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
659
+ " - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
660
+ "",
661
+ 'Returns: { database: Database } \u2014 note BOTH `id` (numeric \u2014 this is what link_resource_to_service wants) and `publicId` ("db_\u2026" \u2014 what the other database tools want).',
662
+ "",
663
+ 'Provisioning is async: the row comes back immediately, usually as `creating`. Poll get_database until `status === "available"` before linking or connecting.',
664
+ "",
665
+ 'Example: create_database({ project_id: "prj_abc", name: "app-db", engine: "postgres" }) \u2192 { database: { id: 42, publicId: "db_\u2026", status: "creating" } }'
666
+ ].join("\n"),
667
+ input: {
668
+ project_id: z5.union([z5.number().int().positive(), z5.string()]).describe('Target project \u2014 numeric id or publicId ("prj_\u2026").'),
669
+ name: z5.string().min(1).max(100).describe("Database name (1\u2013100 chars)."),
670
+ engine: z5.enum(DB_ENGINES).describe("Database engine."),
671
+ version: z5.string().max(20).optional().describe(`Engine version. ${VERSION_HELP}. Omit for the engine default.`),
672
+ plan: z5.enum(["micro", "starter", "standard", "pro"]).optional().describe('Plan tier (memory/CPU). Default "starter".'),
673
+ environment_id: z5.union([z5.number().int().positive(), z5.string()]).optional().describe("Environment to bind to. Defaults to the project Production env."),
674
+ postgis: z5.boolean().optional().describe("Postgres only \u2014 enable the PostGIS extension image."),
675
+ pgvector: z5.boolean().optional().describe(
676
+ "Postgres only \u2014 enable the pgvector extension image. Exclusive with postgis."
677
+ )
678
+ },
679
+ handler: async (args2, ctx) => {
680
+ const teamId = await ctx.resolveTeamId();
681
+ const projectId = await ctx.hoststack.resolveId(args2.project_id, {
682
+ kind: "project",
683
+ teamId
684
+ });
685
+ const input = {
686
+ name: args2.name,
687
+ engine: args2.engine,
688
+ projectId
689
+ };
690
+ if (args2.version !== void 0) input.version = args2.version;
691
+ if (args2.plan !== void 0) input.plan = args2.plan;
692
+ if (args2.environment_id !== void 0) {
693
+ input.environmentId = await ctx.hoststack.resolveId(args2.environment_id, {
694
+ kind: "environment",
695
+ teamId
696
+ });
697
+ }
698
+ if (args2.postgis !== void 0) input.postgis = args2.postgis;
699
+ if (args2.pgvector !== void 0) input.pgvector = args2.pgvector;
700
+ const response = await ctx.hoststack.databases.create(teamId, input);
701
+ const data = { database: shapeDatabase(response.database) };
702
+ const db = response.database;
703
+ return respond({
704
+ summary: `Created ${args2.engine} database "${args2.name}" (${db.publicId ?? "unknown"}, numeric id ${db.id ?? "?"}) \u2014 status ${db.status ?? "creating"}. Poll get_database until status=available, then link_resource_to_service to inject its URL into a service.`,
705
+ data
706
+ });
707
+ }
708
+ });
709
+ defineTool({
710
+ name: "delete_database",
711
+ category: "databases",
712
+ description: [
713
+ "Permanently delete a managed database \u2014 the container, its volume, and ALL data it holds.",
714
+ "",
715
+ 'When to use: the user has explicitly asked to destroy a database they no longer want. This is irreversible and takes the data with it \u2014 confirm with the user before calling, and never call it to "clean up" as a side effect of another task. If the goal is to stop paying for an idle database while keeping the data, use suspend_database instead.',
716
+ "",
717
+ "Any service still linked to this database will lose the injected connection vars on its next deploy, and will fail at runtime if it depends on them. Check which services consume it first \u2014 the dashboard shows this under the database's Linked Services tab.",
718
+ "",
719
+ "Inputs:",
720
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
721
+ "",
722
+ "Returns: { ok: true }.",
723
+ "",
724
+ 'Example: delete_database({ database_id: "db_abc" }) \u2192 { ok: true }'
725
+ ].join("\n"),
726
+ input: {
727
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to permanently delete.")
728
+ },
729
+ handler: async (args2, ctx) => {
730
+ const teamId = await ctx.resolveTeamId();
731
+ await ctx.hoststack.databases.delete(teamId, args2.database_id);
732
+ return respond({
733
+ summary: `Deleted database ${args2.database_id} and all of its data. This cannot be undone.`
734
+ });
735
+ }
736
+ });
737
+ defineTool({
738
+ name: "suspend_database",
739
+ category: "databases",
740
+ description: [
741
+ "Suspend a managed database \u2014 stops its container while KEEPING the volume and all data. The reversible alternative to delete_database.",
742
+ "",
743
+ "When to use: a staging or preview database that nobody is using, to stop burning its compute tier. Connections fail while suspended; resume_database brings it back on the same connection URL.",
744
+ "",
745
+ "Inputs:",
746
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
747
+ "",
748
+ "Returns: { ok: true }.",
749
+ "",
750
+ 'Example: suspend_database({ database_id: "db_abc" }) \u2192 { ok: true }'
751
+ ].join("\n"),
752
+ input: {
753
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to suspend.")
754
+ },
755
+ handler: async (args2, ctx) => {
756
+ const teamId = await ctx.resolveTeamId();
757
+ await ctx.hoststack.databases.suspend(teamId, args2.database_id);
758
+ return respond({
759
+ summary: `Suspended database ${args2.database_id}. Data is preserved; resume_database restores it on the same URL.`
760
+ });
761
+ }
762
+ });
763
+ defineTool({
764
+ name: "resume_database",
765
+ category: "databases",
766
+ description: [
767
+ "Resume a suspended managed database \u2014 restarts its container against the existing volume. The connection URL is unchanged, so linked services do NOT need a redeploy.",
768
+ "",
769
+ "When to use: undoing a suspend_database, or bringing back a database that was suspended for non-payment once billing is resolved.",
770
+ "",
771
+ "Inputs:",
772
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
773
+ "",
774
+ 'Returns: { ok: true }. Poll get_database until `status === "available"`.',
775
+ "",
776
+ 'Example: resume_database({ database_id: "db_abc" }) \u2192 { ok: true }'
777
+ ].join("\n"),
778
+ input: {
779
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to resume.")
780
+ },
781
+ handler: async (args2, ctx) => {
782
+ const teamId = await ctx.resolveTeamId();
783
+ await ctx.hoststack.databases.resume(teamId, args2.database_id);
784
+ return respond({
785
+ summary: `Resume dispatched for ${args2.database_id}. Poll get_database until status=available.`
786
+ });
787
+ }
788
+ });
591
789
  defineTool({
592
790
  name: "list_databases",
593
791
  category: "databases",
594
792
  description: [
595
- "List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB, Meilisearch, NATS) inside a project.",
793
+ "List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB) inside a project. Meilisearch and NATS are NOT databases \u2014 they are separate managed resources (search / queue) and do not appear here; see list_service_resources for what a service consumes.",
596
794
  "",
597
795
  "When to use: an agent needs to know what data stores exist in a project before connecting a service or running a migration. Pair with list_projects to discover project IDs.",
598
796
  "",
@@ -777,6 +975,34 @@ defineTool({
777
975
  });
778
976
  }
779
977
  });
978
+ defineTool({
979
+ name: "restart_database",
980
+ category: "databases",
981
+ description: [
982
+ "Restart a managed database in place \u2014 `docker restart` of its container: same container, same volume, same connection URL. The database is briefly unavailable (a few seconds) while it bounces; no redeploy of connected services is needed.",
983
+ "",
984
+ "When to use: a database is wedged (stuck connection, needs to reload a config change, leaking memory) and a clean bounce is the fix \u2014 the database-equivalent of restarting a service.",
985
+ "",
986
+ "The database must be `available`. A suspended database has no running container (resume it first \u2192 409); a creating/migrating one is mid-operation (409). For a Patroni HA cluster this bounces the current leader, which triggers a normal failover while replicas keep serving reads.",
987
+ "",
988
+ "Inputs:",
989
+ " - database_id: publicId of the database to restart.",
990
+ "",
991
+ "Returns: { ok: true } once the restart command reaches an agent (dispatched optimistically, like a service restart). Poll get_service_logs / get_database to confirm it came back.",
992
+ "",
993
+ 'Example: restart_database({ database_id: "db_abc" }) \u2192 { ok: true }'
994
+ ].join("\n"),
995
+ input: {
996
+ database_id: z5.string().describe("Database publicId (e.g. db_abc) to restart.")
997
+ },
998
+ handler: async (args2, ctx) => {
999
+ const teamId = await ctx.resolveTeamId();
1000
+ await ctx.hoststack.databases.restart(teamId, args2.database_id);
1001
+ return respond({
1002
+ summary: `Restart dispatched for ${args2.database_id}. It will be briefly unavailable while the container bounces.`
1003
+ });
1004
+ }
1005
+ });
780
1006
  defineTool({
781
1007
  name: "get_database_cluster",
782
1008
  category: "databases",
@@ -902,7 +1128,7 @@ defineTool({
902
1128
  description: [
903
1129
  "Cancel a running deploy. Stops the build/deploy pipeline mid-flight; the previously live revision keeps serving traffic.",
904
1130
  "",
905
- "When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Has no effect on already-finished deploys.",
1131
+ "When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Also the right call for a deploy stuck in `deploying` because the new container is crash-looping against its health check \u2014 that state is cancellable and cancelling it frees the service (and the build queue) immediately instead of waiting out the grace period. Has no effect on already-finished deploys.",
906
1132
  "",
907
1133
  "Inputs:",
908
1134
  " - service_id: publicId of the service.",
@@ -1050,11 +1276,21 @@ defineTool({
1050
1276
 
1051
1277
  // src/tools/dns-records.ts
1052
1278
  import { z as z7 } from "zod";
1053
- var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "ALIAS"];
1054
- async function resolveZonePublicId(api, teamId, input) {
1279
+ var DNS_RECORD_TYPES = [
1280
+ "A",
1281
+ "AAAA",
1282
+ "CNAME",
1283
+ "MX",
1284
+ "TXT",
1285
+ "NS",
1286
+ "SRV",
1287
+ "CAA",
1288
+ "ALIAS"
1289
+ ];
1290
+ async function resolveZonePublicId(hoststack, teamId, input) {
1055
1291
  if (input.zone_id) {
1056
- const { zones: zones2 } = await api.get(`/api/dns-zones/${teamId}`);
1057
- const match = zones2.find((z15) => z15.publicId === input.zone_id);
1292
+ const { zones: zones2 } = await hoststack.dns.listZones(teamId);
1293
+ const match = zones2.find((z16) => z16.publicId === input.zone_id);
1058
1294
  if (!match) {
1059
1295
  throw new Error(`Zone ${input.zone_id} not found on this team.`);
1060
1296
  }
@@ -1063,12 +1299,12 @@ async function resolveZonePublicId(api, teamId, input) {
1063
1299
  if (!input.domain) {
1064
1300
  throw new Error("Provide either zone_id or domain.");
1065
1301
  }
1066
- const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1302
+ const { zones } = await hoststack.dns.listZones(teamId);
1067
1303
  const fqdn = input.domain.toLowerCase().replace(/\.$/, "");
1068
1304
  const labels = fqdn.split(".");
1069
1305
  for (let i = 0; i < labels.length - 1; i++) {
1070
1306
  const candidate = labels.slice(i).join(".");
1071
- const match = zones.find((z15) => z15.domainName.toLowerCase() === candidate);
1307
+ const match = zones.find((z16) => z16.domainName.toLowerCase() === candidate);
1072
1308
  if (match && match.status !== "deleting") {
1073
1309
  return { publicId: match.publicId, domainName: match.domainName };
1074
1310
  }
@@ -1092,7 +1328,7 @@ defineTool({
1092
1328
  input: {},
1093
1329
  handler: async (_args, ctx) => {
1094
1330
  const teamId = await ctx.resolveTeamId();
1095
- const response = await ctx.api.get(`/api/dns-zones/${teamId}`);
1331
+ const response = await ctx.hoststack.dns.listZones(teamId);
1096
1332
  const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
1097
1333
  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"}.`;
1098
1334
  return respond({ summary, data: { items } });
@@ -1121,10 +1357,8 @@ defineTool({
1121
1357
  const zoneInput = {};
1122
1358
  if (args2.zone_id !== void 0) zoneInput.zone_id = args2.zone_id;
1123
1359
  if (args2.domain !== void 0) zoneInput.domain = args2.domain;
1124
- const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1125
- const response = await ctx.api.get(
1126
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1127
- );
1360
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1361
+ const response = await ctx.hoststack.dns.listRecords(teamId, zone.publicId);
1128
1362
  const items = Array.isArray(response.records) ? response.records.map(shape) : [];
1129
1363
  const summary = items.length === 0 ? `Zone ${zone.domainName} has no records yet.` : `Returned ${items.length} record${items.length === 1 ? "" : "s"} for ${zone.domainName}.`;
1130
1364
  return respond({
@@ -1153,11 +1387,9 @@ defineTool({
1153
1387
  },
1154
1388
  handler: async (args2, ctx) => {
1155
1389
  const teamId = await ctx.resolveTeamId();
1156
- const { zones } = await ctx.api.get(`/api/dns-zones/${teamId}`);
1390
+ const { zones } = await ctx.hoststack.dns.listZones(teamId);
1157
1391
  for (const zone of zones) {
1158
- const { records } = await ctx.api.get(
1159
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1160
- );
1392
+ const { records } = await ctx.hoststack.dns.listRecords(teamId, zone.publicId);
1161
1393
  const match = records.find((r) => r.publicId === args2.record_id);
1162
1394
  if (match) {
1163
1395
  return respond({
@@ -1213,7 +1445,7 @@ defineTool({
1213
1445
  const zoneInput = {};
1214
1446
  if (args2.zone_id !== void 0) zoneInput.zone_id = args2.zone_id;
1215
1447
  if (args2.domain !== void 0) zoneInput.domain = args2.domain;
1216
- const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1448
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1217
1449
  const body = {
1218
1450
  type: args2.type,
1219
1451
  name: args2.name,
@@ -1221,10 +1453,7 @@ defineTool({
1221
1453
  };
1222
1454
  if (args2.ttl !== void 0) body.ttl = args2.ttl;
1223
1455
  if (args2.priority !== void 0) body.priority = args2.priority;
1224
- const response = await ctx.api.post(
1225
- `/api/dns-zones/${teamId}/${zone.publicId}/records`,
1226
- body
1227
- );
1456
+ const response = await ctx.hoststack.dns.createRecord(teamId, zone.publicId, body);
1228
1457
  return respond({
1229
1458
  summary: `Created ${args2.type} ${args2.name} on ${zone.domainName}.`,
1230
1459
  data: {
@@ -1264,7 +1493,7 @@ defineTool({
1264
1493
  return respondError(`Priority is required for ${args2.type} records (0\u201365535).`);
1265
1494
  }
1266
1495
  const teamId = await ctx.resolveTeamId();
1267
- const target = await findRecordZone(ctx.api, teamId, args2.record_id);
1496
+ const target = await findRecordZone(ctx.hoststack, teamId, args2.record_id);
1268
1497
  if (!target) {
1269
1498
  return respondError(
1270
1499
  `Record ${args2.record_id} not found on any zone owned by this team.`
@@ -1277,8 +1506,10 @@ defineTool({
1277
1506
  };
1278
1507
  if (args2.ttl !== void 0) body.ttl = args2.ttl;
1279
1508
  if (args2.priority !== void 0) body.priority = args2.priority;
1280
- const response = await ctx.api.put(
1281
- `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args2.record_id}`,
1509
+ const response = await ctx.hoststack.dns.updateRecord(
1510
+ teamId,
1511
+ target.zone.publicId,
1512
+ args2.record_id,
1282
1513
  body
1283
1514
  );
1284
1515
  return respond({
@@ -1313,27 +1544,66 @@ defineTool({
1313
1544
  },
1314
1545
  handler: async (args2, ctx) => {
1315
1546
  const teamId = await ctx.resolveTeamId();
1316
- const target = await findRecordZone(ctx.api, teamId, args2.record_id);
1547
+ const target = await findRecordZone(ctx.hoststack, teamId, args2.record_id);
1317
1548
  if (!target) {
1318
1549
  return respondError(
1319
1550
  `Record ${args2.record_id} not found on any zone owned by this team.`
1320
1551
  );
1321
1552
  }
1322
- await ctx.api.delete(
1323
- `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args2.record_id}`
1324
- );
1553
+ await ctx.hoststack.dns.deleteRecord(teamId, target.zone.publicId, args2.record_id);
1325
1554
  return respond({
1326
1555
  summary: `Deleted record ${args2.record_id} from ${target.zone.domainName}.`,
1327
1556
  data: { ok: true }
1328
1557
  });
1329
1558
  }
1330
1559
  });
1331
- async function findRecordZone(api, teamId, recordPublicId) {
1332
- const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1333
- for (const zone of zones) {
1334
- const { records } = await api.get(
1335
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1560
+ defineTool({
1561
+ name: "resync_dns_record",
1562
+ category: "dns",
1563
+ description: [
1564
+ 'Re-push a DNS record to PowerDNS without changing its value. Recovers a record stuck at status="failed" after a transient provider outage \u2014 including managedBy="hoststack" records (auto-created for a service/domain) that create/update/delete refuse to touch.',
1565
+ "",
1566
+ `When to use: a record shows status="failed" with a lastSyncError, or a freshly-attached domain's auto-created A record never went active. Safe and idempotent \u2014 a rejected re-push leaves the live RRset untouched (PowerDNS PATCH is atomic), so this can never drop a working record.`,
1567
+ "",
1568
+ "Inputs:",
1569
+ ' - record_id: record publicId (e.g. "dnr_abc").',
1570
+ "",
1571
+ 'Returns: { record: Record } \u2014 the record with its refreshed status ("active" on success).',
1572
+ "",
1573
+ 'Example: resync_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { status: "active", ... } }'
1574
+ ].join("\n"),
1575
+ input: {
1576
+ record_id: z7.string().describe("Record publicId.")
1577
+ },
1578
+ handler: async (args2, ctx) => {
1579
+ const teamId = await ctx.resolveTeamId();
1580
+ const target = await findRecordZone(ctx.hoststack, teamId, args2.record_id);
1581
+ if (!target) {
1582
+ return respondError(
1583
+ `Record ${args2.record_id} not found on any zone owned by this team.`
1584
+ );
1585
+ }
1586
+ const response = await ctx.hoststack.dns.resyncRecord(
1587
+ teamId,
1588
+ target.zone.publicId,
1589
+ args2.record_id
1336
1590
  );
1591
+ return respond({
1592
+ summary: `Re-synced record ${args2.record_id} on ${target.zone.domainName} (status: ${response.record.status}).`,
1593
+ data: {
1594
+ zone: {
1595
+ publicId: target.zone.publicId,
1596
+ domainName: target.zone.domainName
1597
+ },
1598
+ record: shape(response.record)
1599
+ }
1600
+ });
1601
+ }
1602
+ });
1603
+ async function findRecordZone(hoststack, teamId, recordPublicId) {
1604
+ const { zones } = await hoststack.dns.listZones(teamId);
1605
+ for (const zone of zones) {
1606
+ const { records } = await hoststack.dns.listRecords(teamId, zone.publicId);
1337
1607
  const match = records.find((r) => r.publicId === recordPublicId);
1338
1608
  if (match) return { zone, record: match };
1339
1609
  }
@@ -1393,9 +1663,10 @@ defineTool({
1393
1663
  };
1394
1664
  if (args2.path_prefix !== void 0) input.pathPrefix = args2.path_prefix;
1395
1665
  const response = await ctx.hoststack.domains.add(teamId, input);
1396
- const data = { domain: shapeDomain(response.domain) };
1666
+ const dnsSyncWarning = response.domain.dnsSyncWarning;
1667
+ const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
1397
1668
  return respond({
1398
- summary: `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1669
+ summary: dnsSyncWarning ? `Added domain ${args2.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args2.hostname}. Configure DNS, then call verify_domain.`,
1399
1670
  data
1400
1671
  });
1401
1672
  }
@@ -1493,6 +1764,7 @@ defineTool({
1493
1764
  ' - key: env-var name (e.g. "DATABASE_URL").',
1494
1765
  " - value: new value (will be encrypted at rest if is_secret=true).",
1495
1766
  " - 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.",
1767
+ ' - 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.',
1496
1768
  "",
1497
1769
  'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
1498
1770
  "",
@@ -1502,7 +1774,8 @@ defineTool({
1502
1774
  service_id: z9.string().describe("Service publicId."),
1503
1775
  key: z9.string().min(1).max(128).describe("Env-var key."),
1504
1776
  value: z9.string().describe("New value."),
1505
- is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
1777
+ is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
1778
+ target: z9.enum(["build", "runtime", "both"]).optional().describe("Injection target: build, runtime, or both. Default both.")
1506
1779
  },
1507
1780
  handler: async (args2, ctx) => {
1508
1781
  const teamId = await ctx.resolveTeamId();
@@ -1511,6 +1784,7 @@ defineTool({
1511
1784
  if (match) {
1512
1785
  const updatePayload = { value: args2.value };
1513
1786
  if (args2.is_secret !== void 0) updatePayload.isSecret = args2.is_secret;
1787
+ if (args2.target !== void 0) updatePayload.target = args2.target;
1514
1788
  const response2 = await ctx.hoststack.envVars.update(
1515
1789
  teamId,
1516
1790
  args2.service_id,
@@ -1526,7 +1800,8 @@ defineTool({
1526
1800
  const response = await ctx.hoststack.envVars.create(teamId, args2.service_id, {
1527
1801
  key: args2.key,
1528
1802
  value: args2.value,
1529
- isSecret: args2.is_secret ?? true
1803
+ isSecret: args2.is_secret ?? true,
1804
+ ...args2.target !== void 0 ? { target: args2.target } : {}
1530
1805
  });
1531
1806
  const data = {
1532
1807
  envVar: shapeEnvVar(response.envVar),
@@ -1582,7 +1857,7 @@ defineTool({
1582
1857
  "",
1583
1858
  "Inputs:",
1584
1859
  " - service_id: publicId of the service.",
1585
- " - env_vars: array of { key, value, is_secret? }. is_secret defaults to true per row.",
1860
+ ' - 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.',
1586
1861
  "",
1587
1862
  "Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
1588
1863
  "",
@@ -1594,7 +1869,8 @@ defineTool({
1594
1869
  z9.object({
1595
1870
  key: z9.string().min(1).max(128),
1596
1871
  value: z9.string(),
1597
- is_secret: z9.boolean().optional()
1872
+ is_secret: z9.boolean().optional(),
1873
+ target: z9.enum(["build", "runtime", "both"]).optional()
1598
1874
  })
1599
1875
  ).max(500).describe("Array of env-var rows. Hard cap 500.")
1600
1876
  },
@@ -1607,6 +1883,7 @@ defineTool({
1607
1883
  value: v.value
1608
1884
  };
1609
1885
  if (v.is_secret !== void 0) row.isSecret = v.is_secret;
1886
+ if (v.target !== void 0) row.target = v.target;
1610
1887
  return row;
1611
1888
  })
1612
1889
  };
@@ -1628,6 +1905,10 @@ defineTool({
1628
1905
  "",
1629
1906
  "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.",
1630
1907
  "",
1908
+ // §2.2: these are project deployment environments, not cloud Dev Boxes — point agents at the
1909
+ // right surface so the two "dev" concepts stay distinct (bidirectional cross-reference).
1910
+ "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.",
1911
+ "",
1631
1912
  "Inputs:",
1632
1913
  ' - project_id: project publicId (e.g. "prj_abc123").',
1633
1914
  "",
@@ -1654,10 +1935,15 @@ defineTool({
1654
1935
  "",
1655
1936
  "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.",
1656
1937
  "",
1938
+ // §2.1/§2.2: steer callers away from the agentic cloud Dev Box tools (which live in
1939
+ // services.ts) — an environment is a deployment target, NOT a Dev Box. Bidirectional with
1940
+ // the cross-references those dev-box tools point back here.
1941
+ '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.',
1942
+ "",
1657
1943
  "Inputs:",
1658
1944
  " - project_id: project publicId.",
1659
1945
  " - name: human-readable name (1\u201364 chars). Shown in the env switcher.",
1660
- ' - type: "production" | "staging" | "development" | "preview". Determines the hostname suffix on services in this env (production stays clean; others get -staging / -dev / -preview).',
1946
+ ' - 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.',
1661
1947
  " - is_protected (optional): require admin role for destructive actions in this env. Default false.",
1662
1948
  "",
1663
1949
  "Returns: { environment: Environment }.",
@@ -1667,7 +1953,10 @@ defineTool({
1667
1953
  input: {
1668
1954
  project_id: z10.string().describe("Project publicId."),
1669
1955
  name: z10.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
1670
- type: z10.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
1956
+ type: z10.enum(["production", "staging", "development", "preview"]).describe(
1957
+ // §2.1: "development" here is a project deployment env, NOT a cloud Dev Box.
1958
+ '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.'
1959
+ ),
1671
1960
  is_protected: z10.boolean().optional().describe("Require admin role for destructive actions. Default false.")
1672
1961
  },
1673
1962
  handler: async (args2, ctx) => {
@@ -1758,7 +2047,57 @@ defineTool({
1758
2047
  }
1759
2048
  });
1760
2049
 
2050
+ // src/tools/github.ts
2051
+ defineTool({
2052
+ name: "sync_github_repos",
2053
+ category: "github",
2054
+ description: [
2055
+ "Re-sync the repository list from GitHub for every connected GitHub App installation.",
2056
+ "",
2057
+ "When to use: right after pushing a brand-new repository, HostStack cannot see it until an installation re-sync runs \u2014 so create_service with a github_repo_id would fail. Call this first. (Same operation as the dashboard's refresh icon on the repo picker.)",
2058
+ "",
2059
+ "No inputs.",
2060
+ "",
2061
+ "Returns: { installations: number, repos: [{ account, count }], totalRepos } \u2014 the accounts synced and how many repos each now exposes.",
2062
+ "",
2063
+ 'Example: sync_github_repos({}) \u2192 { installations: 1, repos: [{ account: "acme", count: 12 }], totalRepos: 12 }'
2064
+ ].join("\n"),
2065
+ input: {},
2066
+ handler: async (_args, ctx) => {
2067
+ const teamId = await ctx.resolveTeamId();
2068
+ const { installations } = await ctx.api.get(
2069
+ `/api/github/${teamId}/installations`
2070
+ );
2071
+ if (installations.length === 0) {
2072
+ return respondError(
2073
+ "No GitHub installations connected. Install the HostStack GitHub App from the dashboard (Settings \u2192 GitHub) first."
2074
+ );
2075
+ }
2076
+ const perAccount = [];
2077
+ let totalRepos = 0;
2078
+ for (const inst of installations) {
2079
+ const { repos } = await ctx.api.post(
2080
+ `/api/github/${teamId}/installations/${inst.id}/sync`
2081
+ );
2082
+ perAccount.push({ account: inst.accountLogin, count: repos.length });
2083
+ totalRepos += repos.length;
2084
+ }
2085
+ return respond({
2086
+ summary: `Synced ${totalRepos} repositories across ${installations.length} installation(s).`,
2087
+ data: { installations: installations.length, repos: perAccount, totalRepos }
2088
+ });
2089
+ }
2090
+ });
2091
+
1761
2092
  // src/tools/meta.ts
2093
+ var DEV_ENV_TOOL_NAMES = [
2094
+ "create_dev_environment",
2095
+ "create_standalone_dev_environment",
2096
+ "spin_up_dev_environment",
2097
+ "list_dev_environments",
2098
+ "resize_dev_environment",
2099
+ "delete_dev_environment"
2100
+ ];
1762
2101
  defineTool({
1763
2102
  name: "get_me",
1764
2103
  category: "meta",
@@ -1783,6 +2122,36 @@ defineTool({
1783
2122
  return respond({ summary: `Authenticated as ${userEmail}${teamLabel}.`, data });
1784
2123
  }
1785
2124
  });
2125
+ defineTool({
2126
+ name: "describe_mcp",
2127
+ category: "meta",
2128
+ description: [
2129
+ "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.",
2130
+ "",
2131
+ "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).",
2132
+ "",
2133
+ '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.',
2134
+ "",
2135
+ "Inputs: none.",
2136
+ "",
2137
+ "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.",
2138
+ "",
2139
+ 'Example: describe_mcp() \u2192 { version: "0.14.0", toolCount: 30, devEnvTools: ["create_dev_environment", \u2026], devEnvToolsPresent: true }'
2140
+ ].join("\n"),
2141
+ input: {},
2142
+ handler: async (_args, _ctx) => {
2143
+ const registered = new Set(listToolDefinitions().map((t) => t.name));
2144
+ const devEnvToolsPresent = DEV_ENV_TOOL_NAMES.every((name) => registered.has(name));
2145
+ const data = {
2146
+ version: MCP_VERSION,
2147
+ toolCount: registered.size,
2148
+ devEnvTools: [...DEV_ENV_TOOL_NAMES],
2149
+ devEnvToolsPresent
2150
+ };
2151
+ 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).`;
2152
+ return respond({ summary, data });
2153
+ }
2154
+ });
1786
2155
 
1787
2156
  // src/tools/notifications.ts
1788
2157
  import { z as z11 } from "zod";
@@ -1798,7 +2167,10 @@ var NOTIFICATION_EVENTS = [
1798
2167
  "service.restart_failed",
1799
2168
  "service.auto_suspended",
1800
2169
  "service.acme_cert_failed",
1801
- "git.auth_failed"
2170
+ "service.resource_alert",
2171
+ "git.auth_failed",
2172
+ "cron.execution_failed",
2173
+ "workflow.failed"
1802
2174
  ];
1803
2175
  defineTool({
1804
2176
  name: "list_notification_channels",
@@ -1837,7 +2209,7 @@ defineTool({
1837
2209
  " - webhook_url: Slack/Discord webhook URL OR email address.",
1838
2210
  " - events: list of event names to subscribe to. Pass an empty list to create a channel that fires for nothing (manual subscribe later with update_notification_channel).",
1839
2211
  "",
1840
- "Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, git.auth_failed.",
2212
+ "Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, service.resource_alert, git.auth_failed, cron.execution_failed, workflow.failed.",
1841
2213
  "",
1842
2214
  "Returns: { channel: Channel }.",
1843
2215
  "",
@@ -2092,9 +2464,159 @@ defineTool({
2092
2464
  }
2093
2465
  });
2094
2466
 
2095
- // src/tools/services.ts
2467
+ // src/tools/resource-links.ts
2096
2468
  import { z as z13 } from "zod";
2097
- var DEV_ENV_IMAGE = "hoststack/dev-env:latest";
2469
+ var RESOURCE_LINK_TYPES = [
2470
+ "database",
2471
+ "object_storage",
2472
+ "queue",
2473
+ "search",
2474
+ "email_domain"
2475
+ ];
2476
+ var INJECTION_CONTRACT = [
2477
+ "What a link injects at deploy time:",
2478
+ ' - Alias-prefixed vars for every field of the resource (alias "APP_DB" \u2192 APP_DB_HOST, APP_DB_PORT, APP_DB_USER, APP_DB_PASSWORD, APP_DB_URL, \u2026). This is how one service consumes several resources of the same type without collisions.',
2479
+ " - PLUS the conventional name for the first linked resource of each engine class: `DATABASE_URL` (postgres/mysql/mariadb), `REDIS_URL` (redis), `MONGO_URL` (mongodb). So an app reading `process.env.DATABASE_URL` works with no code changes.",
2480
+ " - The conventional names are set with `??=` \u2014 an env var you set yourself via set_env_var ALWAYS wins over the injected one."
2481
+ ].join("\n");
2482
+ defineTool({
2483
+ name: "list_managed_resources",
2484
+ category: "resource-links",
2485
+ description: [
2486
+ "List EVERY managed resource the team owns in one call \u2014 databases, object storage buckets, queues, search indexes and email domains \u2014 from the unified `managed_resources` read-model.",
2487
+ "",
2488
+ 'When to use: to find the NUMERIC `id` that link_resource_to_service needs, especially for resource types with no dedicated list tool of their own (object storage, queues, search, email domains \u2014 list_databases only covers databases). Also a fast inventory answer for "what do we actually have running".',
2489
+ "",
2490
+ "Team-wide, not project-scoped. Use list_databases when you specifically want databases in one project.",
2491
+ "",
2492
+ "Returns: { items: ManagedResource[] } \u2014 id (numeric, for linking), publicId, type, name, status, region, createdAt.",
2493
+ "",
2494
+ 'Example: list_managed_resources() \u2192 { items: [{ id: 42, type: "database", name: "app-db", status: "available" }, { id: 8, type: "object_storage", name: "uploads", \u2026 }] }'
2495
+ ].join("\n"),
2496
+ input: {},
2497
+ handler: async (_args, ctx) => {
2498
+ const teamId = await ctx.resolveTeamId();
2499
+ const response = await ctx.hoststack.serviceResourceLinks.listManagedResources(teamId);
2500
+ const data = shapeList(response, "resources", shape);
2501
+ const byType = /* @__PURE__ */ new Map();
2502
+ for (const item of data.items) {
2503
+ const type = item && typeof item === "object" && "type" in item ? String(item.type) : "unknown";
2504
+ byType.set(type, (byType.get(type) ?? 0) + 1);
2505
+ }
2506
+ const breakdown = [...byType.entries()].map(([t, n]) => `${n} ${t}`).join(", ");
2507
+ const summary = data.items.length === 0 ? "No managed resources on this team yet." : `${data.items.length} managed resource${data.items.length === 1 ? "" : "s"}: ${breakdown}.`;
2508
+ return respond({ summary, data });
2509
+ }
2510
+ });
2511
+ defineTool({
2512
+ name: "list_service_resources",
2513
+ category: "resource-links",
2514
+ description: [
2515
+ "List the managed resources (databases, object storage, queues, search, email domains) linked to a service \u2014 i.e. which resources get injected into its container as env vars on deploy.",
2516
+ "",
2517
+ 'When to use: before adding a link (to avoid a duplicate or an alias collision), when debugging "why is DATABASE_URL empty in my app" (the answer is usually: no link exists, or the service was never redeployed after linking), or to find the linkId needed by unlink_resource_from_service.',
2518
+ "",
2519
+ INJECTION_CONTRACT,
2520
+ "",
2521
+ "Inputs:",
2522
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
2523
+ "",
2524
+ "Returns: { items: ServiceResourceLink[] } \u2014 id (the linkId), resourceType, resourceId (numeric id of the linked resource), alias, createdAt.",
2525
+ "",
2526
+ 'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
2527
+ ].join("\n"),
2528
+ input: {
2529
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
2530
+ },
2531
+ handler: async (args2, ctx) => {
2532
+ const teamId = await ctx.resolveTeamId();
2533
+ const response = await ctx.hoststack.serviceResourceLinks.list(teamId, args2.service_id);
2534
+ const data = shapeList(response, "links", shape);
2535
+ const summary = data.items.length === 0 ? `No resources linked to service ${args2.service_id}. Nothing is being injected \u2014 link a database with link_resource_to_service.` : `${data.items.length} resource${data.items.length === 1 ? "" : "s"} linked to service ${args2.service_id}.`;
2536
+ return respond({ summary, data });
2537
+ }
2538
+ });
2539
+ defineTool({
2540
+ name: "link_resource_to_service",
2541
+ category: "resource-links",
2542
+ description: [
2543
+ "Link a managed resource to a service so its connection details are injected into the container as environment variables. This is the step that connects a database to an app \u2014 creating the database alone does nothing for the app.",
2544
+ "",
2545
+ "When to use: immediately after create_database (or when pointing an existing service at an existing resource). This is step 2 of 3 in the managed-database flow: create_database \u2192 link_resource_to_service \u2192 trigger_deploy.",
2546
+ "",
2547
+ '*** The link takes effect on the NEXT DEPLOY. *** After calling this, call trigger_deploy({ service_id }) or the running container will not see the new vars. A service that "cannot connect to the database" right after linking almost always just needs that redeploy.',
2548
+ "",
2549
+ INJECTION_CONTRACT,
2550
+ "",
2551
+ "Because the platform injects the credentials directly into the container, you do NOT need to read the password, build a connection string by hand, or store it with set_env_var. Don't.",
2552
+ "",
2553
+ "Inputs:",
2554
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the consuming service.',
2555
+ ` - resource_type: ${RESOURCE_LINK_TYPES.join(" | ")}.`,
2556
+ ' - resource_id: the NUMERIC id of the resource \u2014 e.g. the `id` field from create_database / list_databases, NOT the "db_\u2026" publicId.',
2557
+ ' - alias: uppercase env-var prefix (A\u2013Z, 0\u20139, underscore; must start with a letter; \u226448 chars), e.g. "APP_DB", "CACHE", "CATALOG". Must be unique within the service.',
2558
+ "",
2559
+ "Returns: { link: ServiceResourceLink } \u2014 including `id`, the linkId used to unlink later.",
2560
+ "",
2561
+ "Fails with 409 if this exact resource is already linked to the service, or if the alias is already taken on it.",
2562
+ "",
2563
+ 'Example: link_resource_to_service({ service_id: "svc_abc", resource_type: "database", resource_id: 42, alias: "APP_DB" }) \u2192 { link: { id: 7, alias: "APP_DB" } }'
2564
+ ].join("\n"),
2565
+ input: {
2566
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
2567
+ resource_type: z13.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
2568
+ resource_id: z13.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
2569
+ alias: z13.string().min(1).max(48).regex(
2570
+ /^[A-Z][A-Z0-9_]*$/,
2571
+ "Alias must be uppercase letters, digits and underscores, starting with a letter."
2572
+ ).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
2573
+ },
2574
+ handler: async (args2, ctx) => {
2575
+ const teamId = await ctx.resolveTeamId();
2576
+ const response = await ctx.hoststack.serviceResourceLinks.create(teamId, args2.service_id, {
2577
+ resourceType: args2.resource_type,
2578
+ resourceId: args2.resource_id,
2579
+ alias: args2.alias
2580
+ });
2581
+ const data = { link: shape(response.link) };
2582
+ return respond({
2583
+ summary: `Linked ${args2.resource_type} ${args2.resource_id} to service ${args2.service_id} as "${args2.alias}". Call trigger_deploy({ service_id: "${args2.service_id}" }) to inject it \u2014 the running container will not see the vars until then.`,
2584
+ data
2585
+ });
2586
+ }
2587
+ });
2588
+ defineTool({
2589
+ name: "unlink_resource_from_service",
2590
+ category: "resource-links",
2591
+ description: [
2592
+ "Remove a resource link from a service. The resource itself is NOT deleted \u2014 only the binding.",
2593
+ "",
2594
+ "When to use: repointing a service at a different database, or detaching a resource a service no longer needs. The injected env vars disappear on the next deploy, so a service that still reads DATABASE_URL will break then, not immediately. Use delete_database if the intent is to destroy the data.",
2595
+ "",
2596
+ "Inputs:",
2597
+ ' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
2598
+ " - link_id: numeric linkId from list_service_resources (the `id` field on the link, not the resource id).",
2599
+ "",
2600
+ "Returns: { ok: true }.",
2601
+ "",
2602
+ 'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
2603
+ ].join("\n"),
2604
+ input: {
2605
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
2606
+ link_id: z13.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
2607
+ },
2608
+ handler: async (args2, ctx) => {
2609
+ const teamId = await ctx.resolveTeamId();
2610
+ await ctx.hoststack.serviceResourceLinks.delete(teamId, args2.service_id, args2.link_id);
2611
+ return respond({
2612
+ summary: `Unlinked resource link ${args2.link_id} from service ${args2.service_id}. The resource still exists; its env vars vanish on the next deploy.`
2613
+ });
2614
+ }
2615
+ });
2616
+
2617
+ // src/tools/services.ts
2618
+ import { z as z14 } from "zod";
2619
+ var DEV_ENV_IMAGE = "registry.hoststack.dev/hoststack/dev-env:latest";
2098
2620
  var DEV_ENV_VOLUME = { name: "workspace", mountPath: "/workspace", sizeGb: 10 };
2099
2621
  var SERVICE_TYPES = [
2100
2622
  "web_service",
@@ -2107,11 +2629,19 @@ var SERVICE_PLANS = [
2107
2629
  "pico",
2108
2630
  "nano",
2109
2631
  "micro",
2110
- "starter",
2632
+ "small",
2111
2633
  "standard",
2634
+ "large",
2635
+ "xlarge",
2112
2636
  "pro_standard",
2113
2637
  "pro_large"
2114
2638
  ];
2639
+ var DEV_ENV_MIN_SIZE = "standard";
2640
+ function coerceDevEnvSize(value) {
2641
+ const minIdx = SERVICE_PLANS.indexOf(DEV_ENV_MIN_SIZE);
2642
+ const idx = value ? SERVICE_PLANS.indexOf(value) : -1;
2643
+ return idx >= minIdx ? value : DEV_ENV_MIN_SIZE;
2644
+ }
2115
2645
  defineTool({
2116
2646
  name: "list_services",
2117
2647
  category: "services",
@@ -2120,25 +2650,32 @@ defineTool({
2120
2650
  "",
2121
2651
  "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.",
2122
2652
  "",
2653
+ "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.",
2654
+ "",
2123
2655
  "Inputs (all optional):",
2124
2656
  ' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
2125
2657
  ' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
2126
2658
  ' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
2127
2659
  ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
2660
+ " - dev_environment: include Dev Boxes in the results (excluded by default).",
2128
2661
  "",
2129
2662
  "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
2130
2663
  "",
2131
2664
  'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
2132
2665
  ].join("\n"),
2133
2666
  input: {
2134
- project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2135
- environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2136
- status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2137
- type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
2667
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2668
+ environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2669
+ status: z14.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2670
+ type: z14.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
2671
+ dev_environment: z14.boolean().optional().describe(
2672
+ "Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
2673
+ )
2138
2674
  },
2139
2675
  handler: async (args2, ctx) => {
2140
2676
  const teamId = await ctx.resolveTeamId();
2141
2677
  const filters = {};
2678
+ if (args2.dev_environment) filters.devEnvironment = true;
2142
2679
  if (args2.project_id !== void 0) {
2143
2680
  const resolved = await ctx.hoststack.resolveId(args2.project_id, {
2144
2681
  kind: "project",
@@ -2161,7 +2698,8 @@ defineTool({
2161
2698
  args2.project_id !== void 0 ? `project=${args2.project_id}` : null,
2162
2699
  args2.environment_id !== void 0 ? `env=${args2.environment_id}` : null,
2163
2700
  args2.status ? `status=${args2.status}` : null,
2164
- args2.type ? `type=${args2.type}` : null
2701
+ args2.type ? `type=${args2.type}` : null,
2702
+ args2.dev_environment ? "devBoxes=included" : null
2165
2703
  ].filter(Boolean).join(", ");
2166
2704
  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}` : ""}.`;
2167
2705
  return respond({ summary, data });
@@ -2175,6 +2713,8 @@ defineTool({
2175
2713
  "",
2176
2714
  "When to use: the user wants to deploy something new. For a one-command AI dev environment specifically, prefer create_dev_environment (it also attaches the /workspace volume and sets the MCP keys).",
2177
2715
  "",
2716
+ '*** NOT for databases. *** If the user wants Postgres, Redis, MySQL, MariaDB or MongoDB, call create_database \u2014 do NOT create a service with docker_image "postgres:16" / "redis:7" / "mongo" / "mysql". A database deployed as a service is unmanaged: no backups, no version upgrades, no HA, no credential rotation, no metrics, no persistent volume, and nothing injects its URL into your app. `docker_image` here is for YOUR application images (or sidecars), not for datastores the platform already manages.',
2717
+ "",
2178
2718
  "Inputs:",
2179
2719
  ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
2180
2720
  " - name: service name (1\u2013100 chars).",
@@ -2195,21 +2735,23 @@ defineTool({
2195
2735
  'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
2196
2736
  ].join("\n"),
2197
2737
  input: {
2198
- project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2199
- name: z13.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
2200
- type: z13.enum(SERVICE_TYPES).describe("Service type."),
2201
- docker_image: z13.string().max(500).optional().describe("Pre-built image ref. Mutually exclusive with github_repo_id."),
2202
- github_repo_id: z13.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
2203
- branch: z13.string().max(200).optional().describe('Git branch (default "main").'),
2204
- install_command: z13.string().max(1e3).optional().describe("Install shell command."),
2205
- build_command: z13.string().max(1e3).optional().describe("Build shell command."),
2206
- start_command: z13.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
2207
- cron_schedule: z13.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
2208
- publish_path: z13.string().max(500).optional().describe("Static-site output dir."),
2209
- runtime: z13.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
2210
- plan: z13.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2211
- environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
2212
- auto_deploy: z13.boolean().optional().describe("Trigger the first deploy immediately (default true).")
2738
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
2739
+ name: z14.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
2740
+ type: z14.enum(SERVICE_TYPES).describe("Service type."),
2741
+ docker_image: z14.string().max(500).optional().describe(
2742
+ "Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
2743
+ ),
2744
+ github_repo_id: z14.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
2745
+ branch: z14.string().max(200).optional().describe('Git branch (default "main").'),
2746
+ install_command: z14.string().max(1e3).optional().describe("Install shell command."),
2747
+ build_command: z14.string().max(1e3).optional().describe("Build shell command."),
2748
+ start_command: z14.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
2749
+ cron_schedule: z14.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
2750
+ publish_path: z14.string().max(500).optional().describe("Static-site output dir."),
2751
+ runtime: z14.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
2752
+ plan: z14.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2753
+ environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
2754
+ auto_deploy: z14.boolean().optional().describe("Trigger the first deploy immediately (default true).")
2213
2755
  },
2214
2756
  handler: async (args2, ctx) => {
2215
2757
  const teamId = await ctx.resolveTeamId();
@@ -2252,14 +2794,16 @@ defineTool({
2252
2794
  name: "create_dev_environment",
2253
2795
  category: "services",
2254
2796
  description: [
2255
- "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.",
2797
+ '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.',
2798
+ "",
2799
+ '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.',
2256
2800
  "",
2257
- `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.`,
2801
+ `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.`,
2258
2802
  "",
2259
2803
  "Inputs:",
2260
2804
  ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
2261
2805
  ' - name (optional): service name (default "dev-environment").',
2262
- ' - plan (optional): service size (default "standard" \u2014 2 GB, the smallest that fits a coding agent + build).',
2806
+ ' - 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".',
2263
2807
  " - disk_gb (optional): /workspace volume size in GB (default 10, 1\u2013100).",
2264
2808
  " - hoststack_api_key (optional): sets HOSTSTACK_API_KEY so the hoststack MCP works inside the container.",
2265
2809
  " - poststack_api_key (optional): sets POSTSTACK_API_KEY so the poststack MCP works inside the container.",
@@ -2270,18 +2814,18 @@ defineTool({
2270
2814
  'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
2271
2815
  ].join("\n"),
2272
2816
  input: {
2273
- project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2274
- name: z13.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2275
- plan: z13.enum(SERVICE_PLANS).optional().describe(
2276
- 'Service size (default "standard" \u2014 2 GB; smallest that fits a coding agent).'
2817
+ project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
2818
+ name: z14.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2819
+ plan: z14.enum(SERVICE_PLANS).optional().describe(
2820
+ 'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
2277
2821
  ),
2278
- disk_gb: z13.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2279
- hoststack_api_key: z13.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2280
- poststack_api_key: z13.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
2281
- repo_url: z13.string().max(500).optional().describe(
2822
+ disk_gb: z14.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2823
+ hoststack_api_key: z14.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2824
+ poststack_api_key: z14.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
2825
+ repo_url: z14.string().max(500).optional().describe(
2282
2826
  "Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
2283
2827
  ),
2284
- branch: z13.string().max(200).optional().describe("Branch to clone (with repo_url).")
2828
+ branch: z14.string().max(200).optional().describe("Branch to clone (with repo_url).")
2285
2829
  },
2286
2830
  handler: async (args2, ctx) => {
2287
2831
  const teamId = await ctx.resolveTeamId();
@@ -2291,68 +2835,94 @@ defineTool({
2291
2835
  });
2292
2836
  const name = args2.name ?? "dev-environment";
2293
2837
  const sizeGb = args2.disk_gb ?? DEV_ENV_VOLUME.sizeGb;
2838
+ const plan = coerceDevEnvSize(args2.plan);
2294
2839
  const createInput = {
2295
2840
  name,
2296
2841
  type: "private_service",
2297
2842
  projectId,
2298
2843
  dockerImage: DEV_ENV_IMAGE,
2299
- autoDeploy: false
2844
+ autoDeploy: false,
2845
+ plan
2300
2846
  };
2301
- if (args2.plan !== void 0) createInput.plan = args2.plan;
2302
2847
  const created = await ctx.hoststack.services.create(teamId, createInput);
2303
2848
  const service = created.service;
2304
- const envVars = [];
2305
- if (args2.hoststack_api_key)
2306
- envVars.push({
2307
- key: "HOSTSTACK_API_KEY",
2308
- value: args2.hoststack_api_key,
2309
- isSecret: true
2310
- });
2311
- if (args2.poststack_api_key)
2312
- envVars.push({
2313
- key: "POSTSTACK_API_KEY",
2314
- value: args2.poststack_api_key,
2315
- isSecret: true
2316
- });
2317
- if (args2.repo_url) {
2318
- envVars.push({
2319
- key: "HOSTSTACK_DEVENV_REPO_URL",
2320
- value: args2.repo_url,
2321
- isSecret: false
2322
- });
2323
- if (args2.branch)
2849
+ const rollback = async () => {
2850
+ try {
2851
+ await ctx.hoststack.services.tearDownDevEnvironment(teamId, service.id);
2852
+ } catch {
2853
+ try {
2854
+ await ctx.hoststack.services.delete(teamId, service.id);
2855
+ } catch {
2856
+ }
2857
+ }
2858
+ };
2859
+ try {
2860
+ const envVars = [];
2861
+ if (args2.hoststack_api_key)
2862
+ envVars.push({
2863
+ key: "HOSTSTACK_API_KEY",
2864
+ value: args2.hoststack_api_key,
2865
+ isSecret: true
2866
+ });
2867
+ if (args2.poststack_api_key)
2324
2868
  envVars.push({
2325
- key: "HOSTSTACK_DEVENV_BRANCH",
2326
- value: args2.branch,
2869
+ key: "POSTSTACK_API_KEY",
2870
+ value: args2.poststack_api_key,
2871
+ isSecret: true
2872
+ });
2873
+ if (args2.repo_url) {
2874
+ envVars.push({
2875
+ key: "HOSTSTACK_DEVENV_REPO_URL",
2876
+ value: args2.repo_url,
2327
2877
  isSecret: false
2328
2878
  });
2329
- }
2330
- if (envVars.length > 0) {
2331
- await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2332
- }
2333
- let volumeAttached = false;
2334
- try {
2335
- await ctx.hoststack.volumes.create(teamId, service.id, {
2336
- name: DEV_ENV_VOLUME.name,
2337
- mountPath: DEV_ENV_VOLUME.mountPath,
2338
- sizeGb
2879
+ if (args2.branch)
2880
+ envVars.push({
2881
+ key: "HOSTSTACK_DEVENV_BRANCH",
2882
+ value: args2.branch,
2883
+ isSecret: false
2884
+ });
2885
+ }
2886
+ if (envVars.length > 0) {
2887
+ await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2888
+ }
2889
+ try {
2890
+ await ctx.hoststack.volumes.create(teamId, service.id, {
2891
+ name: DEV_ENV_VOLUME.name,
2892
+ mountPath: DEV_ENV_VOLUME.mountPath,
2893
+ sizeGb
2894
+ });
2895
+ } catch (error) {
2896
+ const reason = error instanceof Error ? error.message : String(error);
2897
+ await rollback();
2898
+ return respondError(
2899
+ `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}`,
2900
+ { volumeError: reason, rolledBack: true }
2901
+ );
2902
+ }
2903
+ const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2904
+ const deployId = deploy.deploy?.id ?? null;
2905
+ return respond({
2906
+ 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.`,
2907
+ data: { service: shapeService(service), volumeAttached: true, deployId }
2339
2908
  });
2340
- volumeAttached = true;
2341
- } catch {
2909
+ } catch (error) {
2910
+ const reason = error instanceof Error ? error.message : String(error);
2911
+ await rollback();
2912
+ return respondError(
2913
+ `Failed to provision Dev Box "${name}" \u2014 rolled back the partially-created box. Error: ${reason}`,
2914
+ { rolledBack: true }
2915
+ );
2342
2916
  }
2343
- const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2344
- const deployId = deploy.deploy?.id ?? null;
2345
- return respond({
2346
- 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.`,
2347
- data: { service: shapeService(service), volumeAttached, deployId }
2348
- });
2349
2917
  }
2350
2918
  });
2351
2919
  defineTool({
2352
2920
  name: "spin_up_dev_environment",
2353
2921
  category: "services",
2354
2922
  description: [
2355
- "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`.",
2923
+ "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`.",
2924
+ "",
2925
+ "This makes a cloud Dev Box, NOT a project deploy environment \u2014 for a -dev/-staging deploy target use create_environment instead.",
2356
2926
  "",
2357
2927
  '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.',
2358
2928
  "",
@@ -2366,9 +2936,9 @@ defineTool({
2366
2936
  'Example: spin_up_dev_environment({ service_id: "svc_api" }) \u2192 a dev box running a clone of the api service (repo + env-vars + cloned DB) with a public dev URL.'
2367
2937
  ].join("\n"),
2368
2938
  input: {
2369
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
2370
- include_database_clone: z13.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
2371
- name: z13.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
2939
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
2940
+ include_database_clone: z14.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
2941
+ name: z14.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
2372
2942
  },
2373
2943
  handler: async (args2, ctx) => {
2374
2944
  const teamId = await ctx.resolveTeamId();
@@ -2395,7 +2965,9 @@ defineTool({
2395
2965
  name: "delete_dev_environment",
2396
2966
  category: "services",
2397
2967
  description: [
2398
- "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.",
2968
+ "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.",
2969
+ "",
2970
+ "This deletes a cloud Dev Box, NOT a project deploy environment \u2014 to remove a -dev/-staging deploy target use delete_environment instead.",
2399
2971
  "",
2400
2972
  'When to use: "delete / tear down the dev environment", once you have shipped the fix and no longer need the box.',
2401
2973
  "",
@@ -2407,7 +2979,7 @@ defineTool({
2407
2979
  'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
2408
2980
  ].join("\n"),
2409
2981
  input: {
2410
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
2982
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
2411
2983
  },
2412
2984
  handler: async (args2, ctx) => {
2413
2985
  const teamId = await ctx.resolveTeamId();
@@ -2426,23 +2998,27 @@ defineTool({
2426
2998
  name: "resize_dev_environment",
2427
2999
  category: "services",
2428
3000
  description: [
2429
- "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).",
3001
+ "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).",
3002
+ "",
3003
+ "This resizes a cloud Dev Box, NOT a project deploy environment.",
2430
3004
  "",
2431
- "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.",
3005
+ "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.",
2432
3006
  "",
2433
3007
  "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.",
2434
3008
  "",
2435
3009
  "Inputs:",
2436
3010
  " - service_id: the box to resize \u2014 numeric id or publicId.",
2437
- ' - size: target tier (e.g. "standard", "large", "xlarge").',
3011
+ ' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
2438
3012
  "",
2439
3013
  "Returns: { service } with the new plan.",
2440
3014
  "",
2441
3015
  'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
2442
3016
  ].join("\n"),
2443
3017
  input: {
2444
- service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The box to resize \u2014 numeric id or publicId."),
2445
- size: z13.string().min(1).describe('Target size tier, e.g. "standard", "large", "xlarge".')
3018
+ service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The box to resize \u2014 numeric id or publicId."),
3019
+ size: z14.enum(SERVICE_PLANS).describe(
3020
+ 'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
3021
+ )
2446
3022
  },
2447
3023
  handler: async (args2, ctx) => {
2448
3024
  const teamId = await ctx.resolveTeamId();
@@ -2461,7 +3037,9 @@ defineTool({
2461
3037
  name: "list_dev_environments",
2462
3038
  category: "services",
2463
3039
  description: [
2464
- `List the team's dev environments (the dashboard "Development" section), each annotated with the companion services attached to it.`,
3040
+ `List the team's cloud Dev Boxes (the dashboard "Dev Boxes" / Development section), each annotated with the companion services attached to it.`,
3041
+ "",
3042
+ "These are cloud Dev Boxes, NOT project deploy environments \u2014 to list a project's deploy targets (Production / Staging / Preview) use list_environments instead.",
2465
3043
  "",
2466
3044
  `When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
2467
3045
  "",
@@ -2488,22 +3066,62 @@ defineTool({
2488
3066
  });
2489
3067
  }
2490
3068
  });
3069
+ defineTool({
3070
+ name: "list_templates",
3071
+ category: "services",
3072
+ description: [
3073
+ "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.",
3074
+ "",
3075
+ "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.",
3076
+ "",
3077
+ 'Returns: { templates: [{ id, name, image, volume: { name, mountPath, sizeGb }, minPlan, companions }] }. Today there is one preset ("dev-environment").',
3078
+ "",
3079
+ 'Example: list_templates() \u2192 { templates: [{ id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] }] }'
3080
+ ].join("\n"),
3081
+ input: {},
3082
+ handler: async () => {
3083
+ const templates = [
3084
+ {
3085
+ id: "dev-environment",
3086
+ name: "AI Dev Environment",
3087
+ image: DEV_ENV_IMAGE,
3088
+ volume: {
3089
+ name: DEV_ENV_VOLUME.name,
3090
+ mountPath: DEV_ENV_VOLUME.mountPath,
3091
+ sizeGb: DEV_ENV_VOLUME.sizeGb
3092
+ },
3093
+ /** OOM-safe plan floor — a coding agent + build needs at least this. */
3094
+ minPlan: DEV_ENV_MIN_SIZE,
3095
+ /** Companion engines you can attach (fresh + empty), like local `make db-up`. */
3096
+ companions: ["postgres", "redis", "meilisearch"]
3097
+ }
3098
+ ];
3099
+ return respond({
3100
+ 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}").`,
3101
+ data: { templates }
3102
+ });
3103
+ }
3104
+ });
2491
3105
  defineTool({
2492
3106
  name: "create_standalone_dev_environment",
2493
3107
  category: "services",
2494
3108
  description: [
2495
- "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.",
3109
+ "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.",
3110
+ "",
3111
+ "This makes a cloud Dev Box, NOT a project deploy environment (create_environment \u2192 Production / Staging / Preview). It is a workspace you code in, not a deployment target.",
3112
+ "",
3113
+ 'Every Dev Box \u2014 with or without companions \u2014 also ships NATIVE Postgres 17, Redis and Meilisearch INSIDE the box, started with `dev-services up` (postgres :5432 as user "dev" with trust auth, redis :6379, meilisearch :7700). That is the no-Docker replacement for `make db-up` and is the right answer for local development inside the box; there is no Docker daemon available in there. The `companions` option is different \u2014 it attaches SEPARATE managed databases to the box\'s environment for when the box needs a real, persistent, backed-up datastore.',
2496
3114
  "",
2497
3115
  'When to use: "create a dev environment for <repo>", "spin me up a cloud dev box with a Postgres". Distinct from spin_up_dev_environment (which clones an EXISTING service) and create_dev_environment (a bare box in a chosen project).',
2498
3116
  "",
2499
3117
  "Inputs:",
2500
- " - name: the dev box name.",
3118
+ ' - name (optional): the Dev Box name. Omit it and the box is named after its source (the repo name, or "dev-box" when blank), with a numeric suffix if that name is taken \u2014 so "give me a dev box" needs no invented name.',
2501
3119
  ' - source_kind: "github_repo" (clone a connected repo \u2014 needs github_repo_id), "url" (clone any http(s) git URL \u2014 needs clone_url), or "blank" (empty box).',
2502
3120
  " - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
2503
3121
  " - clone_url (for url): an http(s) git clone URL.",
2504
3122
  " - branch (optional): branch to clone.",
2505
3123
  ' - databases (optional): companion services to attach \u2014 any of "postgres", "redis", "meilisearch".',
2506
- ' - plan (optional): box size (default "micro").',
3124
+ ' - 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.',
2507
3125
  " - 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.",
2508
3126
  "",
2509
3127
  "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.",
@@ -2511,17 +3129,21 @@ defineTool({
2511
3129
  'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
2512
3130
  ].join("\n"),
2513
3131
  input: {
2514
- name: z13.string().min(1).max(100).describe("Dev box name."),
2515
- source_kind: z13.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
2516
- github_repo_id: z13.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
2517
- clone_url: z13.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
2518
- branch: z13.string().min(1).max(255).optional().describe("Branch to clone."),
2519
- databases: z13.array(z13.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
2520
- plan: z13.enum(SERVICE_PLANS).optional().describe('Box size (default "micro").'),
2521
- agent_accounts: z13.array(
2522
- z13.object({
2523
- provider: z13.enum(["claude", "codex", "opencode"]),
2524
- account_id: z13.number().int().positive()
3132
+ name: z14.string().min(1).max(100).optional().describe(
3133
+ 'Dev Box name. Omit to have it named after the source (the repo name, or "dev-box" for a blank one), de-duplicated against existing boxes.'
3134
+ ),
3135
+ source_kind: z14.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
3136
+ github_repo_id: z14.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
3137
+ clone_url: z14.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
3138
+ branch: z14.string().min(1).max(255).optional().describe("Branch to clone."),
3139
+ databases: z14.array(z14.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
3140
+ plan: z14.enum(SERVICE_PLANS).optional().describe(
3141
+ 'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
3142
+ ),
3143
+ agent_accounts: z14.array(
3144
+ z14.object({
3145
+ provider: z14.enum(["claude", "codex", "opencode"]),
3146
+ account_id: z14.number().int().positive()
2525
3147
  })
2526
3148
  ).max(3).optional().describe(
2527
3149
  "Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
@@ -2558,7 +3180,7 @@ defineTool({
2558
3180
  source = { kind: "blank" };
2559
3181
  }
2560
3182
  const input = {
2561
- name: args2.name,
3183
+ ...args2.name ? { name: args2.name } : {},
2562
3184
  source,
2563
3185
  ...args2.databases ? { databases: args2.databases } : {},
2564
3186
  ...args2.plan ? { plan: args2.plan } : {},
@@ -2591,12 +3213,12 @@ defineTool({
2591
3213
  "Inputs:",
2592
3214
  ' - service_id: publicId of the service (e.g. "svc_abc123").',
2593
3215
  "",
2594
- "Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, restartPolicy, preDeployCommand, min/maxInstances, scale thresholds.",
3216
+ 'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
2595
3217
  "",
2596
3218
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
2597
3219
  ].join("\n"),
2598
3220
  input: {
2599
- service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
3221
+ service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
2600
3222
  },
2601
3223
  handler: async (args2, ctx) => {
2602
3224
  const teamId = await ctx.resolveTeamId();
@@ -2628,7 +3250,7 @@ defineTool({
2628
3250
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
2629
3251
  ].join("\n"),
2630
3252
  input: {
2631
- service_id: z13.string().describe("Service publicId.")
3253
+ service_id: z14.string().describe("Service publicId.")
2632
3254
  },
2633
3255
  handler: async (args2, ctx) => {
2634
3256
  const teamId = await ctx.resolveTeamId();
@@ -2657,9 +3279,9 @@ defineTool({
2657
3279
  'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
2658
3280
  ].join("\n"),
2659
3281
  input: {
2660
- service_id: z13.string().describe("Service publicId."),
2661
- from: z13.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
2662
- to: z13.string().optional().describe("ISO-8601 upper bound; defaults to now.")
3282
+ service_id: z14.string().describe("Service publicId."),
3283
+ from: z14.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
3284
+ to: z14.string().optional().describe("ISO-8601 upper bound; defaults to now.")
2663
3285
  },
2664
3286
  handler: async (args2, ctx) => {
2665
3287
  const teamId = await ctx.resolveTeamId();
@@ -2703,8 +3325,8 @@ defineTool({
2703
3325
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
2704
3326
  ].join("\n"),
2705
3327
  input: {
2706
- service_id: z13.string().describe("Service publicId."),
2707
- name: z13.string().min(1).max(60).describe("New service name (1\u201360 chars).")
3328
+ service_id: z14.string().describe("Service publicId."),
3329
+ name: z14.string().min(1).max(60).describe("New service name (1\u201360 chars).")
2708
3330
  },
2709
3331
  handler: async (args2, ctx) => {
2710
3332
  const teamId = await ctx.resolveTeamId();
@@ -2741,6 +3363,7 @@ defineTool({
2741
3363
  " - port (optional): integer 1\u201365535 \u2014 container port the platform forwards traffic to.",
2742
3364
  ' - protocol (optional): "http" | "tcp".',
2743
3365
  ' - restart_policy (optional): "always" | "on-failure" | "no".',
3366
+ ` - deploy_strategy (optional): "rolling" (default) | "recreate". SET THIS TO "recreate" for any single-container service that holds an exclusive lock on a mounted volume \u2014 a database running inside the container, an embedded queue, anything with a lockfile or a fixed host port on shared storage. Under the default rolling strategy those services CANNOT deploy at all: the new container can't open the datadir the outgoing container still holds, so it exits; because it never goes healthy the old one is never stopped; because the old one is never stopped the lock is never released. The deploy deadlocks for the whole grace period and then reports "Health check timed out". "recreate" stops the old container first, at the cost of a brief outage.`,
2744
3367
  " - pre_deploy_command (optional): shell command run before the new release accepts traffic (typical use: migrations).",
2745
3368
  " - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
2746
3369
  " - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
@@ -2752,37 +3375,40 @@ defineTool({
2752
3375
  'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
2753
3376
  ].join("\n"),
2754
3377
  input: {
2755
- service_id: z13.string().describe("Service publicId."),
2756
- install_command: z13.string().nullable().optional().describe("Install shell command. Null clears."),
2757
- build_command: z13.string().nullable().optional().describe("Build shell command. Null clears."),
2758
- start_command: z13.string().nullable().optional().describe("Start shell command. Null clears."),
2759
- branch: z13.string().optional().describe("Git branch to track."),
2760
- root_directory: z13.string().optional().describe("Build context root."),
2761
- dockerfile_path: z13.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
2762
- auto_deploy: z13.boolean().optional().describe("Auto-deploy on push."),
2763
- health_check_path: z13.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
2764
- health_check_enabled: z13.boolean().optional().describe("Toggle health checking on/off."),
2765
- health_check_interval: z13.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
2766
- health_check_timeout: z13.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
2767
- health_check_grace_period_sec: z13.number().int().min(1).max(1800).optional().describe(
3378
+ service_id: z14.string().describe("Service publicId."),
3379
+ install_command: z14.string().nullable().optional().describe("Install shell command. Null clears."),
3380
+ build_command: z14.string().nullable().optional().describe("Build shell command. Null clears."),
3381
+ start_command: z14.string().nullable().optional().describe("Start shell command. Null clears."),
3382
+ branch: z14.string().optional().describe("Git branch to track."),
3383
+ root_directory: z14.string().optional().describe("Build context root."),
3384
+ dockerfile_path: z14.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
3385
+ auto_deploy: z14.boolean().optional().describe("Auto-deploy on push."),
3386
+ health_check_path: z14.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
3387
+ health_check_enabled: z14.boolean().optional().describe("Toggle health checking on/off."),
3388
+ health_check_interval: z14.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
3389
+ health_check_timeout: z14.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
3390
+ health_check_grace_period_sec: z14.number().int().min(1).max(1800).optional().describe(
2768
3391
  "Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
2769
3392
  ),
2770
- memory_mb: z13.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
2771
- cpu_shares: z13.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
2772
- disk_size_gb: z13.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
2773
- port: z13.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
2774
- protocol: z13.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
2775
- restart_policy: z13.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
2776
- pre_deploy_command: z13.string().optional().describe("Shell command run before the new release accepts traffic."),
2777
- instance_count: z13.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
2778
- min_instances: z13.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
2779
- max_instances: z13.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
2780
- scale_cpu_threshold: z13.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
2781
- scale_memory_threshold: z13.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
2782
- log_filter_rules: z13.array(
2783
- z13.object({
2784
- pattern: z13.string().min(1).max(200),
2785
- action: z13.enum(["drop", "downgrade"])
3393
+ memory_mb: z14.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
3394
+ cpu_shares: z14.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
3395
+ disk_size_gb: z14.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
3396
+ port: z14.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
3397
+ protocol: z14.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
3398
+ restart_policy: z14.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
3399
+ deploy_strategy: z14.enum(["rolling", "recreate"]).optional().describe(
3400
+ 'How a deploy replaces the container. "rolling" (default) = start new, wait for healthy, switch traffic, stop old (zero downtime). "recreate" = stop old first, then start new (brief outage) \u2014 required for a container holding an exclusive lock on a mounted volume, which cannot deploy at all under rolling.'
3401
+ ),
3402
+ pre_deploy_command: z14.string().optional().describe("Shell command run before the new release accepts traffic."),
3403
+ instance_count: z14.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
3404
+ min_instances: z14.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
3405
+ max_instances: z14.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
3406
+ scale_cpu_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
3407
+ scale_memory_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
3408
+ log_filter_rules: z14.array(
3409
+ z14.object({
3410
+ pattern: z14.string().min(1).max(200),
3411
+ action: z14.enum(["drop", "downgrade"])
2786
3412
  })
2787
3413
  ).max(50).optional().describe(
2788
3414
  "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
@@ -2817,6 +3443,8 @@ defineTool({
2817
3443
  if (args2.port !== void 0) configUpdate["port"] = args2.port;
2818
3444
  if (args2.protocol !== void 0) configUpdate["protocol"] = args2.protocol;
2819
3445
  if (args2.restart_policy !== void 0) configUpdate["restartPolicy"] = args2.restart_policy;
3446
+ if (args2.deploy_strategy !== void 0)
3447
+ configUpdate["deployStrategy"] = args2.deploy_strategy;
2820
3448
  if (args2.pre_deploy_command !== void 0)
2821
3449
  configUpdate["preDeployCommand"] = args2.pre_deploy_command;
2822
3450
  if (args2.instance_count !== void 0) {
@@ -2877,7 +3505,7 @@ defineTool({
2877
3505
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
2878
3506
  ].join("\n"),
2879
3507
  input: {
2880
- service_id: z13.string().describe("Service publicId.")
3508
+ service_id: z14.string().describe("Service publicId.")
2881
3509
  },
2882
3510
  handler: async (args2, ctx) => {
2883
3511
  const teamId = await ctx.resolveTeamId();
@@ -2901,7 +3529,7 @@ defineTool({
2901
3529
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
2902
3530
  ].join("\n"),
2903
3531
  input: {
2904
- service_id: z13.string().describe("Service publicId.")
3532
+ service_id: z14.string().describe("Service publicId.")
2905
3533
  },
2906
3534
  handler: async (args2, ctx) => {
2907
3535
  const teamId = await ctx.resolveTeamId();
@@ -2909,6 +3537,35 @@ defineTool({
2909
3537
  return respond({ summary: `Resumed service ${args2.service_id}.`, data: { ok: true } });
2910
3538
  }
2911
3539
  });
3540
+ defineTool({
3541
+ name: "delete_service",
3542
+ category: "services",
3543
+ description: [
3544
+ "Permanently delete a service: stops and removes its containers, releases its hostname and routes, and cascade-deletes its attached volumes, env vars, domains and deploy history. Irreversible.",
3545
+ "",
3546
+ "When to use: the user explicitly asks to delete or remove a service, or you are cleaning up a service that was created by mistake or is no longer needed. Without this, a broken or abandoned service could only be removed from the dashboard \u2014 so a service that failed to come up had to be left in place and worked around by creating another one.",
3547
+ "",
3548
+ "ALWAYS confirm with the user before calling this on anything that has served traffic. To stop a service temporarily without losing it, use suspend_service instead. To tear down an agentic Dev Box (which also cascades its cloned database), use delete_dev_environment.",
3549
+ "",
3550
+ "Inputs:",
3551
+ " - service_id: publicId of the service to delete.",
3552
+ "",
3553
+ "Returns: { ok: true }.",
3554
+ "",
3555
+ 'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
3556
+ ].join("\n"),
3557
+ input: {
3558
+ service_id: z14.string().describe("Service publicId.")
3559
+ },
3560
+ handler: async (args2, ctx) => {
3561
+ const teamId = await ctx.resolveTeamId();
3562
+ await ctx.hoststack.services.delete(teamId, args2.service_id);
3563
+ return respond({
3564
+ summary: `Deleted service ${args2.service_id} and its attached resources. This cannot be undone.`,
3565
+ data: { ok: true }
3566
+ });
3567
+ }
3568
+ });
2912
3569
  defineTool({
2913
3570
  name: "get_service_logs",
2914
3571
  category: "logs",
@@ -2935,16 +3592,16 @@ defineTool({
2935
3592
  ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
2936
3593
  ].join("\n"),
2937
3594
  input: {
2938
- service_id: z13.string().describe("Service publicId."),
2939
- lines: z13.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
2940
- since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2941
- until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
2942
- stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
2943
- level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
3595
+ service_id: z14.string().describe("Service publicId."),
3596
+ lines: z14.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
3597
+ since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3598
+ until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3599
+ stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3600
+ level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
2944
3601
  "Filter by structured JSON log level (pino/bunyan/severity). Falls back to a stream-alias hint for plain-text logs (info/debug\u2192stdout, warn/error/fatal\u2192stderr)."
2945
3602
  ),
2946
- search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
2947
- count_only: z13.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
3603
+ search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
3604
+ count_only: z14.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
2948
3605
  },
2949
3606
  handler: async (args2, ctx) => {
2950
3607
  const teamId = await ctx.resolveTeamId();
@@ -2993,14 +3650,14 @@ defineTool({
2993
3650
  'Example: get_service_logs_bulk({ service_ids: ["svc_api", "svc_worker"], level: "error", since: "-15m", count_only: true }) \u2192 { results: { svc_api: { count: 0 }, svc_worker: { count: 12 } } }.'
2994
3651
  ].join("\n"),
2995
3652
  input: {
2996
- service_ids: z13.array(z13.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
2997
- lines_per_service: z13.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
2998
- since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2999
- until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3000
- stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3001
- level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3002
- search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
3003
- count_only: z13.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3653
+ service_ids: z14.array(z14.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
3654
+ lines_per_service: z14.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
3655
+ since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
3656
+ until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
3657
+ stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
3658
+ level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
3659
+ search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
3660
+ count_only: z14.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
3004
3661
  },
3005
3662
  handler: async (args2, ctx) => {
3006
3663
  const teamId = await ctx.resolveTeamId();
@@ -3046,7 +3703,8 @@ defineTool({
3046
3703
  });
3047
3704
 
3048
3705
  // src/tools/volumes.ts
3049
- import { z as z14 } from "zod";
3706
+ import { z as z15 } from "zod";
3707
+ var MIN_VOLUME_SIZE_GB = 10;
3050
3708
  defineTool({
3051
3709
  name: "list_volumes",
3052
3710
  category: "volumes",
@@ -3063,7 +3721,7 @@ defineTool({
3063
3721
  'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
3064
3722
  ].join("\n"),
3065
3723
  input: {
3066
- service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
3724
+ service_id: z15.string().describe("Service publicId (e.g. svc_abc123).")
3067
3725
  },
3068
3726
  handler: async (args2, ctx) => {
3069
3727
  const teamId = await ctx.resolveTeamId();
@@ -3085,17 +3743,24 @@ defineTool({
3085
3743
  " - service_id: publicId of the service to attach to.",
3086
3744
  " - name: lowercase alphanumeric + hyphens, \u226464 chars (used as the docker volume identifier \u2014 change with care once data is written).",
3087
3745
  ' - mount_path: in-container absolute path (e.g. "/var/data").',
3088
- " - size_gb: optional, 1\u2013100, default 1. Counts against your plan storage quota and is metered for billing.",
3746
+ " - size_gb: optional, 10\u2013100, default 10. 10 GB is the platform minimum (the underlying block storage cannot provision anything smaller) \u2014 a smaller value is rejected here rather than failing the next deploy. Metered for billing.",
3089
3747
  "",
3090
- "Returns: { volume: Volume } \u2014 the created record.",
3748
+ 'Returns: { volume: Volume } \u2014 the created record. A durable volume comes back `status: "pending"`; the disk is created and attached on the next deploy, which flips it to "active". A deploy is required before the mount exists.',
3091
3749
  "",
3092
- 'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" } }'
3750
+ 'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "pending" } }'
3093
3751
  ].join("\n"),
3094
3752
  input: {
3095
- service_id: z14.string().describe("Service publicId."),
3096
- name: z14.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
3097
- mount_path: z14.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
3098
- size_gb: z14.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
3753
+ service_id: z15.string().describe("Service publicId."),
3754
+ name: z15.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
3755
+ mount_path: z15.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
3756
+ // 10 GB is the real floor: the block-storage backend rejects anything
3757
+ // smaller. Advertising 1 GB here (and defaulting to it) meant taking the
3758
+ // defaults produced a volume that provisioned with `Hetzner API error:
3759
+ // 422` on the NEXT deploy, with nothing tying the failure back to the
3760
+ // size. Reject it at the call instead.
3761
+ size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
3762
+ `Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
3763
+ )
3099
3764
  },
3100
3765
  handler: async (args2, ctx) => {
3101
3766
  const teamId = await ctx.resolveTeamId();
@@ -3107,7 +3772,7 @@ defineTool({
3107
3772
  const response = await ctx.hoststack.volumes.create(teamId, args2.service_id, input);
3108
3773
  const data = { volume: shape(response.volume) };
3109
3774
  return respond({
3110
- summary: `Attached volume "${args2.name}" (${args2.size_gb ?? 1}GB) at ${args2.mount_path} on service ${args2.service_id}.`,
3775
+ summary: `Attached volume "${args2.name}" (${response.volume.sizeGb}GB) at ${args2.mount_path} on service ${args2.service_id}. Status is "${response.volume.status}" \u2014 deploy the service to provision and mount it.`,
3111
3776
  data
3112
3777
  });
3113
3778
  }
@@ -3131,10 +3796,10 @@ defineTool({
3131
3796
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
3132
3797
  ].join("\n"),
3133
3798
  input: {
3134
- service_id: z14.string().describe("Service publicId."),
3135
- volume_id: z14.string().describe("Volume publicId (e.g. vol_\u2026)."),
3136
- mount_path: z14.string().startsWith("/").max(500).optional().describe("New mount path."),
3137
- size_gb: z14.number().int().min(1).max(100).optional().describe("New size in GB.")
3799
+ service_id: z15.string().describe("Service publicId."),
3800
+ volume_id: z15.string().describe("Volume publicId (e.g. vol_\u2026)."),
3801
+ mount_path: z15.string().startsWith("/").max(500).optional().describe("New mount path."),
3802
+ size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
3138
3803
  },
3139
3804
  handler: async (args2, ctx) => {
3140
3805
  const teamId = await ctx.resolveTeamId();
@@ -3172,8 +3837,8 @@ defineTool({
3172
3837
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
3173
3838
  ].join("\n"),
3174
3839
  input: {
3175
- service_id: z14.string().describe("Service publicId."),
3176
- volume_id: z14.string().describe("Volume publicId.")
3840
+ service_id: z15.string().describe("Service publicId."),
3841
+ volume_id: z15.string().describe("Volume publicId.")
3177
3842
  },
3178
3843
  handler: async (args2, ctx) => {
3179
3844
  const teamId = await ctx.resolveTeamId();
@@ -3187,7 +3852,7 @@ defineTool({
3187
3852
 
3188
3853
  // src/server-factory.ts
3189
3854
  var PACKAGE_NAME = "hoststack";
3190
- var PACKAGE_VERSION = "0.9.1";
3855
+ var PACKAGE_VERSION = MCP_VERSION;
3191
3856
  function createMcpServer(options) {
3192
3857
  const baseUrl2 = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
3193
3858
  const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl: baseUrl2 });