@hoststack.dev/mcp 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,6 +2,10 @@
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { HostStack } from "@hoststack.dev/sdk";
4
4
 
5
+ // src/version.ts
6
+ var MCP_VERSION = true ? "0.15.0" : "0.0.0-dev";
7
+ var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
8
+
5
9
  // src/api-client.ts
6
10
  var ApiClient = class {
7
11
  constructor(apiKey, baseUrl) {
@@ -11,7 +15,8 @@ var ApiClient = class {
11
15
  get headers() {
12
16
  return {
13
17
  Authorization: `Bearer ${this.apiKey}`,
14
- "Content-Type": "application/json"
18
+ "Content-Type": "application/json",
19
+ "User-Agent": USER_AGENT
15
20
  };
16
21
  }
17
22
  async get(path, params) {
@@ -1054,10 +1059,20 @@ defineTool({
1054
1059
 
1055
1060
  // src/tools/dns-records.ts
1056
1061
  import { z as z7 } from "zod";
1057
- var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "ALIAS"];
1058
- async function resolveZonePublicId(api, teamId, input) {
1062
+ var DNS_RECORD_TYPES = [
1063
+ "A",
1064
+ "AAAA",
1065
+ "CNAME",
1066
+ "MX",
1067
+ "TXT",
1068
+ "NS",
1069
+ "SRV",
1070
+ "CAA",
1071
+ "ALIAS"
1072
+ ];
1073
+ async function resolveZonePublicId(hoststack, teamId, input) {
1059
1074
  if (input.zone_id) {
1060
- const { zones: zones2 } = await api.get(`/api/dns-zones/${teamId}`);
1075
+ const { zones: zones2 } = await hoststack.dns.listZones(teamId);
1061
1076
  const match = zones2.find((z15) => z15.publicId === input.zone_id);
1062
1077
  if (!match) {
1063
1078
  throw new Error(`Zone ${input.zone_id} not found on this team.`);
@@ -1067,7 +1082,7 @@ async function resolveZonePublicId(api, teamId, input) {
1067
1082
  if (!input.domain) {
1068
1083
  throw new Error("Provide either zone_id or domain.");
1069
1084
  }
1070
- const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1085
+ const { zones } = await hoststack.dns.listZones(teamId);
1071
1086
  const fqdn = input.domain.toLowerCase().replace(/\.$/, "");
1072
1087
  const labels = fqdn.split(".");
1073
1088
  for (let i = 0; i < labels.length - 1; i++) {
@@ -1096,7 +1111,7 @@ defineTool({
1096
1111
  input: {},
1097
1112
  handler: async (_args, ctx) => {
1098
1113
  const teamId = await ctx.resolveTeamId();
1099
- const response = await ctx.api.get(`/api/dns-zones/${teamId}`);
1114
+ const response = await ctx.hoststack.dns.listZones(teamId);
1100
1115
  const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
1101
1116
  const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.`;
1102
1117
  return respond({ summary, data: { items } });
@@ -1125,10 +1140,8 @@ defineTool({
1125
1140
  const zoneInput = {};
1126
1141
  if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
1127
1142
  if (args.domain !== void 0) zoneInput.domain = args.domain;
1128
- const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1129
- const response = await ctx.api.get(
1130
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1131
- );
1143
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1144
+ const response = await ctx.hoststack.dns.listRecords(teamId, zone.publicId);
1132
1145
  const items = Array.isArray(response.records) ? response.records.map(shape) : [];
1133
1146
  const summary = items.length === 0 ? `Zone ${zone.domainName} has no records yet.` : `Returned ${items.length} record${items.length === 1 ? "" : "s"} for ${zone.domainName}.`;
1134
1147
  return respond({
@@ -1157,11 +1170,9 @@ defineTool({
1157
1170
  },
1158
1171
  handler: async (args, ctx) => {
1159
1172
  const teamId = await ctx.resolveTeamId();
1160
- const { zones } = await ctx.api.get(`/api/dns-zones/${teamId}`);
1173
+ const { zones } = await ctx.hoststack.dns.listZones(teamId);
1161
1174
  for (const zone of zones) {
1162
- const { records } = await ctx.api.get(
1163
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1164
- );
1175
+ const { records } = await ctx.hoststack.dns.listRecords(teamId, zone.publicId);
1165
1176
  const match = records.find((r) => r.publicId === args.record_id);
1166
1177
  if (match) {
1167
1178
  return respond({
@@ -1217,7 +1228,7 @@ defineTool({
1217
1228
  const zoneInput = {};
1218
1229
  if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
1219
1230
  if (args.domain !== void 0) zoneInput.domain = args.domain;
1220
- const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1231
+ const zone = await resolveZonePublicId(ctx.hoststack, teamId, zoneInput);
1221
1232
  const body = {
1222
1233
  type: args.type,
1223
1234
  name: args.name,
@@ -1225,10 +1236,7 @@ defineTool({
1225
1236
  };
1226
1237
  if (args.ttl !== void 0) body.ttl = args.ttl;
1227
1238
  if (args.priority !== void 0) body.priority = args.priority;
1228
- const response = await ctx.api.post(
1229
- `/api/dns-zones/${teamId}/${zone.publicId}/records`,
1230
- body
1231
- );
1239
+ const response = await ctx.hoststack.dns.createRecord(teamId, zone.publicId, body);
1232
1240
  return respond({
1233
1241
  summary: `Created ${args.type} ${args.name} on ${zone.domainName}.`,
1234
1242
  data: {
@@ -1268,7 +1276,7 @@ defineTool({
1268
1276
  return respondError(`Priority is required for ${args.type} records (0\u201365535).`);
1269
1277
  }
1270
1278
  const teamId = await ctx.resolveTeamId();
1271
- const target = await findRecordZone(ctx.api, teamId, args.record_id);
1279
+ const target = await findRecordZone(ctx.hoststack, teamId, args.record_id);
1272
1280
  if (!target) {
1273
1281
  return respondError(
1274
1282
  `Record ${args.record_id} not found on any zone owned by this team.`
@@ -1281,8 +1289,10 @@ defineTool({
1281
1289
  };
1282
1290
  if (args.ttl !== void 0) body.ttl = args.ttl;
1283
1291
  if (args.priority !== void 0) body.priority = args.priority;
1284
- const response = await ctx.api.put(
1285
- `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`,
1292
+ const response = await ctx.hoststack.dns.updateRecord(
1293
+ teamId,
1294
+ target.zone.publicId,
1295
+ args.record_id,
1286
1296
  body
1287
1297
  );
1288
1298
  return respond({
@@ -1317,27 +1327,23 @@ defineTool({
1317
1327
  },
1318
1328
  handler: async (args, ctx) => {
1319
1329
  const teamId = await ctx.resolveTeamId();
1320
- const target = await findRecordZone(ctx.api, teamId, args.record_id);
1330
+ const target = await findRecordZone(ctx.hoststack, teamId, args.record_id);
1321
1331
  if (!target) {
1322
1332
  return respondError(
1323
1333
  `Record ${args.record_id} not found on any zone owned by this team.`
1324
1334
  );
1325
1335
  }
1326
- await ctx.api.delete(
1327
- `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`
1328
- );
1336
+ await ctx.hoststack.dns.deleteRecord(teamId, target.zone.publicId, args.record_id);
1329
1337
  return respond({
1330
1338
  summary: `Deleted record ${args.record_id} from ${target.zone.domainName}.`,
1331
1339
  data: { ok: true }
1332
1340
  });
1333
1341
  }
1334
1342
  });
1335
- async function findRecordZone(api, teamId, recordPublicId) {
1336
- const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1343
+ async function findRecordZone(hoststack, teamId, recordPublicId) {
1344
+ const { zones } = await hoststack.dns.listZones(teamId);
1337
1345
  for (const zone of zones) {
1338
- const { records } = await api.get(
1339
- `/api/dns-zones/${teamId}/${zone.publicId}/records`
1340
- );
1346
+ const { records } = await hoststack.dns.listRecords(teamId, zone.publicId);
1341
1347
  const match = records.find((r) => r.publicId === recordPublicId);
1342
1348
  if (match) return { zone, record: match };
1343
1349
  }
@@ -1497,6 +1503,7 @@ defineTool({
1497
1503
  ' - key: env-var name (e.g. "DATABASE_URL").',
1498
1504
  " - value: new value (will be encrypted at rest if is_secret=true).",
1499
1505
  " - 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.",
1506
+ ' - target (optional): where the var is injected \u2014 "build", "runtime", or "both". On create, defaults to "both". On update, omitting it leaves the existing target untouched.',
1500
1507
  "",
1501
1508
  'Returns: { envVar: EnvVar, action: "created" | "updated" }.',
1502
1509
  "",
@@ -1506,7 +1513,8 @@ defineTool({
1506
1513
  service_id: z9.string().describe("Service publicId."),
1507
1514
  key: z9.string().min(1).max(128).describe("Env-var key."),
1508
1515
  value: z9.string().describe("New value."),
1509
- is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
1516
+ is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
1517
+ target: z9.enum(["build", "runtime", "both"]).optional().describe("Injection target: build, runtime, or both. Default both.")
1510
1518
  },
1511
1519
  handler: async (args, ctx) => {
1512
1520
  const teamId = await ctx.resolveTeamId();
@@ -1515,6 +1523,7 @@ defineTool({
1515
1523
  if (match) {
1516
1524
  const updatePayload = { value: args.value };
1517
1525
  if (args.is_secret !== void 0) updatePayload.isSecret = args.is_secret;
1526
+ if (args.target !== void 0) updatePayload.target = args.target;
1518
1527
  const response2 = await ctx.hoststack.envVars.update(
1519
1528
  teamId,
1520
1529
  args.service_id,
@@ -1530,7 +1539,8 @@ defineTool({
1530
1539
  const response = await ctx.hoststack.envVars.create(teamId, args.service_id, {
1531
1540
  key: args.key,
1532
1541
  value: args.value,
1533
- isSecret: args.is_secret ?? true
1542
+ isSecret: args.is_secret ?? true,
1543
+ ...args.target !== void 0 ? { target: args.target } : {}
1534
1544
  });
1535
1545
  const data = {
1536
1546
  envVar: shapeEnvVar(response.envVar),
@@ -1586,7 +1596,7 @@ defineTool({
1586
1596
  "",
1587
1597
  "Inputs:",
1588
1598
  " - service_id: publicId of the service.",
1589
- " - env_vars: array of { key, value, is_secret? }. is_secret defaults to true per row.",
1599
+ ' - env_vars: array of { key, value, is_secret?, target? }. is_secret defaults to FALSE per row (a row is stored in the clear unless you set is_secret:true); target defaults to "both". Note this differs from set_env_var, which defaults a newly-created var to secret.',
1590
1600
  "",
1591
1601
  "Returns: { ok: true }. Re-list with list_env_vars to confirm the new state.",
1592
1602
  "",
@@ -1598,7 +1608,8 @@ defineTool({
1598
1608
  z9.object({
1599
1609
  key: z9.string().min(1).max(128),
1600
1610
  value: z9.string(),
1601
- is_secret: z9.boolean().optional()
1611
+ is_secret: z9.boolean().optional(),
1612
+ target: z9.enum(["build", "runtime", "both"]).optional()
1602
1613
  })
1603
1614
  ).max(500).describe("Array of env-var rows. Hard cap 500.")
1604
1615
  },
@@ -1611,6 +1622,7 @@ defineTool({
1611
1622
  value: v.value
1612
1623
  };
1613
1624
  if (v.is_secret !== void 0) row.isSecret = v.is_secret;
1625
+ if (v.target !== void 0) row.target = v.target;
1614
1626
  return row;
1615
1627
  })
1616
1628
  };
@@ -1632,6 +1644,10 @@ defineTool({
1632
1644
  "",
1633
1645
  "When to use: the user wants to see what envs exist before creating a service in one or promoting a deploy. Every project has at least Production.",
1634
1646
  "",
1647
+ // §2.2: these are project deployment environments, not cloud Dev Boxes — point agents at the
1648
+ // right surface so the two "dev" concepts stay distinct (bidirectional cross-reference).
1649
+ "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.",
1650
+ "",
1635
1651
  "Inputs:",
1636
1652
  ' - project_id: project publicId (e.g. "prj_abc123").',
1637
1653
  "",
@@ -1658,10 +1674,15 @@ defineTool({
1658
1674
  "",
1659
1675
  "When to use: the user wants to add an env so they can run a sibling service alongside production for testing or staging before release.",
1660
1676
  "",
1677
+ // §2.1/§2.2: steer callers away from the agentic cloud Dev Box tools (which live in
1678
+ // services.ts) — an environment is a deployment target, NOT a Dev Box. Bidirectional with
1679
+ // the cross-references those dev-box tools point back here.
1680
+ '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.',
1681
+ "",
1661
1682
  "Inputs:",
1662
1683
  " - project_id: project publicId.",
1663
1684
  " - name: human-readable name (1\u201364 chars). Shown in the env switcher.",
1664
- ' - type: "production" | "staging" | "development" | "preview". Determines the hostname suffix on services in this env (production stays clean; others get -staging / -dev / -preview).',
1685
+ ' - type: "production" | "staging" | "development" | "preview". Determines the hostname suffix on services in this env (production stays clean; others get -staging / -dev / -preview). type="development" creates a PROJECT deployment environment (a -dev hostname suffix), NOT an agentic Dev Box \u2014 for a cloud dev box use create_dev_environment / create_standalone_dev_environment / spin_up_dev_environment.',
1665
1686
  " - is_protected (optional): require admin role for destructive actions in this env. Default false.",
1666
1687
  "",
1667
1688
  "Returns: { environment: Environment }.",
@@ -1671,7 +1692,10 @@ defineTool({
1671
1692
  input: {
1672
1693
  project_id: z10.string().describe("Project publicId."),
1673
1694
  name: z10.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
1674
- type: z10.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
1695
+ type: z10.enum(["production", "staging", "development", "preview"]).describe(
1696
+ // §2.1: "development" here is a project deployment env, NOT a cloud Dev Box.
1697
+ '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.'
1698
+ ),
1675
1699
  is_protected: z10.boolean().optional().describe("Require admin role for destructive actions. Default false.")
1676
1700
  },
1677
1701
  handler: async (args, ctx) => {
@@ -1763,6 +1787,14 @@ defineTool({
1763
1787
  });
1764
1788
 
1765
1789
  // src/tools/meta.ts
1790
+ var DEV_ENV_TOOL_NAMES = [
1791
+ "create_dev_environment",
1792
+ "create_standalone_dev_environment",
1793
+ "spin_up_dev_environment",
1794
+ "list_dev_environments",
1795
+ "resize_dev_environment",
1796
+ "delete_dev_environment"
1797
+ ];
1766
1798
  defineTool({
1767
1799
  name: "get_me",
1768
1800
  category: "meta",
@@ -1787,6 +1819,36 @@ defineTool({
1787
1819
  return respond({ summary: `Authenticated as ${userEmail}${teamLabel}.`, data });
1788
1820
  }
1789
1821
  });
1822
+ defineTool({
1823
+ name: "describe_mcp",
1824
+ category: "meta",
1825
+ description: [
1826
+ "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.",
1827
+ "",
1828
+ "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).",
1829
+ "",
1830
+ '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.',
1831
+ "",
1832
+ "Inputs: none.",
1833
+ "",
1834
+ "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.",
1835
+ "",
1836
+ 'Example: describe_mcp() \u2192 { version: "0.14.0", toolCount: 30, devEnvTools: ["create_dev_environment", \u2026], devEnvToolsPresent: true }'
1837
+ ].join("\n"),
1838
+ input: {},
1839
+ handler: async (_args, _ctx) => {
1840
+ const registered = new Set(listToolDefinitions().map((t) => t.name));
1841
+ const devEnvToolsPresent = DEV_ENV_TOOL_NAMES.every((name) => registered.has(name));
1842
+ const data = {
1843
+ version: MCP_VERSION,
1844
+ toolCount: registered.size,
1845
+ devEnvTools: [...DEV_ENV_TOOL_NAMES],
1846
+ devEnvToolsPresent
1847
+ };
1848
+ 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).`;
1849
+ return respond({ summary, data });
1850
+ }
1851
+ });
1790
1852
 
1791
1853
  // src/tools/notifications.ts
1792
1854
  import { z as z11 } from "zod";
@@ -2111,11 +2173,19 @@ var SERVICE_PLANS = [
2111
2173
  "pico",
2112
2174
  "nano",
2113
2175
  "micro",
2114
- "starter",
2176
+ "small",
2115
2177
  "standard",
2178
+ "large",
2179
+ "xlarge",
2116
2180
  "pro_standard",
2117
2181
  "pro_large"
2118
2182
  ];
2183
+ var DEV_ENV_MIN_SIZE = "standard";
2184
+ function coerceDevEnvSize(value) {
2185
+ const minIdx = SERVICE_PLANS.indexOf(DEV_ENV_MIN_SIZE);
2186
+ const idx = value ? SERVICE_PLANS.indexOf(value) : -1;
2187
+ return idx >= minIdx ? value : DEV_ENV_MIN_SIZE;
2188
+ }
2119
2189
  defineTool({
2120
2190
  name: "list_services",
2121
2191
  category: "services",
@@ -2124,11 +2194,14 @@ defineTool({
2124
2194
  "",
2125
2195
  "When to use: the agent needs to find a service by name, check what is deployed, or pick a target for a follow-up tool (logs, deploys, env vars). This is the canonical way to resolve a publicId from a human-friendly name.",
2126
2196
  "",
2197
+ "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.",
2198
+ "",
2127
2199
  "Inputs (all optional):",
2128
2200
  ' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
2129
2201
  ' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
2130
2202
  ' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
2131
2203
  ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
2204
+ " - dev_environment: include Dev Boxes in the results (excluded by default).",
2132
2205
  "",
2133
2206
  "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
2134
2207
  "",
@@ -2138,11 +2211,15 @@ defineTool({
2138
2211
  project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
2139
2212
  environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
2140
2213
  status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
2141
- type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
2214
+ type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
2215
+ dev_environment: z13.boolean().optional().describe(
2216
+ "Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
2217
+ )
2142
2218
  },
2143
2219
  handler: async (args, ctx) => {
2144
2220
  const teamId = await ctx.resolveTeamId();
2145
2221
  const filters = {};
2222
+ if (args.dev_environment) filters.devEnvironment = true;
2146
2223
  if (args.project_id !== void 0) {
2147
2224
  const resolved = await ctx.hoststack.resolveId(args.project_id, {
2148
2225
  kind: "project",
@@ -2165,7 +2242,8 @@ defineTool({
2165
2242
  args.project_id !== void 0 ? `project=${args.project_id}` : null,
2166
2243
  args.environment_id !== void 0 ? `env=${args.environment_id}` : null,
2167
2244
  args.status ? `status=${args.status}` : null,
2168
- args.type ? `type=${args.type}` : null
2245
+ args.type ? `type=${args.type}` : null,
2246
+ args.dev_environment ? "devBoxes=included" : null
2169
2247
  ].filter(Boolean).join(", ");
2170
2248
  const summary = data.items.length === 0 ? filterDesc ? `No services match the given filters (${filterDesc}).` : "No services yet \u2014 create one with create_service (or create_dev_environment for an AI dev box), or via the dashboard." : `Found ${data.items.length} service${data.items.length === 1 ? "" : "s"}${filterDesc ? ` matching ${filterDesc}` : ""}.`;
2171
2249
  return respond({ summary, data });
@@ -2256,14 +2334,16 @@ defineTool({
2256
2334
  name: "create_dev_environment",
2257
2335
  category: "services",
2258
2336
  description: [
2259
- "Spin up an AI dev environment in one call: a private service from the agentic dev-env image (Claude Code + Codex + OpenCode + MCPs preinstalled), with a persistent /workspace volume and the MCP API keys set, then fire the first deploy.",
2337
+ '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.',
2338
+ "",
2339
+ '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.',
2260
2340
  "",
2261
- `When to use: the user wants a cloud terminal / dev box they can drive coding agents in (from a desk or a phone). This mirrors the dashboard's "AI Dev Environment" wizard preset \u2014 create (deploy deferred) \u2192 set keys \u2192 attach /workspace \u2192 deploy, so the first container boots with its volume and keys already in place.`,
2341
+ `When to use: the user wants a cloud terminal / Dev Box they can drive coding agents in (from a desk or a phone). This mirrors the dashboard's "AI Dev Environment" wizard preset \u2014 create (deploy deferred) \u2192 set keys \u2192 attach /workspace \u2192 deploy, so the first container boots with its volume and keys already in place.`,
2262
2342
  "",
2263
2343
  "Inputs:",
2264
2344
  ' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
2265
2345
  ' - name (optional): service name (default "dev-environment").',
2266
- ' - plan (optional): service size (default "standard" \u2014 2 GB, the smallest that fits a coding agent + build).',
2346
+ ' - plan (optional): box size. Defaults to "standard" (2 GB) \u2014 the OOM-safe floor (DEV_ENV_MIN_SIZE); a coding agent + build OOMs below it. A smaller plan is clamped up to "standard".',
2267
2347
  " - disk_gb (optional): /workspace volume size in GB (default 10, 1\u2013100).",
2268
2348
  " - hoststack_api_key (optional): sets HOSTSTACK_API_KEY so the hoststack MCP works inside the container.",
2269
2349
  " - poststack_api_key (optional): sets POSTSTACK_API_KEY so the poststack MCP works inside the container.",
@@ -2277,7 +2357,7 @@ defineTool({
2277
2357
  project_id: z13.union([z13.number().int().positive(), z13.string()]).describe("Target project \u2014 numeric id or publicId."),
2278
2358
  name: z13.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
2279
2359
  plan: z13.enum(SERVICE_PLANS).optional().describe(
2280
- 'Service size (default "standard" \u2014 2 GB; smallest that fits a coding agent).'
2360
+ 'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
2281
2361
  ),
2282
2362
  disk_gb: z13.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2283
2363
  hoststack_api_key: z13.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
@@ -2295,68 +2375,94 @@ defineTool({
2295
2375
  });
2296
2376
  const name = args.name ?? "dev-environment";
2297
2377
  const sizeGb = args.disk_gb ?? DEV_ENV_VOLUME.sizeGb;
2378
+ const plan = coerceDevEnvSize(args.plan);
2298
2379
  const createInput = {
2299
2380
  name,
2300
2381
  type: "private_service",
2301
2382
  projectId,
2302
2383
  dockerImage: DEV_ENV_IMAGE,
2303
- autoDeploy: false
2384
+ autoDeploy: false,
2385
+ plan
2304
2386
  };
2305
- if (args.plan !== void 0) createInput.plan = args.plan;
2306
2387
  const created = await ctx.hoststack.services.create(teamId, createInput);
2307
2388
  const service = created.service;
2308
- const envVars = [];
2309
- if (args.hoststack_api_key)
2310
- envVars.push({
2311
- key: "HOSTSTACK_API_KEY",
2312
- value: args.hoststack_api_key,
2313
- isSecret: true
2314
- });
2315
- if (args.poststack_api_key)
2316
- envVars.push({
2317
- key: "POSTSTACK_API_KEY",
2318
- value: args.poststack_api_key,
2319
- isSecret: true
2320
- });
2321
- if (args.repo_url) {
2322
- envVars.push({
2323
- key: "HOSTSTACK_DEVENV_REPO_URL",
2324
- value: args.repo_url,
2325
- isSecret: false
2326
- });
2327
- if (args.branch)
2389
+ const rollback = async () => {
2390
+ try {
2391
+ await ctx.hoststack.services.tearDownDevEnvironment(teamId, service.id);
2392
+ } catch {
2393
+ try {
2394
+ await ctx.hoststack.services.delete(teamId, service.id);
2395
+ } catch {
2396
+ }
2397
+ }
2398
+ };
2399
+ try {
2400
+ const envVars = [];
2401
+ if (args.hoststack_api_key)
2328
2402
  envVars.push({
2329
- key: "HOSTSTACK_DEVENV_BRANCH",
2330
- value: args.branch,
2403
+ key: "HOSTSTACK_API_KEY",
2404
+ value: args.hoststack_api_key,
2405
+ isSecret: true
2406
+ });
2407
+ if (args.poststack_api_key)
2408
+ envVars.push({
2409
+ key: "POSTSTACK_API_KEY",
2410
+ value: args.poststack_api_key,
2411
+ isSecret: true
2412
+ });
2413
+ if (args.repo_url) {
2414
+ envVars.push({
2415
+ key: "HOSTSTACK_DEVENV_REPO_URL",
2416
+ value: args.repo_url,
2331
2417
  isSecret: false
2332
2418
  });
2333
- }
2334
- if (envVars.length > 0) {
2335
- await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2336
- }
2337
- let volumeAttached = false;
2338
- try {
2339
- await ctx.hoststack.volumes.create(teamId, service.id, {
2340
- name: DEV_ENV_VOLUME.name,
2341
- mountPath: DEV_ENV_VOLUME.mountPath,
2342
- sizeGb
2419
+ if (args.branch)
2420
+ envVars.push({
2421
+ key: "HOSTSTACK_DEVENV_BRANCH",
2422
+ value: args.branch,
2423
+ isSecret: false
2424
+ });
2425
+ }
2426
+ if (envVars.length > 0) {
2427
+ await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2428
+ }
2429
+ try {
2430
+ await ctx.hoststack.volumes.create(teamId, service.id, {
2431
+ name: DEV_ENV_VOLUME.name,
2432
+ mountPath: DEV_ENV_VOLUME.mountPath,
2433
+ sizeGb
2434
+ });
2435
+ } catch (error) {
2436
+ const reason = error instanceof Error ? error.message : String(error);
2437
+ await rollback();
2438
+ return respondError(
2439
+ `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}`,
2440
+ { volumeError: reason, rolledBack: true }
2441
+ );
2442
+ }
2443
+ const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2444
+ const deployId = deploy.deploy?.id ?? null;
2445
+ return respond({
2446
+ 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.`,
2447
+ data: { service: shapeService(service), volumeAttached: true, deployId }
2343
2448
  });
2344
- volumeAttached = true;
2345
- } catch {
2449
+ } catch (error) {
2450
+ const reason = error instanceof Error ? error.message : String(error);
2451
+ await rollback();
2452
+ return respondError(
2453
+ `Failed to provision Dev Box "${name}" \u2014 rolled back the partially-created box. Error: ${reason}`,
2454
+ { rolledBack: true }
2455
+ );
2346
2456
  }
2347
- const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2348
- const deployId = deploy.deploy?.id ?? null;
2349
- return respond({
2350
- summary: `Created AI dev environment "${name}" (${service.publicId})${volumeAttached ? " with a /workspace volume" : ""} \u2014 deploying. Open it in the dashboard's Development section (or the service's Terminal tab) once running.`,
2351
- data: { service: shapeService(service), volumeAttached, deployId }
2352
- });
2353
2457
  }
2354
2458
  });
2355
2459
  defineTool({
2356
2460
  name: "spin_up_dev_environment",
2357
2461
  category: "services",
2358
2462
  description: [
2359
- "Spin up an agentic dev environment FROM an existing service so you can reproduce a bug, fix it, view it, and ship it. Creates a dev box (Claude/Codex/OpenCode + MCPs) that RUNS a clone of the app: the repo is auto-cloned into /workspace, the service env-vars are copied, and its linked database is cloned (so the box never touches the prod DB). The box gets an unguessable public dev URL and seamless `git push`.",
2463
+ "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`.",
2464
+ "",
2465
+ "This makes a cloud Dev Box, NOT a project deploy environment \u2014 for a -dev/-staging deploy target use create_environment instead.",
2360
2466
  "",
2361
2467
  'When to use: "spin up a dev environment for <service>", "I found a bug on <service>, give me a box to fix it". Distinct from create_dev_environment, which makes a BARE box not tied to any app.',
2362
2468
  "",
@@ -2399,7 +2505,9 @@ defineTool({
2399
2505
  name: "delete_dev_environment",
2400
2506
  category: "services",
2401
2507
  description: [
2402
- "Tear down an agentic dev environment in one call: removes the dev box AND cascade-deletes its cloned database, its /workspace volume, and the auto-created `development` environment if it is now empty.",
2508
+ "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.",
2509
+ "",
2510
+ "This deletes a cloud Dev Box, NOT a project deploy environment \u2014 to remove a -dev/-staging deploy target use delete_environment instead.",
2403
2511
  "",
2404
2512
  'When to use: "delete / tear down the dev environment", once you have shipped the fix and no longer need the box.',
2405
2513
  "",
@@ -2430,15 +2538,17 @@ defineTool({
2430
2538
  name: "resize_dev_environment",
2431
2539
  category: "services",
2432
2540
  description: [
2433
- "Resize a dev box (or any service) to a different size tier \u2014 the supported way to give it more memory/CPU/disk headroom (e.g. when ESLint/tsc OOMs).",
2541
+ "Resize a cloud Dev Box (or any service) to a different size tier \u2014 the supported way to give it more memory/CPU/disk headroom (e.g. when ESLint/tsc OOMs).",
2434
2542
  "",
2435
- "When to use: a dev box OOM-killed (see exitReason/recommendedSize from list_dev_environments), or you just want more headroom. This changes the SIZE TIER \u2014 unlike per-config memory/CPU overrides, which are clamped to the current tier and so cannot grow a box past it.",
2543
+ "This resizes a cloud Dev Box, NOT a project deploy environment.",
2544
+ "",
2545
+ "When to use: a Dev Box OOM-killed (see exitReason/recommendedSize from list_dev_environments), or you just want more headroom. This changes the SIZE TIER \u2014 unlike per-config memory/CPU overrides, which are clamped to the current tier and so cannot grow a box past it.",
2436
2546
  "",
2437
2547
  "How it applies: the new tier's memory + CPU take effect LIVE on the running container (no recreate, no dropped shell sessions); a larger disk takes effect on the next recreate (suspend\u2192resume). Dev boxes are floored to the OOM-safe minimum size server-side.",
2438
2548
  "",
2439
2549
  "Inputs:",
2440
2550
  " - service_id: the box to resize \u2014 numeric id or publicId.",
2441
- ' - size: target tier (e.g. "standard", "large", "xlarge").',
2551
+ ' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
2442
2552
  "",
2443
2553
  "Returns: { service } with the new plan.",
2444
2554
  "",
@@ -2446,7 +2556,9 @@ defineTool({
2446
2556
  ].join("\n"),
2447
2557
  input: {
2448
2558
  service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The box to resize \u2014 numeric id or publicId."),
2449
- size: z13.string().min(1).describe('Target size tier, e.g. "standard", "large", "xlarge".')
2559
+ size: z13.enum(SERVICE_PLANS).describe(
2560
+ 'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
2561
+ )
2450
2562
  },
2451
2563
  handler: async (args, ctx) => {
2452
2564
  const teamId = await ctx.resolveTeamId();
@@ -2465,7 +2577,9 @@ defineTool({
2465
2577
  name: "list_dev_environments",
2466
2578
  category: "services",
2467
2579
  description: [
2468
- `List the team's dev environments (the dashboard "Development" section), each annotated with the companion services attached to it.`,
2580
+ `List the team's cloud Dev Boxes (the dashboard "Dev Boxes" / Development section), each annotated with the companion services attached to it.`,
2581
+ "",
2582
+ "These are cloud Dev Boxes, NOT project deploy environments \u2014 to list a project's deploy targets (Production / Staging / Preview) use list_environments instead.",
2469
2583
  "",
2470
2584
  `When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
2471
2585
  "",
@@ -2492,22 +2606,60 @@ defineTool({
2492
2606
  });
2493
2607
  }
2494
2608
  });
2609
+ defineTool({
2610
+ name: "list_templates",
2611
+ category: "services",
2612
+ description: [
2613
+ "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.",
2614
+ "",
2615
+ "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.",
2616
+ "",
2617
+ 'Returns: { templates: [{ id, name, image, volume: { name, mountPath, sizeGb }, minPlan, companions }] }. Today there is one preset ("dev-environment").',
2618
+ "",
2619
+ 'Example: list_templates() \u2192 { templates: [{ id: "dev-environment", image: "hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] }] }'
2620
+ ].join("\n"),
2621
+ input: {},
2622
+ handler: async () => {
2623
+ const templates = [
2624
+ {
2625
+ id: "dev-environment",
2626
+ name: "AI Dev Environment",
2627
+ image: DEV_ENV_IMAGE,
2628
+ volume: {
2629
+ name: DEV_ENV_VOLUME.name,
2630
+ mountPath: DEV_ENV_VOLUME.mountPath,
2631
+ sizeGb: DEV_ENV_VOLUME.sizeGb
2632
+ },
2633
+ /** OOM-safe plan floor — a coding agent + build needs at least this. */
2634
+ minPlan: DEV_ENV_MIN_SIZE,
2635
+ /** Companion engines you can attach (fresh + empty), like local `make db-up`. */
2636
+ companions: ["postgres", "redis", "meilisearch"]
2637
+ }
2638
+ ];
2639
+ return respond({
2640
+ 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}").`,
2641
+ data: { templates }
2642
+ });
2643
+ }
2644
+ });
2495
2645
  defineTool({
2496
2646
  name: "create_standalone_dev_environment",
2497
2647
  category: "services",
2498
2648
  description: [
2499
- "Create a STANDALONE dev environment in one call: a cloud box (Claude Code + Codex + OpenCode + MCPs) on a persistent /workspace, from a connected GitHub repo, an arbitrary clone URL, or blank \u2014 with optional companion Postgres / Redis / Meilisearch wired into its env (mirrors a local `make db-up`). The box lives in the team's hidden Development home, NOT under a project.",
2649
+ "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.",
2650
+ "",
2651
+ "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.",
2500
2652
  "",
2501
2653
  'When to use: "create a dev environment for <repo>", "spin me up a cloud dev box with a Postgres". Distinct from spin_up_dev_environment (which clones an EXISTING service) and create_dev_environment (a bare box in a chosen project).',
2502
2654
  "",
2503
2655
  "Inputs:",
2504
- " - name: the dev box name.",
2656
+ " - name: the Dev Box name.",
2505
2657
  ' - source_kind: "github_repo" (clone a connected repo \u2014 needs github_repo_id), "url" (clone any http(s) git URL \u2014 needs clone_url), or "blank" (empty box).',
2506
2658
  " - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
2507
2659
  " - clone_url (for url): an http(s) git clone URL.",
2508
2660
  " - branch (optional): branch to clone.",
2509
2661
  ' - databases (optional): companion services to attach \u2014 any of "postgres", "redis", "meilisearch".',
2510
- ' - plan (optional): box size (default "micro").',
2662
+ ' - plan (optional): box size. Defaults to "standard" (2 GB) \u2014 the OOM-safe floor (DEV_ENV_MIN_SIZE); a smaller plan is floored up to "standard" server-side.',
2511
2663
  " - agent_accounts (optional): bind specific saved agent logins by account id per provider. OMIT to auto-inherit the box owner's default logins (claude/codex/opencode), so `claude` is already authenticated on first boot \u2014 no manual login.",
2512
2664
  "",
2513
2665
  "Returns: { service, devUrl, deployId } \u2014 deploying. Once live: open the Terminal tab, run `claude`, start the dev server on $PORT, view at https://<devUrl>. Tear down with delete_dev_environment.",
@@ -2515,13 +2667,15 @@ defineTool({
2515
2667
  'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
2516
2668
  ].join("\n"),
2517
2669
  input: {
2518
- name: z13.string().min(1).max(100).describe("Dev box name."),
2670
+ name: z13.string().min(1).max(100).describe("Dev Box name."),
2519
2671
  source_kind: z13.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
2520
2672
  github_repo_id: z13.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
2521
2673
  clone_url: z13.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
2522
2674
  branch: z13.string().min(1).max(255).optional().describe("Branch to clone."),
2523
2675
  databases: z13.array(z13.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
2524
- plan: z13.enum(SERVICE_PLANS).optional().describe('Box size (default "micro").'),
2676
+ plan: z13.enum(SERVICE_PLANS).optional().describe(
2677
+ 'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
2678
+ ),
2525
2679
  agent_accounts: z13.array(
2526
2680
  z13.object({
2527
2681
  provider: z13.enum(["claude", "codex", "opencode"]),
@@ -3191,7 +3345,7 @@ defineTool({
3191
3345
 
3192
3346
  // src/server-factory.ts
3193
3347
  var PACKAGE_NAME = "hoststack";
3194
- var PACKAGE_VERSION = "0.9.1";
3348
+ var PACKAGE_VERSION = MCP_VERSION;
3195
3349
  function createMcpServer(options) {
3196
3350
  const baseUrl = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
3197
3351
  const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl });