@hoststack.dev/mcp 0.13.0 → 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.',
2260
2338
  "",
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.`,
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.',
2340
+ "",
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 "micro").',
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.",
@@ -2276,7 +2356,9 @@ defineTool({
2276
2356
  input: {
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
- plan: z13.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
2359
+ plan: z13.enum(SERVICE_PLANS).optional().describe(
2360
+ 'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
2361
+ ),
2280
2362
  disk_gb: z13.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
2281
2363
  hoststack_api_key: z13.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
2282
2364
  poststack_api_key: z13.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
@@ -2293,68 +2375,94 @@ defineTool({
2293
2375
  });
2294
2376
  const name = args.name ?? "dev-environment";
2295
2377
  const sizeGb = args.disk_gb ?? DEV_ENV_VOLUME.sizeGb;
2378
+ const plan = coerceDevEnvSize(args.plan);
2296
2379
  const createInput = {
2297
2380
  name,
2298
2381
  type: "private_service",
2299
2382
  projectId,
2300
2383
  dockerImage: DEV_ENV_IMAGE,
2301
- autoDeploy: false
2384
+ autoDeploy: false,
2385
+ plan
2302
2386
  };
2303
- if (args.plan !== void 0) createInput.plan = args.plan;
2304
2387
  const created = await ctx.hoststack.services.create(teamId, createInput);
2305
2388
  const service = created.service;
2306
- const envVars = [];
2307
- if (args.hoststack_api_key)
2308
- envVars.push({
2309
- key: "HOSTSTACK_API_KEY",
2310
- value: args.hoststack_api_key,
2311
- isSecret: true
2312
- });
2313
- if (args.poststack_api_key)
2314
- envVars.push({
2315
- key: "POSTSTACK_API_KEY",
2316
- value: args.poststack_api_key,
2317
- isSecret: true
2318
- });
2319
- if (args.repo_url) {
2320
- envVars.push({
2321
- key: "HOSTSTACK_DEVENV_REPO_URL",
2322
- value: args.repo_url,
2323
- isSecret: false
2324
- });
2325
- 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)
2402
+ envVars.push({
2403
+ key: "HOSTSTACK_API_KEY",
2404
+ value: args.hoststack_api_key,
2405
+ isSecret: true
2406
+ });
2407
+ if (args.poststack_api_key)
2326
2408
  envVars.push({
2327
- key: "HOSTSTACK_DEVENV_BRANCH",
2328
- value: args.branch,
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,
2329
2417
  isSecret: false
2330
2418
  });
2331
- }
2332
- if (envVars.length > 0) {
2333
- await ctx.hoststack.envVars.bulkSet(teamId, service.id, { vars: envVars });
2334
- }
2335
- let volumeAttached = false;
2336
- try {
2337
- await ctx.hoststack.volumes.create(teamId, service.id, {
2338
- name: DEV_ENV_VOLUME.name,
2339
- mountPath: DEV_ENV_VOLUME.mountPath,
2340
- 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 }
2341
2448
  });
2342
- volumeAttached = true;
2343
- } 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
+ );
2344
2456
  }
2345
- const deploy = await ctx.hoststack.deploys.trigger(teamId, service.id);
2346
- const deployId = deploy.deploy?.id ?? null;
2347
- return respond({
2348
- 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.`,
2349
- data: { service: shapeService(service), volumeAttached, deployId }
2350
- });
2351
2457
  }
2352
2458
  });
