@hoststack.dev/mcp 0.16.1 → 0.18.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/README.md +31 -19
- package/dist/hoststack-mcp.js +1199 -269
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1199 -269
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
package/dist/hoststack-mcp.js
CHANGED
|
@@ -8,7 +8,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
8
8
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
9
9
|
|
|
10
10
|
// src/version.ts
|
|
11
|
-
var MCP_VERSION = true ? "0.
|
|
11
|
+
var MCP_VERSION = true ? "0.18.0" : "0.0.0-dev";
|
|
12
12
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
13
13
|
|
|
14
14
|
// src/api-client.ts
|
|
@@ -228,7 +228,7 @@ definePrompt({
|
|
|
228
228
|
const text = `Goal: diagnose the most recent failed deploy on service ${service_id} and propose a fix.
|
|
229
229
|
|
|
230
230
|
Plan (use these tools in order):
|
|
231
|
-
1. \`list_deploys({ service_id: "${service_id}" })\` \u2014 pull recent deploys, newest first. Identify the most recent deploy with status \`failed\` (or \`cancelled\` if the user wants to investigate that too). Capture its publicId, branch, and
|
|
231
|
+
1. \`list_deploys({ service_id: "${service_id}" })\` \u2014 pull recent deploys, newest first. Identify the most recent deploy with status \`failed\` (or \`cancelled\` if the user wants to investigate that too). Capture its publicId, branch, and commitHash.
|
|
232
232
|
2. \`get_deploy_logs({ service_id: "${service_id}", deploy_id: "<dpl_\u2026>" })\` \u2014 read the full build output. Scan for: stack traces, "ERROR" / "error:" lines, exit codes, missing env vars, OOM-killed signals, network failures pulling dependencies.
|
|
233
233
|
3. \`get_service({ service_id: "${service_id}" })\` \u2014 confirm the service's runtime, plan, and whether autoDeploy is on. Plan tier matters because OOMs at small tiers point at \`maxMemoryMb\`.
|
|
234
234
|
4. (Optional) \`list_env_vars({ service_id: "${service_id}" })\` \u2014 only if the build error mentions a missing variable. Don't fetch otherwise; values are masked anyway.
|
|
@@ -510,9 +510,9 @@ defineTool({
|
|
|
510
510
|
name: "list_alerts",
|
|
511
511
|
category: "alerts",
|
|
512
512
|
description: [
|
|
513
|
-
"List recent alert-shaped events for the team: deploy failures, git auth losses, service health failures, auto-restarts, ACME cert failures, resource alerts (high CPU), database backup/restore failures, registrant verification lapses.",
|
|
513
|
+
"List recent alert-shaped events for the team: deploy failures, git auth losses, service health failures, uptime failures (a service stopped answering its public URL), new and regressed error issues (an exception the app reported), auto-restarts, ACME cert failures, resource alerts (high CPU), database backup/restore failures, registrant verification lapses.",
|
|
514
514
|
"",
|
|
515
|
-
"When to use: triage 'what's currently broken or recently broke for this team'. Pairs with list_activity_log for the full audit feed; this tool is the alert-shaped subset.",
|
|
515
|
+
"When to use: triage 'what's currently broken or recently broke for this team'. Pairs with list_activity_log for the full audit feed; this tool is the alert-shaped subset. For an `error.issue_new` / `error.issue_regressed` entry, `list_error_issues` and `get_error_issue` carry the stack behind it.",
|
|
516
516
|
"",
|
|
517
517
|
'By default events are AGGREGATED by (action, resourceId) so flapping events collapse to one row with a fire count + first/last timestamps \u2014 e.g. "service.auto_restarted on service 31, 8 times in the last hour, last at 14:22". Pass aggregate=false to see every raw row.',
|
|
518
518
|
"",
|
|
@@ -620,7 +620,105 @@ defineTool({
|
|
|
620
620
|
});
|
|
621
621
|
|
|
622
622
|
// src/tools/databases.ts
|
|
623
|
+
import { z as z6 } from "zod";
|
|
624
|
+
|
|
625
|
+
// src/tools/machines.ts
|
|
623
626
|
import { z as z5 } from "zod";
|
|
627
|
+
var machineInput = z5.union([z5.number().int().positive(), z5.string()]).optional().describe(
|
|
628
|
+
`Run this on one of the team's OWN enrolled machines instead of HostStack compute \u2014 the machine's name (e.g. "desktop") or its numeric id. List them with list_machines. Omit for HostStack compute, which is the default and the right answer unless the user asked for their own hardware.`
|
|
629
|
+
);
|
|
630
|
+
async function resolveMachineId(ctx, teamId, machine) {
|
|
631
|
+
const { machines } = await ctx.hoststack.machines.list(teamId);
|
|
632
|
+
if (machines.length === 0) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
"This team has no enrolled machines. Enrol one from the dashboard (Machines \u2192 Add machine) or with `hoststack machines add`, then run the install command it prints on that machine."
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
const known = machines.map((m) => `"${m.name}" (id ${m.id})`).join(", ");
|
|
638
|
+
if (typeof machine === "number") {
|
|
639
|
+
const byId = machines.find((m) => m.id === machine);
|
|
640
|
+
if (!byId) throw new Error(`No machine with id ${machine}. This team has: ${known}.`);
|
|
641
|
+
return byId.id;
|
|
642
|
+
}
|
|
643
|
+
const needle = machine.trim().toLowerCase();
|
|
644
|
+
const matches = machines.filter(
|
|
645
|
+
(m) => m.name.toLowerCase() === needle || m.hostname.toLowerCase() === needle
|
|
646
|
+
);
|
|
647
|
+
if (matches.length === 0) {
|
|
648
|
+
throw new Error(`No machine named "${machine}". This team has: ${known}.`);
|
|
649
|
+
}
|
|
650
|
+
if (matches.length > 1) {
|
|
651
|
+
throw new Error(
|
|
652
|
+
`More than one machine is named "${machine}" (ids ${matches.map((m) => m.id).join(", ")}). Pass the numeric id.`
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
return matches[0].id;
|
|
656
|
+
}
|
|
657
|
+
function describeMachine(m) {
|
|
658
|
+
if (!m.enrolled) return `${m.name}: registered but never paired`;
|
|
659
|
+
if (m.status !== "active") return `${m.name}: offline`;
|
|
660
|
+
if (m.agentBuild === "from-source") return `${m.name}: online, agent running from source`;
|
|
661
|
+
if (m.agentBuild === "unknown") return `${m.name}: online, agent too old to report its build`;
|
|
662
|
+
return `${m.name}: online`;
|
|
663
|
+
}
|
|
664
|
+
defineTool({
|
|
665
|
+
name: "list_machines",
|
|
666
|
+
category: "machines",
|
|
667
|
+
description: [
|
|
668
|
+
"List the team's OWN enrolled machines \u2014 hardware they supply (a spare desktop, a home server, a VPS they already pay for) that HostStack runs work on. These are NOT HostStack's servers; you cannot enrol, resize or reboot them from here.",
|
|
669
|
+
"",
|
|
670
|
+
"When to use: before creating a service, database or dev box that the user wants on their own hardware (pass the machine name to create_service / create_database / create_standalone_dev_environment), or to explain why something pinned to a machine is down \u2014 a machine that is switched off takes everything on it with it.",
|
|
671
|
+
"",
|
|
672
|
+
"Inputs: none.",
|
|
673
|
+
"",
|
|
674
|
+
"Returns: { items: Machine[] } \u2014 each entry has id (pass this as `machine`), name, status (active|offline|provisioning), enrolled, agentBuild (current|behind|from-source|unknown), agentVersion, lastHeartbeatAt, totalMemoryMb, totalCpuCores, and workloads { devBoxes, services, databases }.",
|
|
675
|
+
"",
|
|
676
|
+
"Notes: a machine is only reachable while its owner has it switched on, and a database pinned to one is reachable ONLY from that same machine \u2014 put the app that queries it there too. Enrolment needs a person at the machine to run an installer, so there is no tool for it: point the user at the dashboard (Machines \u2192 Add machine) or `hoststack machines add`.",
|
|
677
|
+
"",
|
|
678
|
+
'Example: list_machines() \u2192 { items: [{ id: 12452, name: "desktop", status: "active", workloads: { devBoxes: 8, services: 0, databases: 2 } }] }'
|
|
679
|
+
].join("\n"),
|
|
680
|
+
input: {},
|
|
681
|
+
handler: async (_args, ctx) => {
|
|
682
|
+
const teamId = await ctx.resolveTeamId();
|
|
683
|
+
const response = await ctx.hoststack.machines.list(teamId);
|
|
684
|
+
const data = shapeList(response, "machines", shape);
|
|
685
|
+
const summary = response.machines.length === 0 ? "This team has no enrolled machines. Everything runs on HostStack compute." : `${response.machines.length} machine${response.machines.length === 1 ? "" : "s"} \u2014 ${response.machines.map(describeMachine).join("; ")}.`;
|
|
686
|
+
return respond({ summary, data });
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
defineTool({
|
|
690
|
+
name: "get_machine",
|
|
691
|
+
category: "machines",
|
|
692
|
+
description: [
|
|
693
|
+
"One of the team's own enrolled machines, with what is actually running on it \u2014 rather than the counts list_machines carries.",
|
|
694
|
+
"",
|
|
695
|
+
'When to use: before advising the user to switch a machine off, move work off it, or remove it \u2014 `running` is the list of things that go away with the machine. Also the fastest answer to "what is on my desktop?".',
|
|
696
|
+
"",
|
|
697
|
+
"Inputs:",
|
|
698
|
+
' - machine: the machine name (e.g. "desktop") or its numeric id.',
|
|
699
|
+
"",
|
|
700
|
+
'Returns: { machine: Machine, running: Workload[] } \u2014 each workload has kind ("dev_box" | "service" | "database"), name, status and projectId. The kinds differ in consequence: a dev box waits for its machine to come back, a service is down while it is off, and a database takes down everything reading from it.',
|
|
701
|
+
"",
|
|
702
|
+
'Example: get_machine({ machine: "desktop" }) \u2192 { machine: { name: "desktop", status: "active" }, running: [{ kind: "database", name: "postgres-58" }] }'
|
|
703
|
+
].join("\n"),
|
|
704
|
+
input: {
|
|
705
|
+
machine: z5.union([z5.number().int().positive(), z5.string()]).describe('Machine name (e.g. "desktop") or numeric id. List them with list_machines.')
|
|
706
|
+
},
|
|
707
|
+
handler: async (args2, ctx) => {
|
|
708
|
+
const teamId = await ctx.resolveTeamId();
|
|
709
|
+
const machineId = await resolveMachineId(ctx, teamId, args2.machine);
|
|
710
|
+
const response = await ctx.hoststack.machines.get(teamId, machineId);
|
|
711
|
+
const data = {
|
|
712
|
+
machine: shape(response.machine),
|
|
713
|
+
running: shapeList({ running: response.running }, "running", shape).items
|
|
714
|
+
};
|
|
715
|
+
const running = response.running ?? [];
|
|
716
|
+
const summary = running.length === 0 ? `${describeMachine(response.machine)}. Nothing is running on it.` : `${describeMachine(response.machine)}. Running ${running.length} thing${running.length === 1 ? "" : "s"}: ${running.map((w) => `${w.name} (${w.kind})`).join(", ")}.`;
|
|
717
|
+
return respond({ summary, data });
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// src/tools/databases.ts
|
|
624
722
|
var DATABASE_VERSIONS = {
|
|
625
723
|
postgres: { default: "18", supported: ["18", "17", "16", "15"] },
|
|
626
724
|
redis: { default: "8", supported: ["8", "7", "6"] },
|
|
@@ -657,6 +755,7 @@ defineTool({
|
|
|
657
755
|
" - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
|
|
658
756
|
" - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
|
|
659
757
|
" - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
|
|
758
|
+
" - machine (optional): put it on one of the team's OWN enrolled machines (name or id, see list_machines) instead of HostStack compute. It costs nothing there and is still backed up off-site, but it is reachable ONLY from that same machine \u2014 the service that queries it has to run there too, and linking it to a service anywhere else is refused. No HA and no external access on a machine you own.",
|
|
660
759
|
"",
|
|
661
760
|
'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
761
|
"",
|
|
@@ -665,16 +764,17 @@ defineTool({
|
|
|
665
764
|
'Example: create_database({ project_id: "prj_abc", name: "app-db", engine: "postgres" }) \u2192 { database: { id: 42, publicId: "db_\u2026", status: "creating" } }'
|
|
666
765
|
].join("\n"),
|
|
667
766
|
input: {
|
|
668
|
-
project_id:
|
|
669
|
-
name:
|
|
670
|
-
engine:
|
|
671
|
-
version:
|
|
672
|
-
plan:
|
|
673
|
-
environment_id:
|
|
674
|
-
postgis:
|
|
675
|
-
pgvector:
|
|
767
|
+
project_id: z6.union([z6.number().int().positive(), z6.string()]).describe('Target project \u2014 numeric id or publicId ("prj_\u2026").'),
|
|
768
|
+
name: z6.string().min(1).max(100).describe("Database name (1\u2013100 chars)."),
|
|
769
|
+
engine: z6.enum(DB_ENGINES).describe("Database engine."),
|
|
770
|
+
version: z6.string().max(20).optional().describe(`Engine version. ${VERSION_HELP}. Omit for the engine default.`),
|
|
771
|
+
plan: z6.enum(["micro", "starter", "standard", "pro"]).optional().describe('Plan tier (memory/CPU). Default "starter".'),
|
|
772
|
+
environment_id: z6.union([z6.number().int().positive(), z6.string()]).optional().describe("Environment to bind to. Defaults to the project Production env."),
|
|
773
|
+
postgis: z6.boolean().optional().describe("Postgres only \u2014 enable the PostGIS extension image."),
|
|
774
|
+
pgvector: z6.boolean().optional().describe(
|
|
676
775
|
"Postgres only \u2014 enable the pgvector extension image. Exclusive with postgis."
|
|
677
|
-
)
|
|
776
|
+
),
|
|
777
|
+
machine: machineInput
|
|
678
778
|
},
|
|
679
779
|
handler: async (args2, ctx) => {
|
|
680
780
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -697,6 +797,9 @@ defineTool({
|
|
|
697
797
|
}
|
|
698
798
|
if (args2.postgis !== void 0) input.postgis = args2.postgis;
|
|
699
799
|
if (args2.pgvector !== void 0) input.pgvector = args2.pgvector;
|
|
800
|
+
if (args2.machine !== void 0) {
|
|
801
|
+
input.machineId = await resolveMachineId(ctx, teamId, args2.machine);
|
|
802
|
+
}
|
|
700
803
|
const response = await ctx.hoststack.databases.create(teamId, input);
|
|
701
804
|
const data = { database: shapeDatabase(response.database) };
|
|
702
805
|
const db = response.database;
|
|
@@ -724,7 +827,7 @@ defineTool({
|
|
|
724
827
|
'Example: delete_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
725
828
|
].join("\n"),
|
|
726
829
|
input: {
|
|
727
|
-
database_id:
|
|
830
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc) to permanently delete.")
|
|
728
831
|
},
|
|
729
832
|
handler: async (args2, ctx) => {
|
|
730
833
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -750,7 +853,7 @@ defineTool({
|
|
|
750
853
|
'Example: suspend_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
751
854
|
].join("\n"),
|
|
752
855
|
input: {
|
|
753
|
-
database_id:
|
|
856
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc) to suspend.")
|
|
754
857
|
},
|
|
755
858
|
handler: async (args2, ctx) => {
|
|
756
859
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -776,7 +879,7 @@ defineTool({
|
|
|
776
879
|
'Example: resume_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
777
880
|
].join("\n"),
|
|
778
881
|
input: {
|
|
779
|
-
database_id:
|
|
882
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc) to resume.")
|
|
780
883
|
},
|
|
781
884
|
handler: async (args2, ctx) => {
|
|
782
885
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -802,7 +905,7 @@ defineTool({
|
|
|
802
905
|
'Example: list_databases({ project_id: 12 }) \u2192 { items: [{ publicId: "db_\u2026", type: "postgres", status: "running", \u2026 }] }'
|
|
803
906
|
].join("\n"),
|
|
804
907
|
input: {
|
|
805
|
-
project_id:
|
|
908
|
+
project_id: z6.number().int().positive().describe("Numeric project ID (from list_projects).")
|
|
806
909
|
},
|
|
807
910
|
handler: async (args2, ctx) => {
|
|
808
911
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -831,10 +934,10 @@ defineTool({
|
|
|
831
934
|
'Example: update_database({ database_id: "db_xyz", disk_size_gb: 50 }) \u2192 { database: { diskSizeGb: 50, \u2026 } }'
|
|
832
935
|
].join("\n"),
|
|
833
936
|
input: {
|
|
834
|
-
database_id:
|
|
835
|
-
name:
|
|
836
|
-
plan:
|
|
837
|
-
disk_size_gb:
|
|
937
|
+
database_id: z6.string().describe("Database publicId (e.g. db_xyz)."),
|
|
938
|
+
name: z6.string().min(1).max(100).optional().describe("New database name."),
|
|
939
|
+
plan: z6.enum(["free", "micro", "starter", "standard", "pro"]).optional().describe("Plan tier (memory/CPU)."),
|
|
940
|
+
disk_size_gb: z6.number().int().min(1).max(1024).optional().describe("New disk size in GB. Must be \u2265 current.")
|
|
838
941
|
},
|
|
839
942
|
handler: async (args2, ctx) => {
|
|
840
943
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -867,7 +970,7 @@ defineTool({
|
|
|
867
970
|
'Example: get_database({ database_id: "db_xyz" }) \u2192 { database: { type: "postgres", version: "16", status: "running", \u2026 } }'
|
|
868
971
|
].join("\n"),
|
|
869
972
|
input: {
|
|
870
|
-
database_id:
|
|
973
|
+
database_id: z6.string().describe("Database publicId (e.g. db_xyz).")
|
|
871
974
|
},
|
|
872
975
|
handler: async (args2, ctx) => {
|
|
873
976
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -905,8 +1008,8 @@ defineTool({
|
|
|
905
1008
|
` - query_database({ database_id: "db_abc", sql: "SELECT id, email FROM users WHERE created_at > now() - interval '1 day' LIMIT 50" })`
|
|
906
1009
|
].join("\n"),
|
|
907
1010
|
input: {
|
|
908
|
-
database_id:
|
|
909
|
-
sql:
|
|
1011
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc)."),
|
|
1012
|
+
sql: z6.string().min(1).max(16384).describe(
|
|
910
1013
|
"A single READ-ONLY SQL statement. No trailing `;`, no embedded `;`, no psql meta-commands."
|
|
911
1014
|
)
|
|
912
1015
|
},
|
|
@@ -935,7 +1038,7 @@ defineTool({
|
|
|
935
1038
|
'Example: upgrade_database_to_ha({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
936
1039
|
].join("\n"),
|
|
937
1040
|
input: {
|
|
938
|
-
database_id:
|
|
1041
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc) to upgrade to HA.")
|
|
939
1042
|
},
|
|
940
1043
|
handler: async (args2, ctx) => {
|
|
941
1044
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -964,8 +1067,8 @@ defineTool({
|
|
|
964
1067
|
'Example: upgrade_database_version({ database_id: "db_abc", version: "18" }) \u2192 { ok: true }'
|
|
965
1068
|
].join("\n"),
|
|
966
1069
|
input: {
|
|
967
|
-
database_id:
|
|
968
|
-
version:
|
|
1070
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc) to upgrade."),
|
|
1071
|
+
version: z6.string().describe('Target engine version, e.g. "18" for postgres or "8" for redis.')
|
|
969
1072
|
},
|
|
970
1073
|
handler: async (args2, ctx) => {
|
|
971
1074
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -993,7 +1096,7 @@ defineTool({
|
|
|
993
1096
|
'Example: restart_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
994
1097
|
].join("\n"),
|
|
995
1098
|
input: {
|
|
996
|
-
database_id:
|
|
1099
|
+
database_id: z6.string().describe("Database publicId (e.g. db_abc) to restart.")
|
|
997
1100
|
},
|
|
998
1101
|
handler: async (args2, ctx) => {
|
|
999
1102
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1019,7 +1122,7 @@ defineTool({
|
|
|
1019
1122
|
'Example: get_database_cluster({ database_id: "db_abc" }) \u2192 { members: [...5 live rows], failovers: [] }'
|
|
1020
1123
|
].join("\n"),
|
|
1021
1124
|
input: {
|
|
1022
|
-
database_id:
|
|
1125
|
+
database_id: z6.string().describe("Database publicId for the HA cluster.")
|
|
1023
1126
|
},
|
|
1024
1127
|
handler: async (args2, ctx) => {
|
|
1025
1128
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1031,7 +1134,7 @@ defineTool({
|
|
|
1031
1134
|
});
|
|
1032
1135
|
|
|
1033
1136
|
// src/tools/deploys.ts
|
|
1034
|
-
import { z as
|
|
1137
|
+
import { z as z7 } from "zod";
|
|
1035
1138
|
defineTool({
|
|
1036
1139
|
name: "list_deploys",
|
|
1037
1140
|
category: "deploys",
|
|
@@ -1043,12 +1146,12 @@ defineTool({
|
|
|
1043
1146
|
"Inputs:",
|
|
1044
1147
|
' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
|
|
1045
1148
|
"",
|
|
1046
|
-
'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled),
|
|
1149
|
+
'Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitHash (the resolved commit SHA; the key is OMITTED entirely when the deploy recorded none, so a missing key means "no commit", not "wrong key"), commitMessage, branch, triggeredBy, startedAt, finishedAt, imageBuildMs (docker build / image-pull only; null on skip-build redeploys \u2014 v89), containerBootMs (deploying \u2192 live wall-clock; null on builds that failed before container start \u2014 v89), buildDurationMs (legacy alias of imageBuildMs kept for back-compat), totalDurationMs (full deploy wall-clock = finishedAt \u2212 startedAt). Use imageBuildMs + containerBootMs together to tell "build is slow" apart from "boot is slow".',
|
|
1047
1150
|
"",
|
|
1048
1151
|
'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
|
|
1049
1152
|
].join("\n"),
|
|
1050
1153
|
input: {
|
|
1051
|
-
service_id:
|
|
1154
|
+
service_id: z7.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1052
1155
|
},
|
|
1053
1156
|
handler: async (args2, ctx) => {
|
|
1054
1157
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1070,13 +1173,13 @@ defineTool({
|
|
|
1070
1173
|
" - service_id: publicId of the service.",
|
|
1071
1174
|
' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
|
|
1072
1175
|
"",
|
|
1073
|
-
"Returns: { deploy: Deploy } \u2014 full deploy record (status,
|
|
1176
|
+
"Returns: { deploy: Deploy } \u2014 full deploy record (status, commitHash (omitted when the deploy recorded no commit), commitMessage, branch, imageBuildMs + containerBootMs (v89 split timings; legacy buildDurationMs preserved as alias), totalDurationMs, finishedAt, etc).",
|
|
1074
1177
|
"",
|
|
1075
1178
|
'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
|
|
1076
1179
|
].join("\n"),
|
|
1077
1180
|
input: {
|
|
1078
|
-
service_id:
|
|
1079
|
-
deploy_id:
|
|
1181
|
+
service_id: z7.string().describe("Service publicId."),
|
|
1182
|
+
deploy_id: z7.string().describe("Deploy publicId.")
|
|
1080
1183
|
},
|
|
1081
1184
|
handler: async (args2, ctx) => {
|
|
1082
1185
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1104,9 +1207,9 @@ defineTool({
|
|
|
1104
1207
|
'Example: trigger_deploy({ service_id: "svc_abc" }) \u2192 { deploy: { publicId: "dpl_\u2026", status: "pending", \u2026 } }'
|
|
1105
1208
|
].join("\n"),
|
|
1106
1209
|
input: {
|
|
1107
|
-
service_id:
|
|
1108
|
-
commit_hash:
|
|
1109
|
-
branch:
|
|
1210
|
+
service_id: z7.string().describe("Service publicId."),
|
|
1211
|
+
commit_hash: z7.string().max(40).optional().describe("Specific commit to build (default: branch HEAD)."),
|
|
1212
|
+
branch: z7.string().max(200).optional().describe("Branch to build (default: service configured branch).")
|
|
1110
1213
|
},
|
|
1111
1214
|
handler: async (args2, ctx) => {
|
|
1112
1215
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1139,8 +1242,8 @@ defineTool({
|
|
|
1139
1242
|
'Example: cancel_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { ok: true }'
|
|
1140
1243
|
].join("\n"),
|
|
1141
1244
|
input: {
|
|
1142
|
-
service_id:
|
|
1143
|
-
deploy_id:
|
|
1245
|
+
service_id: z7.string().describe("Service publicId."),
|
|
1246
|
+
deploy_id: z7.string().describe("Deploy publicId.")
|
|
1144
1247
|
},
|
|
1145
1248
|
handler: async (args2, ctx) => {
|
|
1146
1249
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1168,10 +1271,10 @@ defineTool({
|
|
|
1168
1271
|
'Example: get_deploy_logs({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { logs: "Building\u2026\\nStep 1/8\u2026\\n\u2026", lineCount: 42, nextAfterId: 1234 }'
|
|
1169
1272
|
].join("\n"),
|
|
1170
1273
|
input: {
|
|
1171
|
-
service_id:
|
|
1172
|
-
deploy_id:
|
|
1173
|
-
after_id:
|
|
1174
|
-
limit:
|
|
1274
|
+
service_id: z7.string().describe("Service publicId."),
|
|
1275
|
+
deploy_id: z7.string().describe("Deploy publicId."),
|
|
1276
|
+
after_id: z7.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
|
|
1277
|
+
limit: z7.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
|
|
1175
1278
|
},
|
|
1176
1279
|
handler: async (args2, ctx) => {
|
|
1177
1280
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1211,10 +1314,10 @@ defineTool({
|
|
|
1211
1314
|
'Example: diagnose_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 all three sections in one call.'
|
|
1212
1315
|
].join("\n"),
|
|
1213
1316
|
input: {
|
|
1214
|
-
service_id:
|
|
1215
|
-
deploy_id:
|
|
1216
|
-
build_log_lines:
|
|
1217
|
-
runtime_log_lines:
|
|
1317
|
+
service_id: z7.string().describe("Service publicId."),
|
|
1318
|
+
deploy_id: z7.string().describe("Deploy publicId."),
|
|
1319
|
+
build_log_lines: z7.number().int().min(1).max(2e3).optional().describe("Trailing build log lines. Default 200, max 2000."),
|
|
1320
|
+
runtime_log_lines: z7.number().int().min(0).max(1e3).optional().describe(
|
|
1218
1321
|
"Trailing runtime log lines since deploy start. Default 100, max 1000. 0 = skip."
|
|
1219
1322
|
)
|
|
1220
1323
|
},
|
|
@@ -1275,7 +1378,7 @@ defineTool({
|
|
|
1275
1378
|
});
|
|
1276
1379
|
|
|
1277
1380
|
// src/tools/dns-records.ts
|
|
1278
|
-
import { z as
|
|
1381
|
+
import { z as z8 } from "zod";
|
|
1279
1382
|
var DNS_RECORD_TYPES = [
|
|
1280
1383
|
"A",
|
|
1281
1384
|
"AAAA",
|
|
@@ -1290,7 +1393,7 @@ var DNS_RECORD_TYPES = [
|
|
|
1290
1393
|
async function resolveZonePublicId(hoststack, teamId, input) {
|
|
1291
1394
|
if (input.zone_id) {
|
|
1292
1395
|
const { zones: zones2 } = await hoststack.dns.listZones(teamId);
|
|
1293
|
-
const match = zones2.find((
|
|
1396
|
+
const match = zones2.find((z20) => z20.publicId === input.zone_id);
|
|
1294
1397
|
if (!match) {
|
|
1295
1398
|
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
1296
1399
|
}
|
|
@@ -1304,7 +1407,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
|
|
|
1304
1407
|
const labels = fqdn.split(".");
|
|
1305
1408
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
1306
1409
|
const candidate = labels.slice(i).join(".");
|
|
1307
|
-
const match = zones.find((
|
|
1410
|
+
const match = zones.find((z20) => z20.domainName.toLowerCase() === candidate);
|
|
1308
1411
|
if (match && match.status !== "deleting") {
|
|
1309
1412
|
return { publicId: match.publicId, domainName: match.domainName };
|
|
1310
1413
|
}
|
|
@@ -1349,8 +1452,8 @@ defineTool({
|
|
|
1349
1452
|
'Example: list_dns_records({ domain: "micci.dk" }) \u2192 { zone: { domainName: "micci.dk", \u2026 }, items: [{ type: "TXT", name: "@", value: "google-site-verification=\u2026", ttl: 300, \u2026 }] }'
|
|
1350
1453
|
].join("\n"),
|
|
1351
1454
|
input: {
|
|
1352
|
-
zone_id:
|
|
1353
|
-
domain:
|
|
1455
|
+
zone_id: z8.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
|
|
1456
|
+
domain: z8.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
|
|
1354
1457
|
},
|
|
1355
1458
|
handler: async (args2, ctx) => {
|
|
1356
1459
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1383,7 +1486,7 @@ defineTool({
|
|
|
1383
1486
|
'Example: get_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { type: "TXT", name: "@", value: "\u2026", status: "active", \u2026 } }'
|
|
1384
1487
|
].join("\n"),
|
|
1385
1488
|
input: {
|
|
1386
|
-
record_id:
|
|
1489
|
+
record_id: z8.string().describe("Record publicId.")
|
|
1387
1490
|
},
|
|
1388
1491
|
handler: async (args2, ctx) => {
|
|
1389
1492
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1429,13 +1532,13 @@ defineTool({
|
|
|
1429
1532
|
'Example: create_dns_record({ domain: "micci.dk", type: "TXT", name: "@", content: "google-site-verification=C0V0scK48g2\u2026", ttl: 300 })'
|
|
1430
1533
|
].join("\n"),
|
|
1431
1534
|
input: {
|
|
1432
|
-
zone_id:
|
|
1433
|
-
domain:
|
|
1434
|
-
type:
|
|
1435
|
-
name:
|
|
1436
|
-
content:
|
|
1437
|
-
ttl:
|
|
1438
|
-
priority:
|
|
1535
|
+
zone_id: z8.string().optional().describe("Zone publicId."),
|
|
1536
|
+
domain: z8.string().optional().describe("Apex or subdomain to resolve a hosted zone."),
|
|
1537
|
+
type: z8.enum(DNS_RECORD_TYPES).describe("DNS record type."),
|
|
1538
|
+
name: z8.string().min(1).max(253).describe('"@" for apex, or a bare label. Do NOT include the zone suffix.'),
|
|
1539
|
+
content: z8.string().min(1).max(2e3).describe("Record value; TXT values are unquoted."),
|
|
1540
|
+
ttl: z8.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
|
|
1541
|
+
priority: z8.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
|
|
1439
1542
|
},
|
|
1440
1543
|
handler: async (args2, ctx) => {
|
|
1441
1544
|
if ((args2.type === "MX" || args2.type === "SRV") && args2.priority === void 0) {
|
|
@@ -1481,12 +1584,12 @@ defineTool({
|
|
|
1481
1584
|
'Example: update_dns_record({ record_id: "dnr_abc", type: "A", name: "api", content: "203.0.113.42", ttl: 300 })'
|
|
1482
1585
|
].join("\n"),
|
|
1483
1586
|
input: {
|
|
1484
|
-
record_id:
|
|
1485
|
-
type:
|
|
1486
|
-
name:
|
|
1487
|
-
content:
|
|
1488
|
-
ttl:
|
|
1489
|
-
priority:
|
|
1587
|
+
record_id: z8.string().describe("Record publicId."),
|
|
1588
|
+
type: z8.enum(DNS_RECORD_TYPES).describe("DNS record type."),
|
|
1589
|
+
name: z8.string().min(1).max(253).describe('"@" for apex, or a bare label. Do NOT include the zone suffix.'),
|
|
1590
|
+
content: z8.string().min(1).max(2e3).describe("Record value; TXT unquoted."),
|
|
1591
|
+
ttl: z8.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
|
|
1592
|
+
priority: z8.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
|
|
1490
1593
|
},
|
|
1491
1594
|
handler: async (args2, ctx) => {
|
|
1492
1595
|
if ((args2.type === "MX" || args2.type === "SRV") && args2.priority === void 0) {
|
|
@@ -1540,7 +1643,7 @@ defineTool({
|
|
|
1540
1643
|
'Example: delete_dns_record({ record_id: "dnr_abc" }) \u2192 { ok: true }'
|
|
1541
1644
|
].join("\n"),
|
|
1542
1645
|
input: {
|
|
1543
|
-
record_id:
|
|
1646
|
+
record_id: z8.string().describe("Record publicId.")
|
|
1544
1647
|
},
|
|
1545
1648
|
handler: async (args2, ctx) => {
|
|
1546
1649
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1573,7 +1676,7 @@ defineTool({
|
|
|
1573
1676
|
'Example: resync_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { status: "active", ... } }'
|
|
1574
1677
|
].join("\n"),
|
|
1575
1678
|
input: {
|
|
1576
|
-
record_id:
|
|
1679
|
+
record_id: z8.string().describe("Record publicId.")
|
|
1577
1680
|
},
|
|
1578
1681
|
handler: async (args2, ctx) => {
|
|
1579
1682
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1611,7 +1714,7 @@ async function findRecordZone(hoststack, teamId, recordPublicId) {
|
|
|
1611
1714
|
}
|
|
1612
1715
|
|
|
1613
1716
|
// src/tools/domains.ts
|
|
1614
|
-
import { z as
|
|
1717
|
+
import { z as z9 } from "zod";
|
|
1615
1718
|
defineTool({
|
|
1616
1719
|
name: "list_domains",
|
|
1617
1720
|
category: "domains",
|
|
@@ -1651,9 +1754,9 @@ defineTool({
|
|
|
1651
1754
|
'Example: add_domain({ hostname: "api.example.com", service_id: "svc_abc" }) \u2192 { domain: { hostname: "api.example.com", dnsTargets: [...], verified: false, \u2026 } }'
|
|
1652
1755
|
].join("\n"),
|
|
1653
1756
|
input: {
|
|
1654
|
-
hostname:
|
|
1655
|
-
service_id:
|
|
1656
|
-
path_prefix:
|
|
1757
|
+
hostname: z9.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
|
|
1758
|
+
service_id: z9.string().describe("Service publicId to bind to."),
|
|
1759
|
+
path_prefix: z9.string().optional().describe('Optional URL prefix for path-based routing (e.g. "/api").')
|
|
1657
1760
|
},
|
|
1658
1761
|
handler: async (args2, ctx) => {
|
|
1659
1762
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1687,7 +1790,7 @@ defineTool({
|
|
|
1687
1790
|
'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
|
|
1688
1791
|
].join("\n"),
|
|
1689
1792
|
input: {
|
|
1690
|
-
domain_id:
|
|
1793
|
+
domain_id: z9.string().describe("Domain publicId.")
|
|
1691
1794
|
},
|
|
1692
1795
|
handler: async (args2, ctx) => {
|
|
1693
1796
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1714,7 +1817,7 @@ defineTool({
|
|
|
1714
1817
|
'Example: remove_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
|
|
1715
1818
|
].join("\n"),
|
|
1716
1819
|
input: {
|
|
1717
|
-
domain_id:
|
|
1820
|
+
domain_id: z9.string().describe("Domain publicId.")
|
|
1718
1821
|
},
|
|
1719
1822
|
handler: async (args2, ctx) => {
|
|
1720
1823
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1724,7 +1827,7 @@ defineTool({
|
|
|
1724
1827
|
});
|
|
1725
1828
|
|
|
1726
1829
|
// src/tools/env-vars.ts
|
|
1727
|
-
import { z as
|
|
1830
|
+
import { z as z10 } from "zod";
|
|
1728
1831
|
defineTool({
|
|
1729
1832
|
name: "list_env_vars",
|
|
1730
1833
|
category: "env-vars",
|
|
@@ -1741,7 +1844,7 @@ defineTool({
|
|
|
1741
1844
|
'Example: list_env_vars({ service_id: "svc_abc" }) \u2192 { items: [{ key: "DATABASE_URL", value: "\u2022\u2022\u2022\u2022\u2022\u2022", isSecret: true }, { key: "PORT", value: "3000", isSecret: false }] }'
|
|
1742
1845
|
].join("\n"),
|
|
1743
1846
|
input: {
|
|
1744
|
-
service_id:
|
|
1847
|
+
service_id: z10.string().describe("Service publicId.")
|
|
1745
1848
|
},
|
|
1746
1849
|
handler: async (args2, ctx) => {
|
|
1747
1850
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1771,11 +1874,11 @@ defineTool({
|
|
|
1771
1874
|
'Example: set_env_var({ service_id: "svc_abc", key: "DATABASE_URL", value: "postgres://\u2026", is_secret: true }) \u2192 { envVar: { key: "DATABASE_URL", value: "\u2022\u2022\u2022\u2022\u2022\u2022", \u2026 }, action: "updated" }'
|
|
1772
1875
|
].join("\n"),
|
|
1773
1876
|
input: {
|
|
1774
|
-
service_id:
|
|
1775
|
-
key:
|
|
1776
|
-
value:
|
|
1777
|
-
is_secret:
|
|
1778
|
-
target:
|
|
1877
|
+
service_id: z10.string().describe("Service publicId."),
|
|
1878
|
+
key: z10.string().min(1).max(128).describe("Env-var key."),
|
|
1879
|
+
value: z10.string().describe("New value."),
|
|
1880
|
+
is_secret: z10.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true."),
|
|
1881
|
+
target: z10.enum(["build", "runtime", "both"]).optional().describe("Injection target: build, runtime, or both. Default both.")
|
|
1779
1882
|
},
|
|
1780
1883
|
handler: async (args2, ctx) => {
|
|
1781
1884
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1827,8 +1930,8 @@ defineTool({
|
|
|
1827
1930
|
'Example: delete_env_var({ service_id: "svc_abc", key: "OLD_FLAG" }) \u2192 { ok: true }'
|
|
1828
1931
|
].join("\n"),
|
|
1829
1932
|
input: {
|
|
1830
|
-
service_id:
|
|
1831
|
-
key:
|
|
1933
|
+
service_id: z10.string().describe("Service publicId."),
|
|
1934
|
+
key: z10.string().min(1).max(128).describe("Env-var key to delete.")
|
|
1832
1935
|
},
|
|
1833
1936
|
handler: async (args2, ctx) => {
|
|
1834
1937
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1864,13 +1967,13 @@ defineTool({
|
|
|
1864
1967
|
'Example: bulk_set_env_vars({ service_id: "svc_abc", env_vars: [{ key: "PORT", value: "3000", is_secret: false }, { key: "DATABASE_URL", value: "\u2026", is_secret: true }] }) \u2192 { ok: true }'
|
|
1865
1968
|
].join("\n"),
|
|
1866
1969
|
input: {
|
|
1867
|
-
service_id:
|
|
1868
|
-
env_vars:
|
|
1869
|
-
|
|
1870
|
-
key:
|
|
1871
|
-
value:
|
|
1872
|
-
is_secret:
|
|
1873
|
-
target:
|
|
1970
|
+
service_id: z10.string().describe("Service publicId."),
|
|
1971
|
+
env_vars: z10.array(
|
|
1972
|
+
z10.object({
|
|
1973
|
+
key: z10.string().min(1).max(128),
|
|
1974
|
+
value: z10.string(),
|
|
1975
|
+
is_secret: z10.boolean().optional(),
|
|
1976
|
+
target: z10.enum(["build", "runtime", "both"]).optional()
|
|
1874
1977
|
})
|
|
1875
1978
|
).max(500).describe("Array of env-var rows. Hard cap 500.")
|
|
1876
1979
|
},
|
|
@@ -1896,7 +1999,7 @@ defineTool({
|
|
|
1896
1999
|
});
|
|
1897
2000
|
|
|
1898
2001
|
// src/tools/environments.ts
|
|
1899
|
-
import { z as
|
|
2002
|
+
import { z as z11 } from "zod";
|
|
1900
2003
|
defineTool({
|
|
1901
2004
|
name: "list_environments",
|
|
1902
2005
|
category: "environments",
|
|
@@ -1917,7 +2020,7 @@ defineTool({
|
|
|
1917
2020
|
'Example: list_environments({ project_id: "prj_abc" }) \u2192 { items: [{ name: "Production", type: "production", isDefault: true }, { name: "Staging", type: "staging" }] }'
|
|
1918
2021
|
].join("\n"),
|
|
1919
2022
|
input: {
|
|
1920
|
-
project_id:
|
|
2023
|
+
project_id: z11.string().describe("Project publicId.")
|
|
1921
2024
|
},
|
|
1922
2025
|
handler: async (args2, ctx) => {
|
|
1923
2026
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1951,13 +2054,13 @@ defineTool({
|
|
|
1951
2054
|
'Example: create_environment({ project_id: "prj_abc", name: "Staging", type: "staging" }) \u2192 { environment: { id: 7, publicId: "env_\u2026", name: "Staging", type: "staging" } }'
|
|
1952
2055
|
].join("\n"),
|
|
1953
2056
|
input: {
|
|
1954
|
-
project_id:
|
|
1955
|
-
name:
|
|
1956
|
-
type:
|
|
2057
|
+
project_id: z11.string().describe("Project publicId."),
|
|
2058
|
+
name: z11.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
|
|
2059
|
+
type: z11.enum(["production", "staging", "development", "preview"]).describe(
|
|
1957
2060
|
// §2.1: "development" here is a project deployment env, NOT a cloud Dev Box.
|
|
1958
2061
|
'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
2062
|
),
|
|
1960
|
-
is_protected:
|
|
2063
|
+
is_protected: z11.boolean().optional().describe("Require admin role for destructive actions. Default false.")
|
|
1961
2064
|
},
|
|
1962
2065
|
handler: async (args2, ctx) => {
|
|
1963
2066
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1991,8 +2094,8 @@ defineTool({
|
|
|
1991
2094
|
'Example: delete_environment({ project_id: "prj_abc", environment_id: "env_xyz" }) \u2192 { success: true }'
|
|
1992
2095
|
].join("\n"),
|
|
1993
2096
|
input: {
|
|
1994
|
-
project_id:
|
|
1995
|
-
environment_id:
|
|
2097
|
+
project_id: z11.string().describe("Project publicId."),
|
|
2098
|
+
environment_id: z11.union([z11.string(), z11.number()]).describe("Environment publicId or numeric id.")
|
|
1996
2099
|
},
|
|
1997
2100
|
handler: async (args2, ctx) => {
|
|
1998
2101
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2023,9 +2126,9 @@ defineTool({
|
|
|
2023
2126
|
'Example: promote_deploy({ service_id: "svc_staging", deploy_id: "dpl_built", target_environment_id: "env_prod" }) \u2192 { deploy: { id: 99, status: "pending", trigger: "rollback", dockerImageId: "sha256:\u2026" } }'
|
|
2024
2127
|
].join("\n"),
|
|
2025
2128
|
input: {
|
|
2026
|
-
service_id:
|
|
2027
|
-
deploy_id:
|
|
2028
|
-
target_environment_id:
|
|
2129
|
+
service_id: z11.string().describe("Source service publicId."),
|
|
2130
|
+
deploy_id: z11.string().describe("Source deploy publicId (must have a built image)."),
|
|
2131
|
+
target_environment_id: z11.union([z11.string(), z11.number()]).describe("Target environment publicId or numeric id.")
|
|
2029
2132
|
},
|
|
2030
2133
|
handler: async (args2, ctx) => {
|
|
2031
2134
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2047,6 +2150,437 @@ defineTool({
|
|
|
2047
2150
|
}
|
|
2048
2151
|
});
|
|
2049
2152
|
|
|
2153
|
+
// src/tools/analytics.ts
|
|
2154
|
+
import { z as z12 } from "zod";
|
|
2155
|
+
async function resolveSiteIds(domains, teamId, api) {
|
|
2156
|
+
if (!domains || domains.length === 0) return void 0;
|
|
2157
|
+
const { sites } = await api.get(`/api/analytics/${teamId}/sites`);
|
|
2158
|
+
const ids = [];
|
|
2159
|
+
const missing = [];
|
|
2160
|
+
for (const domain of domains) {
|
|
2161
|
+
const needle = domain.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
|
|
2162
|
+
const match = sites.find((s) => s.domain === needle);
|
|
2163
|
+
if (match) ids.push(match.id);
|
|
2164
|
+
else missing.push(domain);
|
|
2165
|
+
}
|
|
2166
|
+
if (missing.length > 0) {
|
|
2167
|
+
throw new Error(
|
|
2168
|
+
`Not tracking ${missing.join(", ")}. Known sites: ${sites.map((s) => s.domain).join(", ") || "none yet"}.`
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
2171
|
+
return ids.join(",");
|
|
2172
|
+
}
|
|
2173
|
+
defineTool({
|
|
2174
|
+
name: "list_analytics_sites",
|
|
2175
|
+
category: "analytics",
|
|
2176
|
+
description: [
|
|
2177
|
+
"List the websites this team tracks \u2014 domains, their site keys, retention, and which service (if any) serves each one.",
|
|
2178
|
+
"",
|
|
2179
|
+
"When to use: before any other analytics call, to learn which domains exist; or to fetch the script tag a site needs.",
|
|
2180
|
+
"",
|
|
2181
|
+
"The site key is PUBLIC by design \u2014 it ships in a `data-site-key` attribute that anyone can view-source, like a Sentry DSN. It is write-only and scoped to one site. Do not treat it as a secret, and do not confuse it with the team API key.",
|
|
2182
|
+
"",
|
|
2183
|
+
"Returns: { sites: Array }. Each site has id, publicId, name, domain, ingestKey, allowedOrigins, serviceId, serviceName, retentionDays, droppedCount (events refused by the hourly quota and therefore MISSING from every count for that site), and keyGraceEndsAt when a rotation is still in its grace window.",
|
|
2184
|
+
"",
|
|
2185
|
+
'Each site also carries lastEventAt (null means NOTHING has ever reached ingest for that key) plus lastRefusalReason and refusedRecently, a 7-day tally by reason. An empty chart with lastEventAt set is a site with no traffic; an empty chart with lastEventAt null is a broken setup. Do not report "no visitors" without checking which one you are looking at \u2014 call check_analytics_site for the diagnosis.',
|
|
2186
|
+
"",
|
|
2187
|
+
"Example: list_analytics_sites({}) \u2192 { sites: [{ id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026', retentionDays: 30, serviceName: 'micci-web' }] }."
|
|
2188
|
+
].join("\n"),
|
|
2189
|
+
input: {},
|
|
2190
|
+
handler: async (_args, ctx) => {
|
|
2191
|
+
const teamId = await ctx.resolveTeamId();
|
|
2192
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/sites`);
|
|
2193
|
+
const items = Array.isArray(response.sites) ? response.sites.map(shape) : [];
|
|
2194
|
+
const summary = items.length === 0 ? "No analytics sites yet. Create one with create_analytics_site." : `Tracking ${items.length} site${items.length === 1 ? "" : "s"}.`;
|
|
2195
|
+
return respond({ summary, data: { sites: items } });
|
|
2196
|
+
}
|
|
2197
|
+
});
|
|
2198
|
+
defineTool({
|
|
2199
|
+
name: "check_analytics_site",
|
|
2200
|
+
category: "analytics",
|
|
2201
|
+
description: [
|
|
2202
|
+
"Why a site is reporting nothing \u2014 the diagnosis, without having to generate traffic and guess.",
|
|
2203
|
+
"",
|
|
2204
|
+
"When to use: any time a chart is empty, a site was just set up, a key was rotated, or someone asks whether the snippet is installed correctly. Call this BEFORE telling anyone their traffic dropped.",
|
|
2205
|
+
"",
|
|
2206
|
+
"The ingest endpoint answers HTTP 204 to a real site key and to a typo alike \u2014 deliberately, so it cannot be used to enumerate keys \u2014 which means five completely different situations produce the same empty chart: a stale or mistyped key in the deployed snippet, an origin the site does not allow, a blown hourly quota, events blocked in the browser before they leave (CORS, ad blocker, CSP), and simply no visitors yet. This tool tells them apart.",
|
|
2207
|
+
"",
|
|
2208
|
+
"Returns health ('receiving' | 'quiet' | 'refusing' | 'never'), a headline and detail sentence, lastEventAt, lastRefusalAt/Reason/Origin, refusedRecently (a 7-day tally by reason), the site key the snippet must carry, allowed origins, and quota usage this hour.",
|
|
2209
|
+
"",
|
|
2210
|
+
"health='never' means nothing has EVER reached ingest for this key, so the request is not leaving the browser \u2014 check the snippet is in the deployed HTML and tell the user to add `data-debug` to the script tag, which makes the tracker log its resolved endpoint and every accepted 204 to the console.",
|
|
2211
|
+
"",
|
|
2212
|
+
"Example: check_analytics_site({ domain: 'example.com' }) \u2192 { health: 'refusing', headline: 'Reaching us, and being refused.', detail: 'Something on example.com posted 214 event(s) with a site key we do not recognise\u2026', lastEventAt: null, refusedRecently: { unknown_key: 214, bad_origin: 0, bot: 3, over_quota: 0 } }."
|
|
2213
|
+
].join("\n"),
|
|
2214
|
+
input: {
|
|
2215
|
+
domain: z12.string().max(253).describe("Bare hostname of a site this team tracks.")
|
|
2216
|
+
},
|
|
2217
|
+
handler: async (args2, ctx) => {
|
|
2218
|
+
const teamId = await ctx.resolveTeamId();
|
|
2219
|
+
const siteIds = await resolveSiteIds([args2.domain], teamId, ctx.api);
|
|
2220
|
+
const status = await ctx.api.get(
|
|
2221
|
+
`/api/analytics/${teamId}/sites/${siteIds}/status`
|
|
2222
|
+
);
|
|
2223
|
+
return respond({ summary: `${args2.domain}: ${status.headline}`, data: shape(status) });
|
|
2224
|
+
}
|
|
2225
|
+
});
|
|
2226
|
+
defineTool({
|
|
2227
|
+
name: "get_analytics_summary",
|
|
2228
|
+
category: "analytics",
|
|
2229
|
+
description: [
|
|
2230
|
+
"One row per site: visitors, pageviews, bounce rate, average visit and live visitors, each with the previous period to compare against.",
|
|
2231
|
+
"",
|
|
2232
|
+
'When to use: "how are my sites doing", a weekly check, or spotting which of several domains moved. This is the cross-site view; use get_analytics_overview for the breakdowns behind one of them.',
|
|
2233
|
+
"",
|
|
2234
|
+
"READ THE VISITOR NUMBER CAREFULLY. Inside a site's raw-event window (35 days by default, so 24h through 30d are raw) `visitors` is a true distinct count over the range. Beyond it the answer comes from a daily rollup and `visitors` is each day's uniques ADDED TOGETHER \u2014 someone who visits daily counts once per day. `visitorsAreSummedDailies` says which you got; quote it when reporting a 90d or 12mo number.",
|
|
2235
|
+
"",
|
|
2236
|
+
"Visitors are never summed ACROSS sites. The same person on two domains is two session ids, and reconciling them would require the cross-domain identity a cookieless tracker exists not to build. Add pageviews if you need a total; do not add visitors.",
|
|
2237
|
+
"",
|
|
2238
|
+
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site).",
|
|
2239
|
+
"",
|
|
2240
|
+
"Example: get_analytics_summary({ range: '30d' }) \u2192 { range: '30d', sites: [{ domain: 'hoststack.dev', current: { visitors: 4210, pageviews: 11890, bounceRate: 47.2 }, previous: {\u2026}, live: 3, visitorsAreSummedDailies: false }] }."
|
|
2241
|
+
].join("\n"),
|
|
2242
|
+
input: {
|
|
2243
|
+
range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
|
|
2244
|
+
domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks.")
|
|
2245
|
+
},
|
|
2246
|
+
handler: async (args2, ctx) => {
|
|
2247
|
+
const teamId = await ctx.resolveTeamId();
|
|
2248
|
+
const siteIds = await resolveSiteIds(args2.domains, teamId, ctx.api);
|
|
2249
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/summary`, {
|
|
2250
|
+
range: args2.range ?? "7d",
|
|
2251
|
+
...siteIds ? { siteIds } : {}
|
|
2252
|
+
});
|
|
2253
|
+
const sites = Array.isArray(response.sites) ? response.sites : [];
|
|
2254
|
+
const summed = sites.some((s) => s.visitorsAreSummedDailies);
|
|
2255
|
+
const summary = sites.length === 0 ? "No sites to report on." : `${sites.length} site${sites.length === 1 ? "" : "s"} over ${response.range}.${summed ? " Visitor counts are daily uniques summed, not distinct over the range \u2014 this range is past the raw-event window." : ""}`;
|
|
2256
|
+
return respond({ summary, data: { range: response.range, sites: sites.map(shape) } });
|
|
2257
|
+
}
|
|
2258
|
+
});
|
|
2259
|
+
defineTool({
|
|
2260
|
+
name: "get_analytics_overview",
|
|
2261
|
+
category: "analytics",
|
|
2262
|
+
description: [
|
|
2263
|
+
"The full breakdown for one site (or several): timeseries, top paths, referrers, custom events, browsers, operating systems, languages, screen sizes, campaigns, devices and countries.",
|
|
2264
|
+
"",
|
|
2265
|
+
"When to use: after get_analytics_summary has told you WHICH site moved, and you need to see what drove it \u2014 a referrer that appeared, a campaign that landed, a path that spiked.",
|
|
2266
|
+
"",
|
|
2267
|
+
"Two things change past the raw-event window (35 days by default, so 24h through 30d are raw): `visitors` becomes daily uniques summed rather than a distinct count (`visitorsAreSummedDailies`), and FILTERS ARE IGNORED because the rollup stores per-day aggregates with no events left to filter (`filtersSupported: false`). Check both fields before drawing a conclusion; do not silently present a filtered request that was not filtered.",
|
|
2268
|
+
"",
|
|
2269
|
+
"Inputs: range (24h|7d|30d|90d|12mo, default 7d), domains (optional list; omit for every site), filters (optional map \u2014 country, browser, os, device, language, path, referrer, utmSource, utmMedium, utmCampaign, utmTerm, utmContent).",
|
|
2270
|
+
"",
|
|
2271
|
+
"Example: get_analytics_overview({ domains: ['hoststack.dev'], range: '7d', filters: { country: 'DK' } }) \u2192 { source: 'raw', filtersSupported: true, summary: { current: { visitors: 310, \u2026 } }, topPaths: [{ path: '/pricing', pageviews: 214 }], topReferrers: [{ referrer: 'Direct', visits: 190 }], \u2026 }."
|
|
2272
|
+
].join("\n"),
|
|
2273
|
+
input: {
|
|
2274
|
+
range: z12.enum(["24h", "7d", "30d", "90d", "12mo"]).optional().describe("Default 7d."),
|
|
2275
|
+
domains: z12.array(z12.string().max(253)).max(50).optional().describe("Bare hostnames. Omit for every site this team tracks."),
|
|
2276
|
+
filters: z12.record(z12.string(), z12.string().max(200)).optional().describe("Dimension filters. Ignored for ranges past the raw-event window.")
|
|
2277
|
+
},
|
|
2278
|
+
handler: async (args2, ctx) => {
|
|
2279
|
+
const teamId = await ctx.resolveTeamId();
|
|
2280
|
+
const siteIds = await resolveSiteIds(args2.domains, teamId, ctx.api);
|
|
2281
|
+
const response = await ctx.api.get(`/api/analytics/${teamId}/overview`, {
|
|
2282
|
+
range: args2.range ?? "7d",
|
|
2283
|
+
...siteIds ? { siteIds } : {},
|
|
2284
|
+
...args2.filters ?? {}
|
|
2285
|
+
});
|
|
2286
|
+
const { current } = response.summary;
|
|
2287
|
+
const caveats = [];
|
|
2288
|
+
if (response.visitorsAreSummedDailies) {
|
|
2289
|
+
caveats.push(
|
|
2290
|
+
"visitors are daily uniques summed (this range is past the raw-event window)"
|
|
2291
|
+
);
|
|
2292
|
+
}
|
|
2293
|
+
if (args2.filters && Object.keys(args2.filters).length > 0 && !response.filtersSupported) {
|
|
2294
|
+
caveats.push(
|
|
2295
|
+
"the filters you passed were NOT applied \u2014 they only work on shorter ranges"
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
const summary = `${current.pageviews.toLocaleString()} pageviews from ${current.visitors.toLocaleString()} visitors over ${response.range}${caveats.length > 0 ? `. Note: ${caveats.join("; ")}.` : "."}`;
|
|
2299
|
+
return respond({ summary, data: response });
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
defineTool({
|
|
2303
|
+
name: "create_analytics_site",
|
|
2304
|
+
category: "analytics",
|
|
2305
|
+
description: [
|
|
2306
|
+
"Start tracking a domain, and return the script tag to paste into its <head>.",
|
|
2307
|
+
"",
|
|
2308
|
+
"When to use: the user wants analytics for a site that is not in list_analytics_sites yet. Works for any domain, whether or not it is hosted on HostStack.",
|
|
2309
|
+
"",
|
|
2310
|
+
"If the domain is already attached to a service on this team, the new site links itself to that service and appears on its Analytics tab. Nothing is counted until the snippet is actually on the page \u2014 creating the site alone produces an empty dashboard, which is expected, not a fault.",
|
|
2311
|
+
"",
|
|
2312
|
+
'Inputs: domain (required, bare hostname \u2014 "example.com", not a URL), name (optional display name, defaults to the domain).',
|
|
2313
|
+
"",
|
|
2314
|
+
`Example: create_analytics_site({ domain: 'micci.dk' }) \u2192 { site: { id: 3, domain: 'micci.dk', ingestKey: 'site_\u2026' }, snippet: '<script defer src="https://hoststack.dev/t.js" data-site-key="site_\u2026"></script>' }.`
|
|
2315
|
+
].join("\n"),
|
|
2316
|
+
input: {
|
|
2317
|
+
domain: z12.string().min(1).max(253).describe("Bare hostname, e.g. example.com"),
|
|
2318
|
+
name: z12.string().min(1).max(100).optional().describe("Display name. Defaults to the domain.")
|
|
2319
|
+
},
|
|
2320
|
+
handler: async (args2, ctx) => {
|
|
2321
|
+
const teamId = await ctx.resolveTeamId();
|
|
2322
|
+
const site = await ctx.api.post(`/api/analytics/${teamId}/sites`, {
|
|
2323
|
+
domain: args2.domain,
|
|
2324
|
+
...args2.name ? { name: args2.name } : {}
|
|
2325
|
+
});
|
|
2326
|
+
const snippet = `<script defer src="https://hoststack.dev/t.js" data-site-key="${site.ingestKey}"></script>`;
|
|
2327
|
+
return respond({
|
|
2328
|
+
summary: `Now tracking ${site.domain}. Paste the snippet into its <head> \u2014 nothing is counted until you do.`,
|
|
2329
|
+
data: { site: shape(site), snippet }
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
// src/tools/errors.ts
|
|
2335
|
+
import { z as z13 } from "zod";
|
|
2336
|
+
defineTool({
|
|
2337
|
+
name: "list_error_issues",
|
|
2338
|
+
category: "errors",
|
|
2339
|
+
description: [
|
|
2340
|
+
"List error issues for the team \u2014 exceptions the team's own applications reported, grouped by cause rather than listed one per event.",
|
|
2341
|
+
"",
|
|
2342
|
+
'When to use: "what is throwing in production", triaging after a deploy, or finding the bug behind a user report. Pairs with list_alerts (which tells you an alert fired) and get_service_logs (which shows the raw output around it).',
|
|
2343
|
+
"",
|
|
2344
|
+
'Grouping: an issue is one distinct problem. Its fingerprint is the exception class, the message with variable parts removed (so "user 41 not found" and "user 9002 not found" are ONE issue), and the topmost stack frame in the team\'s own code \u2014 not the framework\'s. `culprit` is that frame, and it is the line to open first.',
|
|
2345
|
+
"",
|
|
2346
|
+
"Counts are exact; occurrence samples are not. `occurrenceCount` is the true number of times it happened. A bounded number of full occurrences is retained per issue (see get_error_issue), and `droppedCount` is how many were counted but not stored because the service exceeded its hourly ingest quota.",
|
|
2347
|
+
"",
|
|
2348
|
+
'Defaults to status=unresolved, because the list exists to answer "what is broken". Pass status=resolved or status=ignored for the others.',
|
|
2349
|
+
"",
|
|
2350
|
+
"Inputs (all optional): serviceId, status (unresolved|resolved|ignored), q (substring match on title/culprit), limit (default 50, max 200), offset, sort (last_seen|first_seen|count).",
|
|
2351
|
+
"",
|
|
2352
|
+
'Returns: { issues: Array, total: number }. Each issue has id, publicId, serviceId, serviceName, type, title, culprit, level, status, occurrenceCount, droppedCount, affectedUsers (capped at 500 \u2014 500 means "500 or more"), firstSeenAt, lastSeenAt, firstSeenRelease, lastSeenRelease, environment, sourceTaskPublicId (a dev-box task already open for it).',
|
|
2353
|
+
"",
|
|
2354
|
+
"Example: list_error_issues({ serviceId: 48, sort: 'count' }) \u2192 { issues: [{ id: 12, type: 'TypeError', title: 'TypeError: cart.total is not a function', culprit: 'src/checkout.ts:12 in checkout', occurrenceCount: 4187, affectedUsers: 96, lastSeenRelease: 'a91f3c2', \u2026 }], total: 3 }."
|
|
2355
|
+
].join("\n"),
|
|
2356
|
+
input: {
|
|
2357
|
+
serviceId: z13.number().int().positive().optional().describe("Only issues for this service."),
|
|
2358
|
+
status: z13.enum(["unresolved", "resolved", "ignored"]).optional().describe("Default unresolved."),
|
|
2359
|
+
q: z13.string().max(200).optional().describe("Substring match on title or culprit."),
|
|
2360
|
+
limit: z13.number().int().positive().max(200).optional().describe("Default 50, cap 200."),
|
|
2361
|
+
offset: z13.number().int().min(0).optional(),
|
|
2362
|
+
sort: z13.enum(["last_seen", "first_seen", "count"]).optional().describe("Default last_seen.")
|
|
2363
|
+
},
|
|
2364
|
+
handler: async (args2, ctx) => {
|
|
2365
|
+
const teamId = await ctx.resolveTeamId();
|
|
2366
|
+
const params = {};
|
|
2367
|
+
for (const key of ["serviceId", "status", "q", "limit", "offset", "sort"]) {
|
|
2368
|
+
const value = args2[key];
|
|
2369
|
+
if (value !== void 0) params[key] = String(value);
|
|
2370
|
+
}
|
|
2371
|
+
const response = await ctx.api.get(`/api/errors/${teamId}/issues`, params);
|
|
2372
|
+
const items = Array.isArray(response.issues) ? response.issues.map(shape) : [];
|
|
2373
|
+
const summary = items.length === 0 ? `No ${args2.status ?? "unresolved"} error issues${args2.serviceId ? " for that service" : ""}.` : `Returned ${items.length} of ${response.total} ${args2.status ?? "unresolved"} issue${response.total === 1 ? "" : "s"}.`;
|
|
2374
|
+
return respond({ summary, data: { issues: items, total: response.total } });
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
2377
|
+
defineTool({
|
|
2378
|
+
name: "get_error_issue",
|
|
2379
|
+
category: "errors",
|
|
2380
|
+
description: [
|
|
2381
|
+
"Fetch one error issue plus its most recent stored occurrences \u2014 the stack traces, request context and releases behind the aggregate.",
|
|
2382
|
+
"",
|
|
2383
|
+
"When to use: after list_error_issues has told you WHICH problem to look at, and you need the actual stack to reason about the cause.",
|
|
2384
|
+
"",
|
|
2385
|
+
"Occurrences are SAMPLES, not the full history: a bounded number is kept per issue while the count stays exact, and they age out after 30 days while the issue itself remains. An issue whose samples have expired returns an empty occurrences array \u2014 that is normal, not an error.",
|
|
2386
|
+
"",
|
|
2387
|
+
"Each occurrence has stack (raw, as the runtime printed it), context (tenant-supplied JSON, with sensitive keys already redacted), requestId, release, environment and createdAt.",
|
|
2388
|
+
"",
|
|
2389
|
+
"Inputs: issueId (required), occurrences (how many samples, default 5, max 50).",
|
|
2390
|
+
"",
|
|
2391
|
+
"Returns: { issue: {...}, occurrences: Array }.",
|
|
2392
|
+
"",
|
|
2393
|
+
"Example: get_error_issue({ issueId: 12 }) \u2192 { issue: { title: 'TypeError: cart.total is not a function', occurrenceCount: 4187, \u2026 }, occurrences: [{ stack: ' at checkout (/app/src/checkout.ts:12:9)\u2026', requestId: 'req_9f3c', release: 'a91f3c2', createdAt: '\u2026' }] }."
|
|
2394
|
+
].join("\n"),
|
|
2395
|
+
input: {
|
|
2396
|
+
issueId: z13.number().int().positive().describe("Numeric issue id from list_error_issues."),
|
|
2397
|
+
occurrences: z13.number().int().positive().max(50).optional().describe("How many samples to include. Default 5.")
|
|
2398
|
+
},
|
|
2399
|
+
handler: async (args2, ctx) => {
|
|
2400
|
+
const teamId = await ctx.resolveTeamId();
|
|
2401
|
+
const [issueRes, occRes] = await Promise.all([
|
|
2402
|
+
ctx.api.get(`/api/errors/${teamId}/issues/${args2.issueId}`),
|
|
2403
|
+
ctx.api.get(
|
|
2404
|
+
`/api/errors/${teamId}/issues/${args2.issueId}/occurrences`,
|
|
2405
|
+
{ limit: String(args2.occurrences ?? 5) }
|
|
2406
|
+
)
|
|
2407
|
+
]);
|
|
2408
|
+
const occurrences = Array.isArray(occRes.occurrences) ? occRes.occurrences.map(shape) : [];
|
|
2409
|
+
const issue = shape(issueRes.issue);
|
|
2410
|
+
return respond({
|
|
2411
|
+
summary: `${String(issue["title"] ?? "Issue")} \u2014 ${String(issue["occurrenceCount"] ?? 0)} occurrence(s), ${occurrences.length} sample(s) available.`,
|
|
2412
|
+
data: { issue, occurrences }
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
});
|
|
2416
|
+
defineTool({
|
|
2417
|
+
name: "update_error_issue",
|
|
2418
|
+
category: "errors",
|
|
2419
|
+
description: [
|
|
2420
|
+
"Resolve, ignore or reopen an error issue.",
|
|
2421
|
+
"",
|
|
2422
|
+
"When to use: after fixing a bug (resolve, so a recurrence is reported as a regression), or to silence noise you have decided to live with (ignore).",
|
|
2423
|
+
"",
|
|
2424
|
+
"`resolved` records the release it was resolved in. If the issue occurs again afterwards it reopens itself and fires an `error.issue_regressed` alert \u2014 which is the point of resolving rather than ignoring, and the reason not to resolve something you have not actually fixed.",
|
|
2425
|
+
"",
|
|
2426
|
+
"`ignored` keeps counting occurrences and stops telling anyone. An ignored issue never reopens itself, so this is the right choice for noise you have decided to live with, and the wrong choice for something you intend to fix.",
|
|
2427
|
+
"",
|
|
2428
|
+
"`unresolved` reopens it.",
|
|
2429
|
+
"",
|
|
2430
|
+
"Inputs: issueId (required), status (required).",
|
|
2431
|
+
"",
|
|
2432
|
+
"Returns: { issue: {...} } with the updated row.",
|
|
2433
|
+
"",
|
|
2434
|
+
"Example: update_error_issue({ issueId: 12, status: 'resolved' }) \u2192 { issue: { status: 'resolved', resolvedInRelease: 'a91f3c2', \u2026 } }."
|
|
2435
|
+
].join("\n"),
|
|
2436
|
+
input: {
|
|
2437
|
+
issueId: z13.number().int().positive(),
|
|
2438
|
+
status: z13.enum(["unresolved", "resolved", "ignored"])
|
|
2439
|
+
},
|
|
2440
|
+
handler: async (args2, ctx) => {
|
|
2441
|
+
const teamId = await ctx.resolveTeamId();
|
|
2442
|
+
const response = await ctx.api.patch(
|
|
2443
|
+
`/api/errors/${teamId}/issues/${args2.issueId}`,
|
|
2444
|
+
{ status: args2.status }
|
|
2445
|
+
);
|
|
2446
|
+
return respond({
|
|
2447
|
+
summary: `Issue ${args2.issueId} is now ${args2.status}.`,
|
|
2448
|
+
data: { issue: shape(response.issue) }
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
});
|
|
2452
|
+
defineTool({
|
|
2453
|
+
name: "fix_error_in_dev_box",
|
|
2454
|
+
category: "errors",
|
|
2455
|
+
description: [
|
|
2456
|
+
"Turn an error issue into a coding-agent task in the project's dev box, with the repository already checked out.",
|
|
2457
|
+
"",
|
|
2458
|
+
"When to use: the issue is real, it is in the team's own code, and somebody is going to have to open the repository anyway. This writes the whole briefing so nobody has to reconstruct it from the dashboard.",
|
|
2459
|
+
"",
|
|
2460
|
+
"This is the thing a third-party error tracker structurally cannot do. The task carries the exception, the stack with the team's OWN frames marked (`>>`), the release it happened on, how often and to how many users, and a real request context \u2014 plus hard boundaries: work on a branch, do not deploy, do not delete tests, and stop and say so if the cause turns out not to be in this repository.",
|
|
2461
|
+
"",
|
|
2462
|
+
"It CREATES the task; it does not start the agent. Running spends the team's own agent tokens and is a separate decision made in the dev box UI.",
|
|
2463
|
+
"",
|
|
2464
|
+
"Refused with 422 when the cause cannot be attributed to the repository \u2014 every stack frame inside a dependency, no usable stack at all, a browser extension, or a connection failure with no in-app frame. That gate is deliberate: an agent told to fix something it cannot reach does not refuse, it produces a confident and useless diff.",
|
|
2465
|
+
"",
|
|
2466
|
+
"Pressing this twice for one issue returns the existing task (alreadyExisted=true) rather than queueing a duplicate run.",
|
|
2467
|
+
"",
|
|
2468
|
+
"Inputs: issueId (required), serviceId (optional \u2014 pin to a specific dev box; omitted, the project's box is used).",
|
|
2469
|
+
"",
|
|
2470
|
+
"Returns: { task, alreadyExisted, box: { serviceId, name, asleep }, automodeEnabled }.",
|
|
2471
|
+
"",
|
|
2472
|
+
"Example: fix_error_in_dev_box({ issueId: 12 }) \u2192 { task: { title: 'Fix error: TypeError in src/checkout.ts:12' }, alreadyExisted: false, box: { name: 'shop-dev', asleep: true }, automodeEnabled: false }."
|
|
2473
|
+
].join("\n"),
|
|
2474
|
+
input: {
|
|
2475
|
+
issueId: z13.number().int().positive(),
|
|
2476
|
+
serviceId: z13.number().int().positive().optional().describe("Dev box to pin the task to. Omitted, the project's box is used.")
|
|
2477
|
+
},
|
|
2478
|
+
handler: async (args2, ctx) => {
|
|
2479
|
+
const teamId = await ctx.resolveTeamId();
|
|
2480
|
+
const body = { issueId: args2.issueId };
|
|
2481
|
+
if (args2.serviceId !== void 0) body["serviceId"] = args2.serviceId;
|
|
2482
|
+
const response = await ctx.api.post(
|
|
2483
|
+
`/api/dev-env-tasks/${teamId}/from-issue`,
|
|
2484
|
+
body
|
|
2485
|
+
);
|
|
2486
|
+
const verb = response.alreadyExisted ? "Already queued" : "Queued";
|
|
2487
|
+
const note = response.box.asleep ? " That box is asleep \u2014 resume it before running the task." : "";
|
|
2488
|
+
return respond({
|
|
2489
|
+
summary: `${verb} in dev box "${response.box.name}": ${response.task.title ?? "task"}.${note}`,
|
|
2490
|
+
data: shape(response)
|
|
2491
|
+
});
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
defineTool({
|
|
2495
|
+
name: "list_ingest_keys",
|
|
2496
|
+
category: "errors",
|
|
2497
|
+
description: [
|
|
2498
|
+
"List a service's error-ingest keys. Only prefixes and last-used timestamps \u2014 the key itself is stored as a hash and is never readable again.",
|
|
2499
|
+
"",
|
|
2500
|
+
"When to use: checking whether a service is wired up for error reporting at all, or whether an old key is still in use before revoking it (`lastUsedAt` is stamped lazily, at most once a minute).",
|
|
2501
|
+
"",
|
|
2502
|
+
"Inputs: serviceId (required).",
|
|
2503
|
+
"",
|
|
2504
|
+
"Returns: { keys: Array } with id, publicId, name, prefix, lastUsedAt, createdAt.",
|
|
2505
|
+
"",
|
|
2506
|
+
"Example: list_ingest_keys({ serviceId: 48 }) \u2192 { keys: [{ id: 3, name: 'default', prefix: 'ing_9f3c1ab2', lastUsedAt: '2026-08-21T09:14:00Z' }] }."
|
|
2507
|
+
].join("\n"),
|
|
2508
|
+
input: { serviceId: z13.number().int().positive() },
|
|
2509
|
+
handler: async (args2, ctx) => {
|
|
2510
|
+
const teamId = await ctx.resolveTeamId();
|
|
2511
|
+
const response = await ctx.api.get(
|
|
2512
|
+
`/api/services/${teamId}/${args2.serviceId}/ingest-keys`
|
|
2513
|
+
);
|
|
2514
|
+
const keys = Array.isArray(response.keys) ? response.keys.map(shape) : [];
|
|
2515
|
+
return respond({
|
|
2516
|
+
summary: keys.length === 0 ? "No ingest keys on this service \u2014 it cannot report errors yet." : `${keys.length} ingest key${keys.length === 1 ? "" : "s"} on this service.`,
|
|
2517
|
+
data: { keys }
|
|
2518
|
+
});
|
|
2519
|
+
}
|
|
2520
|
+
});
|
|
2521
|
+
defineTool({
|
|
2522
|
+
name: "create_ingest_key",
|
|
2523
|
+
category: "errors",
|
|
2524
|
+
description: [
|
|
2525
|
+
"Mint a write-only error-ingest key for a service, so its application can report exceptions.",
|
|
2526
|
+
"",
|
|
2527
|
+
"When to use: setting a service up for error tracking for the first time, or rotating a key that has leaked or is being retired.",
|
|
2528
|
+
"",
|
|
2529
|
+
"The response is the ONLY place the plaintext key ever exists \u2014 it is stored as a SHA-256 hash and cannot be shown again. Put it wherever the application reads it from (an env var, usually) before discarding the response.",
|
|
2530
|
+
"",
|
|
2531
|
+
"The key is deliberately low-privilege: it can create error events for this ONE service and can do nothing else \u2014 it cannot read the issues it created, list services, or touch anything on the team. That is why shipping it inside the application, including a browser bundle where it is world-readable, is expected rather than a mistake.",
|
|
2532
|
+
"",
|
|
2533
|
+
'Reporting is a plain HTTP POST, no SDK required: POST {baseUrl}/api/ingest/errors/{key} with {"events":[{"type","value","stack","level","context"}]}, up to 100 events per request.',
|
|
2534
|
+
"",
|
|
2535
|
+
'Inputs: serviceId (required), name (optional label, default "default"). Max 5 keys per service \u2014 the extras exist so a key can be rotated without downtime (create the second, ship it, delete the first).',
|
|
2536
|
+
"",
|
|
2537
|
+
"Returns: { key: { id, publicId, name, prefix, key } } where `key` is the plaintext.",
|
|
2538
|
+
"",
|
|
2539
|
+
"Example: create_ingest_key({ serviceId: 48 }) \u2192 { id: 4, name: 'default', prefix: 'ing_1b7d40ae', key: 'ing_1b7d40ae\u2026' }."
|
|
2540
|
+
].join("\n"),
|
|
2541
|
+
input: {
|
|
2542
|
+
serviceId: z13.number().int().positive(),
|
|
2543
|
+
name: z13.string().min(1).max(100).optional().describe('Label. Default "default".')
|
|
2544
|
+
},
|
|
2545
|
+
handler: async (args2, ctx) => {
|
|
2546
|
+
const teamId = await ctx.resolveTeamId();
|
|
2547
|
+
const response = await ctx.api.post(
|
|
2548
|
+
`/api/services/${teamId}/${args2.serviceId}/ingest-keys`,
|
|
2549
|
+
{ name: args2.name ?? "default" }
|
|
2550
|
+
);
|
|
2551
|
+
return respond({
|
|
2552
|
+
summary: "Ingest key created. The plaintext key is in the payload and is not retrievable again \u2014 store it now.",
|
|
2553
|
+
data: shape(response.key)
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
});
|
|
2557
|
+
defineTool({
|
|
2558
|
+
name: "delete_ingest_key",
|
|
2559
|
+
category: "errors",
|
|
2560
|
+
description: [
|
|
2561
|
+
"Revoke an error-ingest key. It stops working immediately, including on API replicas that had it cached.",
|
|
2562
|
+
"",
|
|
2563
|
+
"When to use: finishing a key rotation, or containing a key that leaked somewhere it should not have.",
|
|
2564
|
+
"",
|
|
2565
|
+
"Anything still posting with it starts getting 401s, so revoke the OLD key after the new one is deployed, not before.",
|
|
2566
|
+
"",
|
|
2567
|
+
"Inputs: serviceId, keyId (both required).",
|
|
2568
|
+
"",
|
|
2569
|
+
"Returns: { success: true }.",
|
|
2570
|
+
"",
|
|
2571
|
+
"Example: delete_ingest_key({ serviceId: 48, keyId: 3 }) \u2192 { success: true }."
|
|
2572
|
+
].join("\n"),
|
|
2573
|
+
input: {
|
|
2574
|
+
serviceId: z13.number().int().positive(),
|
|
2575
|
+
keyId: z13.number().int().positive()
|
|
2576
|
+
},
|
|
2577
|
+
handler: async (args2, ctx) => {
|
|
2578
|
+
const teamId = await ctx.resolveTeamId();
|
|
2579
|
+
await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/ingest-keys/${args2.keyId}`);
|
|
2580
|
+
return respond({ summary: `Ingest key ${args2.keyId} revoked.` });
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2050
2584
|
// src/tools/github.ts
|
|
2051
2585
|
defineTool({
|
|
2052
2586
|
name: "sync_github_repos",
|
|
@@ -2154,7 +2688,7 @@ defineTool({
|
|
|
2154
2688
|
});
|
|
2155
2689
|
|
|
2156
2690
|
// src/tools/notifications.ts
|
|
2157
|
-
import { z as
|
|
2691
|
+
import { z as z14 } from "zod";
|
|
2158
2692
|
var NOTIFICATION_EVENTS = [
|
|
2159
2693
|
"deploy.started",
|
|
2160
2694
|
"deploy.succeeded",
|
|
@@ -2168,9 +2702,25 @@ var NOTIFICATION_EVENTS = [
|
|
|
2168
2702
|
"service.auto_suspended",
|
|
2169
2703
|
"service.acme_cert_failed",
|
|
2170
2704
|
"service.resource_alert",
|
|
2705
|
+
"service.uptime_down",
|
|
2706
|
+
"service.uptime_recovered",
|
|
2707
|
+
"error.issue_new",
|
|
2708
|
+
"error.issue_regressed",
|
|
2171
2709
|
"git.auth_failed",
|
|
2172
2710
|
"cron.execution_failed",
|
|
2173
|
-
"workflow.failed"
|
|
2711
|
+
"workflow.failed",
|
|
2712
|
+
"devenv.agent.needs_input",
|
|
2713
|
+
"devenv.agent.finished",
|
|
2714
|
+
"devenv.task.created",
|
|
2715
|
+
"devenv.task.needs_input",
|
|
2716
|
+
"devenv.task.finished",
|
|
2717
|
+
"database.backup_failed",
|
|
2718
|
+
"database.restore_failed",
|
|
2719
|
+
"machine.offline",
|
|
2720
|
+
"machine.online",
|
|
2721
|
+
"billing.invoice",
|
|
2722
|
+
"billing.payment_failed",
|
|
2723
|
+
"billing.spend_limit"
|
|
2174
2724
|
];
|
|
2175
2725
|
defineTool({
|
|
2176
2726
|
name: "list_notification_channels",
|
|
@@ -2209,17 +2759,21 @@ defineTool({
|
|
|
2209
2759
|
" - webhook_url: Slack/Discord webhook URL OR email address.",
|
|
2210
2760
|
" - 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).",
|
|
2211
2761
|
"",
|
|
2212
|
-
|
|
2762
|
+
// Rendered from the enum instead of retyped: the hand-written copy of
|
|
2763
|
+
// this sentence went stale the same way the enum did, and a description
|
|
2764
|
+
// that disagrees with the schema teaches the model to send values the
|
|
2765
|
+
// tool then rejects.
|
|
2766
|
+
`Valid events: ${NOTIFICATION_EVENTS.join(", ")}.`,
|
|
2213
2767
|
"",
|
|
2214
2768
|
"Returns: { channel: Channel }.",
|
|
2215
2769
|
"",
|
|
2216
2770
|
"Example: create_notification_channel({ type: 'slack', name: 'eng-alerts', webhook_url: 'https://hooks.slack.com/\u2026', events: ['deploy.failed', 'git.auth_failed', 'service.restart_failed'] })"
|
|
2217
2771
|
].join("\n"),
|
|
2218
2772
|
input: {
|
|
2219
|
-
type:
|
|
2220
|
-
name:
|
|
2221
|
-
webhook_url:
|
|
2222
|
-
events:
|
|
2773
|
+
type: z14.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
2774
|
+
name: z14.string().min(1).max(128).describe("Human-readable label."),
|
|
2775
|
+
webhook_url: z14.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
2776
|
+
events: z14.array(z14.enum(NOTIFICATION_EVENTS)).describe(
|
|
2223
2777
|
"List of events the channel subscribes to. Empty list = subscribe to nothing."
|
|
2224
2778
|
)
|
|
2225
2779
|
},
|
|
@@ -2257,10 +2811,10 @@ defineTool({
|
|
|
2257
2811
|
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
2258
2812
|
].join("\n"),
|
|
2259
2813
|
input: {
|
|
2260
|
-
channel_id:
|
|
2261
|
-
name:
|
|
2262
|
-
active:
|
|
2263
|
-
events:
|
|
2814
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
2815
|
+
name: z14.string().min(1).max(128).optional().describe("New label."),
|
|
2816
|
+
active: z14.boolean().optional().describe("false silences without deleting."),
|
|
2817
|
+
events: z14.array(z14.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
2264
2818
|
},
|
|
2265
2819
|
handler: async (args2, ctx) => {
|
|
2266
2820
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2299,7 +2853,7 @@ defineTool({
|
|
|
2299
2853
|
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
2300
2854
|
].join("\n"),
|
|
2301
2855
|
input: {
|
|
2302
|
-
channel_id:
|
|
2856
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id.")
|
|
2303
2857
|
},
|
|
2304
2858
|
handler: async (args2, ctx) => {
|
|
2305
2859
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2326,7 +2880,7 @@ defineTool({
|
|
|
2326
2880
|
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
2327
2881
|
].join("\n"),
|
|
2328
2882
|
input: {
|
|
2329
|
-
channel_id:
|
|
2883
|
+
channel_id: z14.number().int().positive().describe("Numeric channel id.")
|
|
2330
2884
|
},
|
|
2331
2885
|
handler: async (args2, ctx) => {
|
|
2332
2886
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2341,7 +2895,7 @@ defineTool({
|
|
|
2341
2895
|
});
|
|
2342
2896
|
|
|
2343
2897
|
// src/tools/projects.ts
|
|
2344
|
-
import { z as
|
|
2898
|
+
import { z as z15 } from "zod";
|
|
2345
2899
|
var AVAILABLE_REGION_IDS = ["eu-central-1"];
|
|
2346
2900
|
defineTool({
|
|
2347
2901
|
name: "list_projects",
|
|
@@ -2382,9 +2936,9 @@ defineTool({
|
|
|
2382
2936
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "eu-central-1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
2383
2937
|
].join("\n"),
|
|
2384
2938
|
input: {
|
|
2385
|
-
name:
|
|
2386
|
-
description:
|
|
2387
|
-
region:
|
|
2939
|
+
name: z15.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
2940
|
+
description: z15.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
2941
|
+
region: z15.enum(AVAILABLE_REGION_IDS).optional().describe("Region: eu-central-1 (Falkenstein) \u2014 currently the only available region.")
|
|
2388
2942
|
},
|
|
2389
2943
|
handler: async (args2, ctx) => {
|
|
2390
2944
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2417,9 +2971,9 @@ defineTool({
|
|
|
2417
2971
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
2418
2972
|
].join("\n"),
|
|
2419
2973
|
input: {
|
|
2420
|
-
project_id:
|
|
2421
|
-
name:
|
|
2422
|
-
description:
|
|
2974
|
+
project_id: z15.string().describe("Project publicId."),
|
|
2975
|
+
name: z15.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
2976
|
+
description: z15.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
2423
2977
|
},
|
|
2424
2978
|
handler: async (args2, ctx) => {
|
|
2425
2979
|
if (args2.name === void 0 && args2.description === void 0) {
|
|
@@ -2453,7 +3007,7 @@ defineTool({
|
|
|
2453
3007
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
2454
3008
|
].join("\n"),
|
|
2455
3009
|
input: {
|
|
2456
|
-
project_id:
|
|
3010
|
+
project_id: z15.string().describe("Project publicId (e.g. prj_abc123).")
|
|
2457
3011
|
},
|
|
2458
3012
|
handler: async (args2, ctx) => {
|
|
2459
3013
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2465,7 +3019,7 @@ defineTool({
|
|
|
2465
3019
|
});
|
|
2466
3020
|
|
|
2467
3021
|
// src/tools/resource-links.ts
|
|
2468
|
-
import { z as
|
|
3022
|
+
import { z as z16 } from "zod";
|
|
2469
3023
|
var RESOURCE_LINK_TYPES = [
|
|
2470
3024
|
"database",
|
|
2471
3025
|
"object_storage",
|
|
@@ -2526,7 +3080,7 @@ defineTool({
|
|
|
2526
3080
|
'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
|
|
2527
3081
|
].join("\n"),
|
|
2528
3082
|
input: {
|
|
2529
|
-
service_id:
|
|
3083
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
|
|
2530
3084
|
},
|
|
2531
3085
|
handler: async (args2, ctx) => {
|
|
2532
3086
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2563,10 +3117,10 @@ defineTool({
|
|
|
2563
3117
|
'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
3118
|
].join("\n"),
|
|
2565
3119
|
input: {
|
|
2566
|
-
service_id:
|
|
2567
|
-
resource_type:
|
|
2568
|
-
resource_id:
|
|
2569
|
-
alias:
|
|
3120
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3121
|
+
resource_type: z16.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
|
|
3122
|
+
resource_id: z16.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
|
|
3123
|
+
alias: z16.string().min(1).max(48).regex(
|
|
2570
3124
|
/^[A-Z][A-Z0-9_]*$/,
|
|
2571
3125
|
"Alias must be uppercase letters, digits and underscores, starting with a letter."
|
|
2572
3126
|
).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
|
|
@@ -2602,8 +3156,8 @@ defineTool({
|
|
|
2602
3156
|
'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
|
|
2603
3157
|
].join("\n"),
|
|
2604
3158
|
input: {
|
|
2605
|
-
service_id:
|
|
2606
|
-
link_id:
|
|
3159
|
+
service_id: z16.union([z16.number().int().positive(), z16.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
3160
|
+
link_id: z16.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
|
|
2607
3161
|
},
|
|
2608
3162
|
handler: async (args2, ctx) => {
|
|
2609
3163
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2615,9 +3169,217 @@ defineTool({
|
|
|
2615
3169
|
});
|
|
2616
3170
|
|
|
2617
3171
|
// src/tools/services.ts
|
|
2618
|
-
import { z as
|
|
3172
|
+
import { z as z17 } from "zod";
|
|
3173
|
+
|
|
3174
|
+
// src/lib/app-templates.ts
|
|
3175
|
+
var MCP_APP_TEMPLATES = [
|
|
3176
|
+
{
|
|
3177
|
+
id: "node-express",
|
|
3178
|
+
name: "Node.js Express",
|
|
3179
|
+
description: "HTTP server with Express.js",
|
|
3180
|
+
type: "web_service",
|
|
3181
|
+
runtime: "node",
|
|
3182
|
+
installCommand: "npm install",
|
|
3183
|
+
startCommand: "node index.js"
|
|
3184
|
+
},
|
|
3185
|
+
{
|
|
3186
|
+
id: "python-fastapi",
|
|
3187
|
+
name: "Python FastAPI",
|
|
3188
|
+
description: "Modern Python API framework",
|
|
3189
|
+
type: "web_service",
|
|
3190
|
+
runtime: "python",
|
|
3191
|
+
installCommand: "pip install -r requirements.txt",
|
|
3192
|
+
startCommand: "uvicorn main:app --host 0.0.0.0 --port $PORT"
|
|
3193
|
+
},
|
|
3194
|
+
{
|
|
3195
|
+
id: "go-api",
|
|
3196
|
+
name: "Go HTTP Server",
|
|
3197
|
+
description: "Lightweight Go web service",
|
|
3198
|
+
type: "web_service",
|
|
3199
|
+
runtime: "go",
|
|
3200
|
+
installCommand: "go mod download",
|
|
3201
|
+
buildCommand: "go build -o server .",
|
|
3202
|
+
startCommand: "./server"
|
|
3203
|
+
},
|
|
3204
|
+
{
|
|
3205
|
+
id: "bun-hono",
|
|
3206
|
+
name: "Bun + Hono",
|
|
3207
|
+
description: "Fast TypeScript API with Bun runtime",
|
|
3208
|
+
type: "web_service",
|
|
3209
|
+
runtime: "bun",
|
|
3210
|
+
installCommand: "bun install",
|
|
3211
|
+
startCommand: "bun run src/index.ts"
|
|
3212
|
+
},
|
|
3213
|
+
{
|
|
3214
|
+
id: "nextjs-ssr",
|
|
3215
|
+
name: "Next.js",
|
|
3216
|
+
description: "Server-rendered Next.js started with next start",
|
|
3217
|
+
type: "web_service",
|
|
3218
|
+
runtime: "node",
|
|
3219
|
+
installCommand: "npm install",
|
|
3220
|
+
buildCommand: "npm run build",
|
|
3221
|
+
startCommand: "npm run start"
|
|
3222
|
+
},
|
|
3223
|
+
{
|
|
3224
|
+
id: "react-router-ssr",
|
|
3225
|
+
name: "React Router / Remix",
|
|
3226
|
+
description: "Server build served by react-router-serve or remix-serve",
|
|
3227
|
+
type: "web_service",
|
|
3228
|
+
runtime: "node",
|
|
3229
|
+
installCommand: "npm install",
|
|
3230
|
+
buildCommand: "npm run build",
|
|
3231
|
+
startCommand: "npm run start"
|
|
3232
|
+
},
|
|
3233
|
+
{
|
|
3234
|
+
id: "sveltekit-node",
|
|
3235
|
+
name: "SvelteKit",
|
|
3236
|
+
description: "SvelteKit server build produced by adapter-node",
|
|
3237
|
+
type: "web_service",
|
|
3238
|
+
runtime: "node",
|
|
3239
|
+
installCommand: "npm install",
|
|
3240
|
+
buildCommand: "npm run build",
|
|
3241
|
+
startCommand: "node build"
|
|
3242
|
+
},
|
|
3243
|
+
{
|
|
3244
|
+
id: "nuxt",
|
|
3245
|
+
name: "Nuxt",
|
|
3246
|
+
description: "Nuxt server build running on the Nitro output",
|
|
3247
|
+
type: "web_service",
|
|
3248
|
+
runtime: "node",
|
|
3249
|
+
installCommand: "npm install",
|
|
3250
|
+
buildCommand: "npm run build",
|
|
3251
|
+
startCommand: "node .output/server/index.mjs"
|
|
3252
|
+
},
|
|
3253
|
+
{
|
|
3254
|
+
id: "django",
|
|
3255
|
+
name: "Django",
|
|
3256
|
+
description: "Django project served by Gunicorn",
|
|
3257
|
+
type: "web_service",
|
|
3258
|
+
runtime: "python",
|
|
3259
|
+
installCommand: "pip install -r requirements.txt",
|
|
3260
|
+
buildCommand: "python manage.py collectstatic --noinput || true",
|
|
3261
|
+
startCommand: "gunicorn --bind 0.0.0.0:$PORT --workers 2 $(ls */wsgi.py | head -n1 | cut -d/ -f1).wsgi:application"
|
|
3262
|
+
},
|
|
3263
|
+
{
|
|
3264
|
+
id: "flask",
|
|
3265
|
+
name: "Flask",
|
|
3266
|
+
description: "Flask app served by Gunicorn",
|
|
3267
|
+
type: "web_service",
|
|
3268
|
+
runtime: "python",
|
|
3269
|
+
installCommand: "pip install -r requirements.txt",
|
|
3270
|
+
startCommand: "gunicorn --bind 0.0.0.0:$PORT --workers 2 app:app"
|
|
3271
|
+
},
|
|
3272
|
+
{
|
|
3273
|
+
id: "spring-boot",
|
|
3274
|
+
name: "Spring Boot",
|
|
3275
|
+
description: "Spring Boot fat JAR built with Maven or Gradle",
|
|
3276
|
+
type: "web_service",
|
|
3277
|
+
runtime: "java",
|
|
3278
|
+
// No install/build on purpose: the agent inspects the repo and picks
|
|
3279
|
+
// Maven vs Gradle with the matching toolchain image. Sending either one
|
|
3280
|
+
// here would break the other half of the split.
|
|
3281
|
+
startCommand: "java -jar /app/app.jar --server.port=$PORT"
|
|
3282
|
+
},
|
|
3283
|
+
{
|
|
3284
|
+
id: "static-react",
|
|
3285
|
+
name: "React SPA",
|
|
3286
|
+
description: "Single-page React application",
|
|
3287
|
+
type: "static_site",
|
|
3288
|
+
runtime: "node",
|
|
3289
|
+
installCommand: "npm install",
|
|
3290
|
+
buildCommand: "npm run build",
|
|
3291
|
+
publishPath: "./dist"
|
|
3292
|
+
},
|
|
3293
|
+
{
|
|
3294
|
+
id: "static-nextjs",
|
|
3295
|
+
name: "Next.js Static",
|
|
3296
|
+
description: "Next.js with static export",
|
|
3297
|
+
type: "static_site",
|
|
3298
|
+
runtime: "node",
|
|
3299
|
+
installCommand: "npm install",
|
|
3300
|
+
buildCommand: "npm run build",
|
|
3301
|
+
publishPath: "./out"
|
|
3302
|
+
},
|
|
3303
|
+
{
|
|
3304
|
+
id: "astro",
|
|
3305
|
+
name: "Astro",
|
|
3306
|
+
description: "Astro site built to Astro's default static output",
|
|
3307
|
+
type: "static_site",
|
|
3308
|
+
runtime: "node",
|
|
3309
|
+
installCommand: "npm install",
|
|
3310
|
+
buildCommand: "npm run build",
|
|
3311
|
+
publishPath: "./dist"
|
|
3312
|
+
},
|
|
3313
|
+
{
|
|
3314
|
+
id: "worker-bullmq",
|
|
3315
|
+
name: "BullMQ Worker",
|
|
3316
|
+
description: "Background job processor",
|
|
3317
|
+
type: "worker",
|
|
3318
|
+
runtime: "node",
|
|
3319
|
+
installCommand: "npm install",
|
|
3320
|
+
startCommand: "node worker.js"
|
|
3321
|
+
},
|
|
3322
|
+
{
|
|
3323
|
+
id: "uptime-kuma",
|
|
3324
|
+
name: "Uptime Kuma",
|
|
3325
|
+
description: "Self-hosted uptime monitoring and status pages, with its own SQLite store",
|
|
3326
|
+
type: "web_service",
|
|
3327
|
+
dockerImage: "louislam/uptime-kuma:1",
|
|
3328
|
+
port: 3e3
|
|
3329
|
+
},
|
|
3330
|
+
{
|
|
3331
|
+
id: "vaultwarden",
|
|
3332
|
+
name: "Vaultwarden",
|
|
3333
|
+
description: "Self-hosted Bitwarden-compatible password manager (unofficial server)",
|
|
3334
|
+
type: "web_service",
|
|
3335
|
+
dockerImage: "vaultwarden/server:1.32.7-alpine",
|
|
3336
|
+
port: 3e3
|
|
3337
|
+
},
|
|
3338
|
+
{
|
|
3339
|
+
id: "n8n",
|
|
3340
|
+
name: "n8n",
|
|
3341
|
+
description: "Self-hosted workflow automation \u2014 visual editor, 400+ integrations",
|
|
3342
|
+
type: "web_service",
|
|
3343
|
+
dockerImage: "n8nio/n8n:1",
|
|
3344
|
+
port: 5678
|
|
3345
|
+
},
|
|
3346
|
+
{
|
|
3347
|
+
id: "wordpress",
|
|
3348
|
+
name: "WordPress",
|
|
3349
|
+
description: "PHP 8.3 + Apache, with a managed MySQL database (billed separately)",
|
|
3350
|
+
type: "web_service",
|
|
3351
|
+
dockerImage: "wordpress:php8.3-apache",
|
|
3352
|
+
port: 80
|
|
3353
|
+
},
|
|
3354
|
+
{
|
|
3355
|
+
id: "ghost",
|
|
3356
|
+
name: "Ghost",
|
|
3357
|
+
description: "Blogs and newsletters, with a managed MySQL database (billed separately)",
|
|
3358
|
+
type: "web_service",
|
|
3359
|
+
dockerImage: "ghost:5-alpine",
|
|
3360
|
+
port: 2368
|
|
3361
|
+
},
|
|
3362
|
+
{
|
|
3363
|
+
id: "cron-cleanup",
|
|
3364
|
+
name: "Cleanup Cron",
|
|
3365
|
+
description: "Daily maintenance task",
|
|
3366
|
+
type: "cron_job",
|
|
3367
|
+
runtime: "node",
|
|
3368
|
+
installCommand: "npm install",
|
|
3369
|
+
startCommand: "node cleanup.js",
|
|
3370
|
+
cronSchedule: "0 3 * * *"
|
|
3371
|
+
}
|
|
3372
|
+
];
|
|
3373
|
+
|
|
3374
|
+
// src/tools/services.ts
|
|
2619
3375
|
var DEV_ENV_IMAGE = "registry.hoststack.dev/hoststack/dev-env:latest";
|
|
2620
3376
|
var DEV_ENV_VOLUME = { name: "workspace", mountPath: "/workspace", sizeGb: 10 };
|
|
3377
|
+
var DEV_BOX_INSIDE = [
|
|
3378
|
+
"INSIDE the box (no Docker daemon \u2014 do not try `docker run`, and runtime `apt-get` is blocked too; it is all preinstalled):",
|
|
3379
|
+
' - Databases/engines via `dev-services up`: Postgres 17 (:5432, user "dev", trust auth), Redis (:6379), Meilisearch (:7700). MariaDB/MySQL (:3306, user "dev", no password, client `mariadb`) and MongoDB (:27017, client `mongosh`) are OPT-IN \u2014 `dev-services up mysql` / `dev-services up mongodb` \u2014 so a box does not pay RAM for engines it never uses. `dev-services createdb <name>` makes a Postgres role + database in one step. Data lives on /workspace, so it survives restarts.',
|
|
3380
|
+
" - Language runtimes: node, bun, python and php 8.3 are built in. go, java, ruby, rust, elixir and dotnet install on demand with `dev-runtime add <name>` (persisted to /workspace). Together that covers every runtime the platform itself deploys \u2014 a WordPress/Laravel/Rails stack runs in the box. elixir needs `dev-runtime add erlang` first: it is a BEAM distribution with no OTP of its own, and Ubuntu ships one too old to use, so mise builds a current one (takes a few minutes).",
|
|
3381
|
+
" - `hoststack-status` prints what is running, the box dev URL and agent login state at any time."
|
|
3382
|
+
].join("\n");
|
|
2621
3383
|
var SERVICE_TYPES = [
|
|
2622
3384
|
"web_service",
|
|
2623
3385
|
"private_service",
|
|
@@ -2664,11 +3426,11 @@ defineTool({
|
|
|
2664
3426
|
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
2665
3427
|
].join("\n"),
|
|
2666
3428
|
input: {
|
|
2667
|
-
project_id:
|
|
2668
|
-
environment_id:
|
|
2669
|
-
status:
|
|
2670
|
-
type:
|
|
2671
|
-
dev_environment:
|
|
3429
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
3430
|
+
environment_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
3431
|
+
status: z17.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
3432
|
+
type: z17.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
|
|
3433
|
+
dev_environment: z17.boolean().optional().describe(
|
|
2672
3434
|
"Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
|
|
2673
3435
|
)
|
|
2674
3436
|
},
|
|
@@ -2713,6 +3475,8 @@ defineTool({
|
|
|
2713
3475
|
"",
|
|
2714
3476
|
"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).",
|
|
2715
3477
|
"",
|
|
3478
|
+
"*** For a packaged app (WordPress, Ghost, n8n, Uptime Kuma, Vaultwarden), start at list_templates and pass template_id. *** Those templates are prebuilt images that only boot with the volume, scratch dirs, uid and companion database the platform attaches from the template id \u2014 the same image passed as a bare docker_image crash-loops on the read-only rootfs, and the fix is a redeploy away rather than an edit away.",
|
|
3479
|
+
"",
|
|
2716
3480
|
'*** 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
3481
|
"",
|
|
2718
3482
|
"Inputs:",
|
|
@@ -2726,32 +3490,43 @@ defineTool({
|
|
|
2726
3490
|
" - cron_schedule (optional): cron expression \u2014 required for cron_job.",
|
|
2727
3491
|
' - publish_path (optional): static-site output dir (e.g. "dist").',
|
|
2728
3492
|
' - runtime (optional): "node" | "bun" | "python" | \u2026 (auto-detected from a repo when omitted).',
|
|
3493
|
+
" - port (optional): the port the container listens on (1\u201365535). A source-built service should omit it \u2014 the platform injects $PORT and expects the app to bind that. Set it for a docker_image whose listen port is fixed by the image (WordPress 80, Ghost 2368, n8n 5678): the platform publishes and health-checks this port and never re-reads what the process actually bound, so an image listening elsewhere fails its first deploy on a container that is perfectly healthy.",
|
|
3494
|
+
" - template_id (optional): create from a quickstart template \u2014 an id from list_templates and nothing else. Everything the template brings (volumes, scratch dirs, uid, generated secrets, companion managed database) is resolved server-side and attached before the first deploy fires; there is no way to send those in the body. For an image template, pass its docker_image and port alongside, exactly as list_templates returns them.",
|
|
2729
3495
|
' - plan (optional): service size (default "micro").',
|
|
2730
3496
|
" - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
|
|
2731
3497
|
" - auto_deploy (optional, default true): trigger the first deploy immediately when a source is present.",
|
|
3498
|
+
" - machine (optional): run it on one of the team's OWN enrolled machines (name or id, see list_machines) instead of HostStack compute. Pinned at creation and never moved afterwards, and the service is up only while that machine is.",
|
|
2732
3499
|
"",
|
|
2733
3500
|
"Returns: { service: Service, deployId: number | null }.",
|
|
2734
3501
|
"",
|
|
2735
|
-
'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
|
|
3502
|
+
'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }',
|
|
3503
|
+
'Example (one-click app): create_service({ project_id: "prj_abc", name: "blog", type: "web_service", template_id: "wordpress", docker_image: "wordpress:php8.3-apache", port: 80 }) \u2014 the wp-content volume, the www-data uid, the Apache scratch dirs and the managed MySQL come from the template id.'
|
|
2736
3504
|
].join("\n"),
|
|
2737
3505
|
input: {
|
|
2738
|
-
project_id:
|
|
2739
|
-
name:
|
|
2740
|
-
type:
|
|
2741
|
-
docker_image:
|
|
3506
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3507
|
+
name: z17.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
|
|
3508
|
+
type: z17.enum(SERVICE_TYPES).describe("Service type."),
|
|
3509
|
+
docker_image: z17.string().max(500).optional().describe(
|
|
2742
3510
|
"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
3511
|
),
|
|
2744
|
-
github_repo_id:
|
|
2745
|
-
branch:
|
|
2746
|
-
install_command:
|
|
2747
|
-
build_command:
|
|
2748
|
-
start_command:
|
|
2749
|
-
cron_schedule:
|
|
2750
|
-
publish_path:
|
|
2751
|
-
runtime:
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
3512
|
+
github_repo_id: z17.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
|
|
3513
|
+
branch: z17.string().max(200).optional().describe('Git branch (default "main").'),
|
|
3514
|
+
install_command: z17.string().max(1e3).optional().describe("Install shell command."),
|
|
3515
|
+
build_command: z17.string().max(1e3).optional().describe("Build shell command."),
|
|
3516
|
+
start_command: z17.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
|
|
3517
|
+
cron_schedule: z17.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
|
|
3518
|
+
publish_path: z17.string().max(500).optional().describe("Static-site output dir."),
|
|
3519
|
+
runtime: z17.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
|
|
3520
|
+
port: z17.number().int().min(1).max(65535).optional().describe(
|
|
3521
|
+
"Listen port, for a prebuilt image whose port is fixed by the image. Omit for a source-built service \u2014 it binds the injected $PORT."
|
|
3522
|
+
),
|
|
3523
|
+
template_id: z17.string().max(64).optional().describe(
|
|
3524
|
+
"Quickstart template id from list_templates. Its volumes, scratch dirs, uid, secrets and companion database are resolved server-side; for an image template also pass its docker_image and port."
|
|
3525
|
+
),
|
|
3526
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
|
|
3527
|
+
environment_id: z17.union([z17.number().int().positive(), z17.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
|
|
3528
|
+
auto_deploy: z17.boolean().optional().describe("Trigger the first deploy immediately (default true)."),
|
|
3529
|
+
machine: machineInput
|
|
2755
3530
|
},
|
|
2756
3531
|
handler: async (args2, ctx) => {
|
|
2757
3532
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2773,6 +3548,8 @@ defineTool({
|
|
|
2773
3548
|
if (args2.cron_schedule !== void 0) input.cronSchedule = args2.cron_schedule;
|
|
2774
3549
|
if (args2.publish_path !== void 0) input.publishPath = args2.publish_path;
|
|
2775
3550
|
if (args2.runtime !== void 0) input.runtime = args2.runtime;
|
|
3551
|
+
if (args2.port !== void 0) input.port = args2.port;
|
|
3552
|
+
if (args2.template_id !== void 0) input.templateId = args2.template_id;
|
|
2776
3553
|
if (args2.plan !== void 0) input.plan = args2.plan;
|
|
2777
3554
|
if (args2.auto_deploy !== void 0) input.autoDeploy = args2.auto_deploy;
|
|
2778
3555
|
if (args2.environment_id !== void 0) {
|
|
@@ -2781,6 +3558,9 @@ defineTool({
|
|
|
2781
3558
|
teamId
|
|
2782
3559
|
});
|
|
2783
3560
|
}
|
|
3561
|
+
if (args2.machine !== void 0) {
|
|
3562
|
+
input.machineId = await resolveMachineId(ctx, teamId, args2.machine);
|
|
3563
|
+
}
|
|
2784
3564
|
const response = await ctx.hoststack.services.create(teamId, input);
|
|
2785
3565
|
const data = {
|
|
2786
3566
|
service: shapeService(response.service),
|
|
@@ -2804,28 +3584,31 @@ defineTool({
|
|
|
2804
3584
|
' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
|
|
2805
3585
|
' - name (optional): service name (default "dev-environment").',
|
|
2806
3586
|
' - 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".',
|
|
2807
|
-
" - disk_gb (optional): /workspace volume size in GB (default 10,
|
|
3587
|
+
" - disk_gb (optional): /workspace volume size in GB (default 10, min 10, max 10240).",
|
|
2808
3588
|
" - hoststack_api_key (optional): sets HOSTSTACK_API_KEY so the hoststack MCP works inside the container.",
|
|
2809
3589
|
" - poststack_api_key (optional): sets POSTSTACK_API_KEY so the poststack MCP works inside the container.",
|
|
2810
3590
|
" - repo_url (optional): clone this git URL into /workspace on first boot (with optional branch).",
|
|
2811
3591
|
"",
|
|
3592
|
+
DEV_BOX_INSIDE,
|
|
3593
|
+
"",
|
|
2812
3594
|
"Returns: { service: Service, volumeAttached: boolean, deployId: number | null }. Open the Terminal tab on the service (dashboard or phone) once it is running; log in once with `claude /login` inside the container.",
|
|
2813
3595
|
"",
|
|
2814
3596
|
'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
|
|
2815
3597
|
].join("\n"),
|
|
2816
3598
|
input: {
|
|
2817
|
-
project_id:
|
|
2818
|
-
name:
|
|
2819
|
-
plan:
|
|
3599
|
+
project_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
3600
|
+
name: z17.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
|
|
3601
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe(
|
|
2820
3602
|
'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
|
|
2821
3603
|
),
|
|
2822
|
-
disk_gb:
|
|
2823
|
-
hoststack_api_key:
|
|
2824
|
-
poststack_api_key:
|
|
2825
|
-
repo_url:
|
|
3604
|
+
disk_gb: z17.number().int().min(10).max(10240).optional().describe("/workspace volume size in GB (default 10, min 10, max 10240)."),
|
|
3605
|
+
hoststack_api_key: z17.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
|
|
3606
|
+
poststack_api_key: z17.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
|
|
3607
|
+
repo_url: z17.string().max(500).optional().describe(
|
|
2826
3608
|
"Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
|
|
2827
3609
|
),
|
|
2828
|
-
branch:
|
|
3610
|
+
branch: z17.string().max(200).optional().describe("Branch to clone (with repo_url)."),
|
|
3611
|
+
machine: machineInput
|
|
2829
3612
|
},
|
|
2830
3613
|
handler: async (args2, ctx) => {
|
|
2831
3614
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2844,6 +3627,9 @@ defineTool({
|
|
|
2844
3627
|
autoDeploy: false,
|
|
2845
3628
|
plan
|
|
2846
3629
|
};
|
|
3630
|
+
if (args2.machine !== void 0) {
|
|
3631
|
+
createInput.machineId = await resolveMachineId(ctx, teamId, args2.machine);
|
|
3632
|
+
}
|
|
2847
3633
|
const created = await ctx.hoststack.services.create(teamId, createInput);
|
|
2848
3634
|
const service = created.service;
|
|
2849
3635
|
const rollback = async () => {
|
|
@@ -2931,14 +3717,16 @@ defineTool({
|
|
|
2931
3717
|
" - include_database_clone (optional): clone the linked database so the app runs on a copy of real data (default true).",
|
|
2932
3718
|
' - name (optional): dev box name (default "<source>-dev").',
|
|
2933
3719
|
"",
|
|
3720
|
+
DEV_BOX_INSIDE,
|
|
3721
|
+
"",
|
|
2934
3722
|
"Returns: { service, devUrl, deployId }. Once it is live: open the Terminal tab, run `claude`, start the dev server (it must bind $PORT), and view the app at https://<devUrl>. Tear it all down later with delete_dev_environment.",
|
|
2935
3723
|
"",
|
|
2936
3724
|
'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.'
|
|
2937
3725
|
].join("\n"),
|
|
2938
3726
|
input: {
|
|
2939
|
-
service_id:
|
|
2940
|
-
include_database_clone:
|
|
2941
|
-
name:
|
|
3727
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
|
|
3728
|
+
include_database_clone: z17.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
|
|
3729
|
+
name: z17.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
|
|
2942
3730
|
},
|
|
2943
3731
|
handler: async (args2, ctx) => {
|
|
2944
3732
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2979,7 +3767,7 @@ defineTool({
|
|
|
2979
3767
|
'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
|
|
2980
3768
|
].join("\n"),
|
|
2981
3769
|
input: {
|
|
2982
|
-
service_id:
|
|
3770
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
|
|
2983
3771
|
},
|
|
2984
3772
|
handler: async (args2, ctx) => {
|
|
2985
3773
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3015,8 +3803,8 @@ defineTool({
|
|
|
3015
3803
|
'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
|
|
3016
3804
|
].join("\n"),
|
|
3017
3805
|
input: {
|
|
3018
|
-
service_id:
|
|
3019
|
-
size:
|
|
3806
|
+
service_id: z17.union([z17.number().int().positive(), z17.string()]).describe("The box to resize \u2014 numeric id or publicId."),
|
|
3807
|
+
size: z17.enum(SERVICE_PLANS).describe(
|
|
3020
3808
|
'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
|
|
3021
3809
|
)
|
|
3022
3810
|
},
|
|
@@ -3070,35 +3858,62 @@ defineTool({
|
|
|
3070
3858
|
name: "list_templates",
|
|
3071
3859
|
category: "services",
|
|
3072
3860
|
description: [
|
|
3073
|
-
"List the
|
|
3861
|
+
"List the app quickstart templates the dashboard New-Service wizard offers, plus the separate Dev Box preset. Two kinds live in one list: a stack template (Next.js, Django, SvelteKit, Spring Boot, \u2026) is a pre-filled install/build/start command set you pair with a Git repo, and a one-click app (WordPress, Ghost, n8n, Uptime Kuma, Vaultwarden) is a prebuilt image that needs no repo at all.",
|
|
3074
3862
|
"",
|
|
3075
|
-
|
|
3863
|
+
'When to use: whenever asked to deploy a known stack ("deploy my Next.js app", "put this Django project on HostStack"), before hand-writing a start command; whenever asked for a packaged app ("deploy WordPress", "I want a Ghost blog"), before reaching for a bare docker_image; and before creating a Dev Box, to confirm the image, /workspace size, plan floor, and companion engines instead of duplicating those constants.',
|
|
3076
3864
|
"",
|
|
3077
|
-
|
|
3865
|
+
"Returns: { templates: [{ id, name, description, type, runtime?, installCommand?, buildCommand?, startCommand?, publishPath?, cronSchedule?, dockerImage?, port?, requiresTemplateId? }], devBox: { id, name, image, volume: { name, mountPath, sizeGb }, minPlan, createWith, companions, inBox } }. A template WITHOUT `dockerImage` is source-built \u2014 pass its commands to create_service with a `github_repo_id`. A template WITH `dockerImage` is flagged `requiresTemplateId` and must be created as create_service({ template_id, docker_image, port, \u2026 }): the volume, scratch dirs, uid, generated secrets and companion managed database it needs are attached server-side from the id, and the same image sent without it crash-loops on the read-only rootfs. The Dev Box is NOT in `templates`: it is created with create_standalone_dev_environment / create_dev_environment, never create_service. `companions` are SEPARATE managed databases you can attach; `inBox` is what the box already runs on its own (engines via `dev-services`, language runtimes via `dev-runtime`) \u2014 check it before concluding a box cannot run something.",
|
|
3078
3866
|
"",
|
|
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"] }
|
|
3867
|
+
'Example: list_templates() \u2192 { templates: [{ id: "nextjs-ssr", name: "Next.js", type: "web_service", runtime: "node", installCommand: "npm install", buildCommand: "npm run build", startCommand: "npm run start" }, { id: "wordpress", name: "WordPress", type: "web_service", dockerImage: "wordpress:php8.3-apache", port: 80, requiresTemplateId: true }, \u2026], devBox: { id: "dev-environment", image: "registry.hoststack.dev/hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] } }'
|
|
3080
3868
|
].join("\n"),
|
|
3081
3869
|
input: {},
|
|
3082
3870
|
handler: async () => {
|
|
3083
|
-
const
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3871
|
+
const devBox = {
|
|
3872
|
+
id: "dev-environment",
|
|
3873
|
+
name: "AI Dev Environment",
|
|
3874
|
+
image: DEV_ENV_IMAGE,
|
|
3875
|
+
volume: {
|
|
3876
|
+
name: DEV_ENV_VOLUME.name,
|
|
3877
|
+
mountPath: DEV_ENV_VOLUME.mountPath,
|
|
3878
|
+
sizeGb: DEV_ENV_VOLUME.sizeGb
|
|
3879
|
+
},
|
|
3880
|
+
/** OOM-safe plan floor — a coding agent + build needs at least this. */
|
|
3881
|
+
minPlan: DEV_ENV_MIN_SIZE,
|
|
3882
|
+
/** Create it with these, never create_service. */
|
|
3883
|
+
createWith: ["create_standalone_dev_environment", "create_dev_environment"],
|
|
3884
|
+
/** Companion engines you can attach (fresh + empty), like local `make db-up`. */
|
|
3885
|
+
companions: ["postgres", "redis", "meilisearch"],
|
|
3886
|
+
/**
|
|
3887
|
+
* What the box can already run without attaching anything. Reported
|
|
3888
|
+
* so an agent picking a template can see that "it needs MySQL" or
|
|
3889
|
+
* "it's a PHP app" is already satisfied, instead of concluding the
|
|
3890
|
+
* box is unsuitable and reaching for Docker (there is no daemon).
|
|
3891
|
+
*/
|
|
3892
|
+
inBox: {
|
|
3893
|
+
engines: {
|
|
3894
|
+
default: ["postgres", "redis", "meilisearch"],
|
|
3895
|
+
optIn: ["mysql", "mongodb"],
|
|
3896
|
+
start: "dev-services up [mysql|mongodb]"
|
|
3092
3897
|
},
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3898
|
+
runtimes: {
|
|
3899
|
+
builtin: ["node", "bun", "python", "php"],
|
|
3900
|
+
// erlang is listed because elixir cannot run without it —
|
|
3901
|
+
// see DEV_BOX_INSIDE. Ordering matters for a caller acting
|
|
3902
|
+
// on this: add erlang, then elixir.
|
|
3903
|
+
onDemand: ["go", "java", "ruby", "rust", "erlang", "elixir", "dotnet"],
|
|
3904
|
+
install: "dev-runtime add <name>"
|
|
3905
|
+
}
|
|
3097
3906
|
}
|
|
3098
|
-
|
|
3907
|
+
};
|
|
3908
|
+
const templates = MCP_APP_TEMPLATES.map(
|
|
3909
|
+
(t) => t.dockerImage ? { ...t, requiresTemplateId: true } : t
|
|
3910
|
+
);
|
|
3911
|
+
const byType = /* @__PURE__ */ new Map();
|
|
3912
|
+
for (const t of templates) byType.set(t.type, (byType.get(t.type) ?? 0) + 1);
|
|
3913
|
+
const breakdown = [...byType].map(([type, n]) => `${n} ${type}`).join(", ");
|
|
3099
3914
|
return respond({
|
|
3100
|
-
summary: `${templates.length}
|
|
3101
|
-
data: { templates }
|
|
3915
|
+
summary: `${templates.length} app templates (${breakdown}) \u2014 pick one by id, then either pass its commands to create_service with a github_repo_id, or, if it has a dockerImage, pass template_id + docker_image + port. Plus the Dev Box preset (image ${DEV_ENV_IMAGE}, /workspace ${DEV_ENV_VOLUME.sizeGb} GB, floor "${DEV_ENV_MIN_SIZE}"), which is created with create_standalone_dev_environment, not create_service.`,
|
|
3916
|
+
data: { templates, devBox }
|
|
3102
3917
|
});
|
|
3103
3918
|
}
|
|
3104
3919
|
});
|
|
@@ -3110,7 +3925,9 @@ defineTool({
|
|
|
3110
3925
|
"",
|
|
3111
3926
|
"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
3927
|
"",
|
|
3113
|
-
|
|
3928
|
+
DEV_BOX_INSIDE,
|
|
3929
|
+
"",
|
|
3930
|
+
"Those in-box engines are the no-Docker replacement for a local `make db-up`, and are the right answer for development inside the box. The `companions` option is a DIFFERENT thing: it attaches SEPARATE managed databases to the box's environment, for when the box needs a real, persistent, backed-up datastore rather than a scratch one.",
|
|
3114
3931
|
"",
|
|
3115
3932
|
'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).',
|
|
3116
3933
|
"",
|
|
@@ -3121,6 +3938,7 @@ defineTool({
|
|
|
3121
3938
|
" - clone_url (for url): an http(s) git clone URL.",
|
|
3122
3939
|
" - branch (optional): branch to clone.",
|
|
3123
3940
|
' - databases (optional): companion services to attach \u2014 any of "postgres", "redis", "meilisearch".',
|
|
3941
|
+
" - machine (optional): run the box on one of the team's OWN enrolled machines (name or id, see list_machines) instead of HostStack compute. /workspace then lives on that machine's disk, so the box is reachable only while it is switched on, and any companion databases are created there too.",
|
|
3124
3942
|
' - 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.',
|
|
3125
3943
|
" - 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.",
|
|
3126
3944
|
"",
|
|
@@ -3129,25 +3947,26 @@ defineTool({
|
|
|
3129
3947
|
'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
|
|
3130
3948
|
].join("\n"),
|
|
3131
3949
|
input: {
|
|
3132
|
-
name:
|
|
3950
|
+
name: z17.string().min(1).max(100).optional().describe(
|
|
3133
3951
|
'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
3952
|
),
|
|
3135
|
-
source_kind:
|
|
3136
|
-
github_repo_id:
|
|
3137
|
-
clone_url:
|
|
3138
|
-
branch:
|
|
3139
|
-
databases:
|
|
3140
|
-
plan:
|
|
3953
|
+
source_kind: z17.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
|
|
3954
|
+
github_repo_id: z17.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
|
|
3955
|
+
clone_url: z17.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
|
|
3956
|
+
branch: z17.string().min(1).max(255).optional().describe("Branch to clone."),
|
|
3957
|
+
databases: z17.array(z17.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
|
|
3958
|
+
plan: z17.enum(SERVICE_PLANS).optional().describe(
|
|
3141
3959
|
'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
|
|
3142
3960
|
),
|
|
3143
|
-
agent_accounts:
|
|
3144
|
-
|
|
3145
|
-
provider:
|
|
3146
|
-
account_id:
|
|
3961
|
+
agent_accounts: z17.array(
|
|
3962
|
+
z17.object({
|
|
3963
|
+
provider: z17.enum(["claude", "codex", "opencode"]),
|
|
3964
|
+
account_id: z17.number().int().positive()
|
|
3147
3965
|
})
|
|
3148
3966
|
).max(3).optional().describe(
|
|
3149
3967
|
"Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
|
|
3150
|
-
)
|
|
3968
|
+
),
|
|
3969
|
+
machine: machineInput
|
|
3151
3970
|
},
|
|
3152
3971
|
handler: async (args2, ctx) => {
|
|
3153
3972
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3179,9 +3998,11 @@ defineTool({
|
|
|
3179
3998
|
} else {
|
|
3180
3999
|
source = { kind: "blank" };
|
|
3181
4000
|
}
|
|
4001
|
+
const machineId = args2.machine !== void 0 ? await resolveMachineId(ctx, teamId, args2.machine) : void 0;
|
|
3182
4002
|
const input = {
|
|
3183
4003
|
...args2.name ? { name: args2.name } : {},
|
|
3184
4004
|
source,
|
|
4005
|
+
...machineId !== void 0 ? { machineId } : {},
|
|
3185
4006
|
...args2.databases ? { databases: args2.databases } : {},
|
|
3186
4007
|
...args2.plan ? { plan: args2.plan } : {},
|
|
3187
4008
|
...args2.agent_accounts && args2.agent_accounts.length > 0 ? {
|
|
@@ -3218,7 +4039,7 @@ defineTool({
|
|
|
3218
4039
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
|
|
3219
4040
|
].join("\n"),
|
|
3220
4041
|
input: {
|
|
3221
|
-
service_id:
|
|
4042
|
+
service_id: z17.string().describe("Service publicId (e.g. svc_abc123).")
|
|
3222
4043
|
},
|
|
3223
4044
|
handler: async (args2, ctx) => {
|
|
3224
4045
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3250,7 +4071,7 @@ defineTool({
|
|
|
3250
4071
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
3251
4072
|
].join("\n"),
|
|
3252
4073
|
input: {
|
|
3253
|
-
service_id:
|
|
4074
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3254
4075
|
},
|
|
3255
4076
|
handler: async (args2, ctx) => {
|
|
3256
4077
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3279,9 +4100,9 @@ defineTool({
|
|
|
3279
4100
|
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
|
|
3280
4101
|
].join("\n"),
|
|
3281
4102
|
input: {
|
|
3282
|
-
service_id:
|
|
3283
|
-
from:
|
|
3284
|
-
to:
|
|
4103
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4104
|
+
from: z17.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
4105
|
+
to: z17.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
3285
4106
|
},
|
|
3286
4107
|
handler: async (args2, ctx) => {
|
|
3287
4108
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3325,8 +4146,8 @@ defineTool({
|
|
|
3325
4146
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
3326
4147
|
].join("\n"),
|
|
3327
4148
|
input: {
|
|
3328
|
-
service_id:
|
|
3329
|
-
name:
|
|
4149
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4150
|
+
name: z17.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
3330
4151
|
},
|
|
3331
4152
|
handler: async (args2, ctx) => {
|
|
3332
4153
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3375,40 +4196,40 @@ defineTool({
|
|
|
3375
4196
|
'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
|
|
3376
4197
|
].join("\n"),
|
|
3377
4198
|
input: {
|
|
3378
|
-
service_id:
|
|
3379
|
-
install_command:
|
|
3380
|
-
build_command:
|
|
3381
|
-
start_command:
|
|
3382
|
-
branch:
|
|
3383
|
-
root_directory:
|
|
3384
|
-
dockerfile_path:
|
|
3385
|
-
auto_deploy:
|
|
3386
|
-
health_check_path:
|
|
3387
|
-
health_check_enabled:
|
|
3388
|
-
health_check_interval:
|
|
3389
|
-
health_check_timeout:
|
|
3390
|
-
health_check_grace_period_sec:
|
|
4199
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4200
|
+
install_command: z17.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
4201
|
+
build_command: z17.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
4202
|
+
start_command: z17.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
4203
|
+
branch: z17.string().optional().describe("Git branch to track."),
|
|
4204
|
+
root_directory: z17.string().optional().describe("Build context root."),
|
|
4205
|
+
dockerfile_path: z17.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
4206
|
+
auto_deploy: z17.boolean().optional().describe("Auto-deploy on push."),
|
|
4207
|
+
health_check_path: z17.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
|
|
4208
|
+
health_check_enabled: z17.boolean().optional().describe("Toggle health checking on/off."),
|
|
4209
|
+
health_check_interval: z17.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
|
|
4210
|
+
health_check_timeout: z17.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
|
|
4211
|
+
health_check_grace_period_sec: z17.number().int().min(1).max(1800).optional().describe(
|
|
3391
4212
|
"Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
|
|
3392
4213
|
),
|
|
3393
|
-
memory_mb:
|
|
3394
|
-
cpu_shares:
|
|
3395
|
-
disk_size_gb:
|
|
3396
|
-
port:
|
|
3397
|
-
protocol:
|
|
3398
|
-
restart_policy:
|
|
3399
|
-
deploy_strategy:
|
|
4214
|
+
memory_mb: z17.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
|
|
4215
|
+
cpu_shares: z17.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
|
|
4216
|
+
disk_size_gb: z17.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
|
|
4217
|
+
port: z17.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
|
|
4218
|
+
protocol: z17.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
|
|
4219
|
+
restart_policy: z17.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
|
|
4220
|
+
deploy_strategy: z17.enum(["rolling", "recreate"]).optional().describe(
|
|
3400
4221
|
'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
4222
|
),
|
|
3402
|
-
pre_deploy_command:
|
|
3403
|
-
instance_count:
|
|
3404
|
-
min_instances:
|
|
3405
|
-
max_instances:
|
|
3406
|
-
scale_cpu_threshold:
|
|
3407
|
-
scale_memory_threshold:
|
|
3408
|
-
log_filter_rules:
|
|
3409
|
-
|
|
3410
|
-
pattern:
|
|
3411
|
-
action:
|
|
4223
|
+
pre_deploy_command: z17.string().optional().describe("Shell command run before the new release accepts traffic."),
|
|
4224
|
+
instance_count: z17.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
4225
|
+
min_instances: z17.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
|
|
4226
|
+
max_instances: z17.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
|
|
4227
|
+
scale_cpu_threshold: z17.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
|
|
4228
|
+
scale_memory_threshold: z17.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
|
|
4229
|
+
log_filter_rules: z17.array(
|
|
4230
|
+
z17.object({
|
|
4231
|
+
pattern: z17.string().min(1).max(200),
|
|
4232
|
+
action: z17.enum(["drop", "downgrade"])
|
|
3412
4233
|
})
|
|
3413
4234
|
).max(50).optional().describe(
|
|
3414
4235
|
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
@@ -3505,7 +4326,7 @@ defineTool({
|
|
|
3505
4326
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3506
4327
|
].join("\n"),
|
|
3507
4328
|
input: {
|
|
3508
|
-
service_id:
|
|
4329
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3509
4330
|
},
|
|
3510
4331
|
handler: async (args2, ctx) => {
|
|
3511
4332
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3529,7 +4350,7 @@ defineTool({
|
|
|
3529
4350
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3530
4351
|
].join("\n"),
|
|
3531
4352
|
input: {
|
|
3532
|
-
service_id:
|
|
4353
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3533
4354
|
},
|
|
3534
4355
|
handler: async (args2, ctx) => {
|
|
3535
4356
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3555,7 +4376,7 @@ defineTool({
|
|
|
3555
4376
|
'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
|
|
3556
4377
|
].join("\n"),
|
|
3557
4378
|
input: {
|
|
3558
|
-
service_id:
|
|
4379
|
+
service_id: z17.string().describe("Service publicId.")
|
|
3559
4380
|
},
|
|
3560
4381
|
handler: async (args2, ctx) => {
|
|
3561
4382
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3592,16 +4413,16 @@ defineTool({
|
|
|
3592
4413
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
3593
4414
|
].join("\n"),
|
|
3594
4415
|
input: {
|
|
3595
|
-
service_id:
|
|
3596
|
-
lines:
|
|
3597
|
-
since:
|
|
3598
|
-
until:
|
|
3599
|
-
stream:
|
|
3600
|
-
level:
|
|
4416
|
+
service_id: z17.string().describe("Service publicId."),
|
|
4417
|
+
lines: z17.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
4418
|
+
since: z17.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4419
|
+
until: z17.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4420
|
+
stream: z17.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4421
|
+
level: z17.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
3601
4422
|
"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)."
|
|
3602
4423
|
),
|
|
3603
|
-
search:
|
|
3604
|
-
count_only:
|
|
4424
|
+
search: z17.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4425
|
+
count_only: z17.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
3605
4426
|
},
|
|
3606
4427
|
handler: async (args2, ctx) => {
|
|
3607
4428
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3650,14 +4471,14 @@ defineTool({
|
|
|
3650
4471
|
'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 } } }.'
|
|
3651
4472
|
].join("\n"),
|
|
3652
4473
|
input: {
|
|
3653
|
-
service_ids:
|
|
3654
|
-
lines_per_service:
|
|
3655
|
-
since:
|
|
3656
|
-
until:
|
|
3657
|
-
stream:
|
|
3658
|
-
level:
|
|
3659
|
-
search:
|
|
3660
|
-
count_only:
|
|
4474
|
+
service_ids: z17.array(z17.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
4475
|
+
lines_per_service: z17.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
4476
|
+
since: z17.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
4477
|
+
until: z17.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
4478
|
+
stream: z17.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
4479
|
+
level: z17.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
4480
|
+
search: z17.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
4481
|
+
count_only: z17.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
3661
4482
|
},
|
|
3662
4483
|
handler: async (args2, ctx) => {
|
|
3663
4484
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3702,8 +4523,117 @@ defineTool({
|
|
|
3702
4523
|
}
|
|
3703
4524
|
});
|
|
3704
4525
|
|
|
4526
|
+
// src/tools/uptime.ts
|
|
4527
|
+
import { z as z18 } from "zod";
|
|
4528
|
+
var STATUS_MEANING = [
|
|
4529
|
+
"Status vocabulary: `up` answering as expected \xB7 `down` failed the threshold and an alert is open \xB7 `unknown` not probed yet \xB7 `unresolvable` the service has no active domain to request \xB7 `paused` the service is not meant to be answering (suspended, mid-deploy, never deployed) OR it sleeps when idle and probing it would keep it awake."
|
|
4530
|
+
].join("\n");
|
|
4531
|
+
defineTool({
|
|
4532
|
+
name: "get_uptime_check",
|
|
4533
|
+
category: "uptime",
|
|
4534
|
+
description: [
|
|
4535
|
+
"Read a service's uptime check \u2014 HostStack requesting the service's public URL on a schedule and alerting when it stops answering.",
|
|
4536
|
+
"",
|
|
4537
|
+
'When to use: "is this service actually reachable", or before changing a check to see what it currently does. This is NOT the deploy-time health check: that one watches the container from inside the host and stops mattering once a deploy is live. This one runs from the control plane against the public URL, so it also catches DNS, TLS, edge and routing failures \u2014 and a service that accepts the connection and then answers nothing.',
|
|
4538
|
+
"",
|
|
4539
|
+
STATUS_MEANING,
|
|
4540
|
+
"",
|
|
4541
|
+
"Inputs: serviceId (required).",
|
|
4542
|
+
"",
|
|
4543
|
+
'Returns: { check } or { check: null } when none is configured. The check carries path, method, expectedStatus, intervalSeconds, failureThreshold, status, consecutiveFailures, lastCheckedAt, lastStatusCode, lastLatencyMs, lastError, lastChangedAt (the "down since" timestamp).',
|
|
4544
|
+
"",
|
|
4545
|
+
"Example: get_uptime_check({ serviceId: 48 }) \u2192 { check: { path: '/healthz', status: 'down', consecutiveFailures: 5, lastError: 'No response within 10000ms', lastChangedAt: '2026-08-21T04:12:00Z' } }."
|
|
4546
|
+
].join("\n"),
|
|
4547
|
+
input: { serviceId: z18.number().int().positive() },
|
|
4548
|
+
handler: async (args2, ctx) => {
|
|
4549
|
+
const teamId = await ctx.resolveTeamId();
|
|
4550
|
+
const response = await ctx.api.get(
|
|
4551
|
+
`/api/services/${teamId}/${args2.serviceId}/uptime-check`
|
|
4552
|
+
);
|
|
4553
|
+
if (response.check === null) {
|
|
4554
|
+
return respond({
|
|
4555
|
+
summary: "No uptime check on this service \u2014 nothing is watching its public URL.",
|
|
4556
|
+
data: { check: null }
|
|
4557
|
+
});
|
|
4558
|
+
}
|
|
4559
|
+
const check = shape(response.check);
|
|
4560
|
+
return respond({
|
|
4561
|
+
summary: `Uptime check is ${String(check["status"])}${check["lastError"] ? ` \u2014 ${String(check["lastError"])}` : ""}.`,
|
|
4562
|
+
data: { check }
|
|
4563
|
+
});
|
|
4564
|
+
}
|
|
4565
|
+
});
|
|
4566
|
+
defineTool({
|
|
4567
|
+
name: "set_uptime_check",
|
|
4568
|
+
category: "uptime",
|
|
4569
|
+
description: [
|
|
4570
|
+
"Create or update a service's uptime check.",
|
|
4571
|
+
"",
|
|
4572
|
+
"When to use: turning monitoring on for a service that has just gone live, or adjusting a check that is too noisy (raise failureThreshold) or too slow to notice (lower intervalSeconds).",
|
|
4573
|
+
"",
|
|
4574
|
+
'Only service types with a public URL can be checked (web services and static sites). A worker, cron job or private service is refused with 400 \u2014 a check on one could only ever report "no domain to check", which reads as a broken feature rather than an inapplicable one.',
|
|
4575
|
+
"",
|
|
4576
|
+
"IMPORTANT: changing the shape of a check RESETS its accumulated state (status, consecutive failures, open alert). A check whose path or expected status just changed has not observed the new check failing, so carrying failures forward would alert about a condition that was never measured. Pass the full shape you want, not a partial edit of an unknown current state \u2014 read it with get_uptime_check first if that matters.",
|
|
4577
|
+
"",
|
|
4578
|
+
"`path` is a path, not a URL: the host comes from the service's primary domain at probe time, so moving the service to a new domain moves the check with it.",
|
|
4579
|
+
"",
|
|
4580
|
+
"Method is GET or HEAD only. A probe fires unattended every interval forever, so it has to be safe to repeat \u2014 a check that could POST would be a scheduled writer against the team's own API.",
|
|
4581
|
+
"",
|
|
4582
|
+
"Redirects are NOT followed: a 301 is an answer, and following one can walk the probe onto a marketing site and report a dead service as healthy. If a redirect is expected, set expectedStatus to it.",
|
|
4583
|
+
"",
|
|
4584
|
+
'Inputs (all optional except serviceId): enabled, path (default "/"), method (GET|HEAD), expectedStatus (default 200), timeoutMs (1000\u201360000, default 10000 \u2014 this is what catches a hung server), intervalSeconds (30\u20133600, default 60), failureThreshold (1\u201310, default 3 \u2014 one failed request is usually a restart, not an outage).',
|
|
4585
|
+
"",
|
|
4586
|
+
"Returns: { check } with the saved check.",
|
|
4587
|
+
"",
|
|
4588
|
+
"Example: set_uptime_check({ serviceId: 48, path: '/healthz', intervalSeconds: 60, failureThreshold: 3 }) \u2192 { check: { status: 'unknown', \u2026 } }."
|
|
4589
|
+
].join("\n"),
|
|
4590
|
+
input: {
|
|
4591
|
+
serviceId: z18.number().int().positive(),
|
|
4592
|
+
enabled: z18.boolean().optional(),
|
|
4593
|
+
path: z18.string().max(500).optional().describe('Must start with /. Default "/".'),
|
|
4594
|
+
method: z18.enum(["GET", "HEAD"]).optional(),
|
|
4595
|
+
expectedStatus: z18.number().int().min(100).max(599).optional(),
|
|
4596
|
+
timeoutMs: z18.number().int().min(1e3).max(6e4).optional(),
|
|
4597
|
+
intervalSeconds: z18.number().int().min(30).max(3600).optional(),
|
|
4598
|
+
failureThreshold: z18.number().int().min(1).max(10).optional()
|
|
4599
|
+
},
|
|
4600
|
+
handler: async (args2, ctx) => {
|
|
4601
|
+
const teamId = await ctx.resolveTeamId();
|
|
4602
|
+
const { serviceId, ...body } = args2;
|
|
4603
|
+
const response = await ctx.api.put(
|
|
4604
|
+
`/api/services/${teamId}/${serviceId}/uptime-check`,
|
|
4605
|
+
body
|
|
4606
|
+
);
|
|
4607
|
+
return respond({
|
|
4608
|
+
summary: `Uptime check saved. It will start reporting within a minute or two.`,
|
|
4609
|
+
data: { check: shape(response.check) }
|
|
4610
|
+
});
|
|
4611
|
+
}
|
|
4612
|
+
});
|
|
4613
|
+
defineTool({
|
|
4614
|
+
name: "delete_uptime_check",
|
|
4615
|
+
category: "uptime",
|
|
4616
|
+
description: [
|
|
4617
|
+
'Stop checking a service. Any open "not answering" alert is left as it stands rather than being silently resolved \u2014 removing the monitor is not evidence the service came back.',
|
|
4618
|
+
"",
|
|
4619
|
+
"When to use: the service is being retired, or monitoring has moved somewhere else. To pause a check without losing its configuration, call set_uptime_check with enabled=false instead.",
|
|
4620
|
+
"",
|
|
4621
|
+
"Inputs: serviceId (required).",
|
|
4622
|
+
"",
|
|
4623
|
+
"Returns: { success: true }.",
|
|
4624
|
+
"",
|
|
4625
|
+
"Example: delete_uptime_check({ serviceId: 48 }) \u2192 { success: true }."
|
|
4626
|
+
].join("\n"),
|
|
4627
|
+
input: { serviceId: z18.number().int().positive() },
|
|
4628
|
+
handler: async (args2, ctx) => {
|
|
4629
|
+
const teamId = await ctx.resolveTeamId();
|
|
4630
|
+
await ctx.api.delete(`/api/services/${teamId}/${args2.serviceId}/uptime-check`);
|
|
4631
|
+
return respond({ summary: "Uptime check removed. Nothing is watching this service now." });
|
|
4632
|
+
}
|
|
4633
|
+
});
|
|
4634
|
+
|
|
3705
4635
|
// src/tools/volumes.ts
|
|
3706
|
-
import { z as
|
|
4636
|
+
import { z as z19 } from "zod";
|
|
3707
4637
|
var MIN_VOLUME_SIZE_GB = 10;
|
|
3708
4638
|
defineTool({
|
|
3709
4639
|
name: "list_volumes",
|
|
@@ -3721,7 +4651,7 @@ defineTool({
|
|
|
3721
4651
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
3722
4652
|
].join("\n"),
|
|
3723
4653
|
input: {
|
|
3724
|
-
service_id:
|
|
4654
|
+
service_id: z19.string().describe("Service publicId (e.g. svc_abc123).")
|
|
3725
4655
|
},
|
|
3726
4656
|
handler: async (args2, ctx) => {
|
|
3727
4657
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3750,15 +4680,15 @@ defineTool({
|
|
|
3750
4680
|
'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" } }'
|
|
3751
4681
|
].join("\n"),
|
|
3752
4682
|
input: {
|
|
3753
|
-
service_id:
|
|
3754
|
-
name:
|
|
3755
|
-
mount_path:
|
|
4683
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4684
|
+
name: z19.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
4685
|
+
mount_path: z19.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
3756
4686
|
// 10 GB is the real floor: the block-storage backend rejects anything
|
|
3757
4687
|
// smaller. Advertising 1 GB here (and defaulting to it) meant taking the
|
|
3758
4688
|
// defaults produced a volume that provisioned with `Hetzner API error:
|
|
3759
4689
|
// 422` on the NEXT deploy, with nothing tying the failure back to the
|
|
3760
4690
|
// size. Reject it at the call instead.
|
|
3761
|
-
size_gb:
|
|
4691
|
+
size_gb: z19.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
|
|
3762
4692
|
`Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
|
|
3763
4693
|
)
|
|
3764
4694
|
},
|
|
@@ -3796,10 +4726,10 @@ defineTool({
|
|
|
3796
4726
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
3797
4727
|
].join("\n"),
|
|
3798
4728
|
input: {
|
|
3799
|
-
service_id:
|
|
3800
|
-
volume_id:
|
|
3801
|
-
mount_path:
|
|
3802
|
-
size_gb:
|
|
4729
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4730
|
+
volume_id: z19.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
4731
|
+
mount_path: z19.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
4732
|
+
size_gb: z19.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
|
|
3803
4733
|
},
|
|
3804
4734
|
handler: async (args2, ctx) => {
|
|
3805
4735
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3837,8 +4767,8 @@ defineTool({
|
|
|
3837
4767
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
3838
4768
|
].join("\n"),
|
|
3839
4769
|
input: {
|
|
3840
|
-
service_id:
|
|
3841
|
-
volume_id:
|
|
4770
|
+
service_id: z19.string().describe("Service publicId."),
|
|
4771
|
+
volume_id: z19.string().describe("Volume publicId.")
|
|
3842
4772
|
},
|
|
3843
4773
|
handler: async (args2, ctx) => {
|
|
3844
4774
|
const teamId = await ctx.resolveTeamId();
|