@hoststack.dev/mcp 0.13.1 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hoststack-mcp.js +888 -223
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +885 -223
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/hoststack-mcp.js
CHANGED
|
@@ -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
|
|
67
|
-
throw new Error(
|
|
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
|
|
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 = [
|
|
1054
|
-
|
|
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
|
|
1057
|
-
const match = zones2.find((
|
|
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
|
|
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((
|
|
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.
|
|
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.
|
|
1125
|
-
const response = await ctx.
|
|
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.
|
|
1390
|
+
const { zones } = await ctx.hoststack.dns.listZones(teamId);
|
|
1157
1391
|
for (const zone of zones) {
|
|
1158
|
-
const { records } = await ctx.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
1281
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
"
|
|
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/
|
|
2467
|
+
// src/tools/resource-links.ts
|
|
2096
2468
|
import { z as z13 } from "zod";
|
|
2097
|
-
var
|
|
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
|
-
"
|
|
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:
|
|
2135
|
-
environment_id:
|
|
2136
|
-
status:
|
|
2137
|
-
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:
|
|
2199
|
-
name:
|
|
2200
|
-
type:
|
|
2201
|
-
docker_image:
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
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
|
-
|
|
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 /
|
|
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):
|
|
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:
|
|
2274
|
-
name:
|
|
2275
|
-
plan:
|
|
2276
|
-
'
|
|
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:
|
|
2279
|
-
hoststack_api_key:
|
|
2280
|
-
poststack_api_key:
|
|
2281
|
-
repo_url:
|
|
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:
|
|
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
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
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: "
|
|
2326
|
-
value: args2.
|
|
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
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
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
|
-
|
|
2341
|
-
|
|
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
|
|
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:
|
|
2370
|
-
include_database_clone:
|
|
2371
|
-
name:
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
2445
|
-
size:
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
2661
|
-
from:
|
|
2662
|
-
to:
|
|
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:
|
|
2707
|
-
name:
|
|
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:
|
|
2756
|
-
install_command:
|
|
2757
|
-
build_command:
|
|
2758
|
-
start_command:
|
|
2759
|
-
branch:
|
|
2760
|
-
root_directory:
|
|
2761
|
-
dockerfile_path:
|
|
2762
|
-
auto_deploy:
|
|
2763
|
-
health_check_path:
|
|
2764
|
-
health_check_enabled:
|
|
2765
|
-
health_check_interval:
|
|
2766
|
-
health_check_timeout:
|
|
2767
|
-
health_check_grace_period_sec:
|
|
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:
|
|
2771
|
-
cpu_shares:
|
|
2772
|
-
disk_size_gb:
|
|
2773
|
-
port:
|
|
2774
|
-
protocol:
|
|
2775
|
-
restart_policy:
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
2939
|
-
lines:
|
|
2940
|
-
since:
|
|
2941
|
-
until:
|
|
2942
|
-
stream:
|
|
2943
|
-
level:
|
|
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:
|
|
2947
|
-
count_only:
|
|
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:
|
|
2997
|
-
lines_per_service:
|
|
2998
|
-
since:
|
|
2999
|
-
until:
|
|
3000
|
-
stream:
|
|
3001
|
-
level:
|
|
3002
|
-
search:
|
|
3003
|
-
count_only:
|
|
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
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
3096
|
-
name:
|
|
3097
|
-
mount_path:
|
|
3098
|
-
|
|
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}" (${
|
|
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:
|
|
3135
|
-
volume_id:
|
|
3136
|
-
mount_path:
|
|
3137
|
-
size_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:
|
|
3176
|
-
volume_id:
|
|
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 =
|
|
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 });
|