2353
2459
  defineTool({
2354
2460
  name: "spin_up_dev_environment",
2355
2461
  category: "services",
2356
2462
  description: [
2357
- "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.",
2358
2466
  "",
2359
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.',
2360
2468
  "",
@@ -2397,7 +2505,9 @@ defineTool({
2397
2505
  name: "delete_dev_environment",
2398
2506
  category: "services",
2399
2507
  description: [
2400
- "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.",
2401
2511
  "",
2402
2512
  'When to use: "delete / tear down the dev environment", once you have shipped the fix and no longer need the box.',
2403
2513
  "",
@@ -2424,15 +2534,56 @@ defineTool({
2424
2534
  });
2425
2535
  }
2426
2536
  });
2537
+ defineTool({
2538
+ name: "resize_dev_environment",
2539
+ category: "services",
2540
+ description: [
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).",
2542
+ "",
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.",
2546
+ "",
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.",
2548
+ "",
2549
+ "Inputs:",
2550
+ " - service_id: the box to resize \u2014 numeric id or publicId.",
2551
+ ' - size: target tier \u2014 one of the service catalog sizes (e.g. "standard", "large", "xlarge").',
2552
+ "",
2553
+ "Returns: { service } with the new plan.",
2554
+ "",
2555
+ 'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
2556
+ ].join("\n"),
2557
+ input: {
2558
+ service_id: z13.union([z13.number().int().positive(), z13.string()]).describe("The box to resize \u2014 numeric id or publicId."),
2559
+ size: z13.enum(SERVICE_PLANS).describe(
2560
+ 'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
2561
+ )
2562
+ },
2563
+ handler: async (args, ctx) => {
2564
+ const teamId = await ctx.resolveTeamId();
2565
+ const serviceId = await ctx.hoststack.resolveId(args.service_id, {
2566
+ kind: "service",
2567
+ teamId
2568
+ });
2569
+ const { service } = await ctx.hoststack.services.resize(teamId, serviceId, args.size);
2570
+ return respond({
2571
+ summary: `Resized to ${service.plan} \u2014 memory/CPU applied live; disk grows on next recreate.`,
2572
+ data: { service: shapeService(service) }
2573
+ });
2574
+ }
2575
+ });
2427
2576
  defineTool({
2428
2577
  name: "list_dev_environments",
2429
2578
  category: "services",
2430
2579
  description: [
2431
- `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.",
2432
2583
  "",
2433
2584
  `When to use: "show my dev environments", before opening/tearing one down, to find a box's id.`,
2434
2585
  "",
2435
- 'Returns: { items: [{ ...service, devUrl, databases }] } where `databases` lists the companion engines wired into the box (e.g. ["postgres","redis"]).',
2586
+ 'Returns: { items: [{ ...service, devUrl, databases, exitReason, recommendedSize }] } where `databases` lists the companion engines wired into the box (e.g. ["postgres","redis"]). `exitReason` is "oom_killed" / "crashed" / null for the box\'s last container exit; when it is "oom_killed", `recommendedSize` is the next tier up to rescale to (use resize_dev_environment).',
2436
2587
  "",
2437
2588
  "Example: list_dev_environments() \u2192 every dev box for the active team."
2438
2589
  ].join("\n"),
@@ -2440,47 +2591,99 @@ defineTool({
2440
2591
  handler: async (_args, ctx) => {
2441
2592
  const teamId = await ctx.resolveTeamId();
2442
2593
  const { environments } = await ctx.hoststack.services.listDevEnvironments(teamId);
2594
+ const oomCount = environments.filter((e) => e.exitReason === "oom_killed").length;
2443
2595
  return respond({
2444
- summary: `${environments.length} dev environment${environments.length === 1 ? "" : "s"}.`,
2596
+ summary: `${environments.length} dev environment${environments.length === 1 ? "" : "s"}.` + (oomCount > 0 ? ` ${oomCount} recently OOM-killed \u2014 consider resize_dev_environment.` : ""),
2445
2597
  data: {
2446
2598
  items: environments.map((env) => ({
2447
2599
  ...shapeService(env),
2448
2600
  devUrl: env.devUrl ?? null,
2449
- databases: env.databases ?? []
2601
+ databases: env.databases ?? [],
2602
+ exitReason: env.exitReason ?? null,
2603
+ recommendedSize: env.recommendedSize ?? null
2450
2604
  }))
2451
2605
  }
2452
2606
  });
2453
2607
  }
2454
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
+ });
2455
2645
  defineTool({
2456
2646
  name: "create_standalone_dev_environment",
2457
2647
  category: "services",
2458
2648
  description: [
2459
- "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.",
2460
2652
  "",
2461
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).',
2462
2654
  "",
2463
2655
  "Inputs:",
2464
- " - name: the dev box name.",
2656
+ " - name: the Dev Box name.",
2465
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).',
2466
2658
  " - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
2467
2659
  " - clone_url (for url): an http(s) git clone URL.",
2468
2660
  " - branch (optional): branch to clone.",
2469
2661
  ' - databases (optional): companion services to attach \u2014 any of "postgres", "redis", "meilisearch".',
2470
- ' - 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.',
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.",
2471
2664
  "",
2472
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.",
2473
2666
  "",
2474
2667
  'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
2475
2668
  ].join("\n"),
2476
2669
  input: {
2477
- name: z13.string().min(1).max(100).describe("Dev box name."),
2670
+ name: z13.string().min(1).max(100).describe("Dev Box name."),
2478
2671
  source_kind: z13.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
2479
2672
  github_repo_id: z13.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
2480
2673
  clone_url: z13.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
2481
2674
  branch: z13.string().min(1).max(255).optional().describe("Branch to clone."),
2482
2675
  databases: z13.array(z13.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
2483
- 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
+ ),
2679
+ agent_accounts: z13.array(
2680
+ z13.object({
2681
+ provider: z13.enum(["claude", "codex", "opencode"]),
2682
+ account_id: z13.number().int().positive()
2683
+ })
2684
+ ).max(3).optional().describe(
2685
+ "Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
2686
+ )
2484
2687
  },
2485
2688
  handler: async (args, ctx) => {
2486
2689
  const teamId = await ctx.resolveTeamId();
@@ -2516,7 +2719,13 @@ defineTool({
2516
2719
  name: args.name,
2517
2720
  source,
2518
2721
  ...args.databases ? { databases: args.databases } : {},
2519
- ...args.plan ? { plan: args.plan } : {}
2722
+ ...args.plan ? { plan: args.plan } : {},
2723
+ ...args.agent_accounts && args.agent_accounts.length > 0 ? {
2724
+ agentAccounts: args.agent_accounts.map((a) => ({
2725
+ provider: a.provider,
2726
+ accountId: a.account_id
2727
+ }))
2728
+ } : {}
2520
2729
  };
2521
2730
  const result = await ctx.hoststack.services.createDevEnvironment(teamId, input);
2522
2731
  return respond({
@@ -3136,7 +3345,7 @@ defineTool({
3136
3345
 
3137
3346
  // src/server-factory.ts
3138
3347
  var PACKAGE_NAME = "hoststack";
3139
- var PACKAGE_VERSION = "0.9.1";
3348
+ var PACKAGE_VERSION = MCP_VERSION;
3140
3349
  function createMcpServer(options) {
3141
3350
  const baseUrl = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
3142
3351
  const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl });