@hoststack.dev/mcp 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/hoststack-mcp.js +640 -132
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +640 -132
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { HostStack } from "@hoststack.dev/sdk";
|
|
4
4
|
|
|
5
5
|
// src/version.ts
|
|
6
|
-
var MCP_VERSION = true ? "0.
|
|
6
|
+
var MCP_VERSION = true ? "0.16.1" : "0.0.0-dev";
|
|
7
7
|
var USER_AGENT = `hoststack-mcp/${MCP_VERSION}`;
|
|
8
8
|
|
|
9
9
|
// src/api-client.ts
|
|
@@ -63,8 +63,8 @@ var ApiClient = class {
|
|
|
63
63
|
}
|
|
64
64
|
async handle(res) {
|
|
65
65
|
if (!res.ok) {
|
|
66
|
-
const
|
|
67
|
-
throw new Error(
|
|
66
|
+
const body = await res.json().catch(() => ({ error: res.statusText }));
|
|
67
|
+
throw new Error(formatApiError(body, res.status));
|
|
68
68
|
}
|
|
69
69
|
if (res.status === 204) {
|
|
70
70
|
return void 0;
|
|
@@ -72,6 +72,31 @@ var ApiClient = class {
|
|
|
72
72
|
return res.json();
|
|
73
73
|
}
|
|
74
74
|
};
|
|
75
|
+
function formatApiError(body, status) {
|
|
76
|
+
const fallback = `API error: ${status}`;
|
|
77
|
+
if (!body || typeof body !== "object") return fallback;
|
|
78
|
+
const err = body.error;
|
|
79
|
+
if (typeof err === "string") return err.trim() ? err : fallback;
|
|
80
|
+
const issues = Array.isArray(err) ? err : err && typeof err === "object" && Array.isArray(err.issues) ? err.issues : null;
|
|
81
|
+
if (issues && issues.length > 0) {
|
|
82
|
+
const rendered = issues.map((issue) => {
|
|
83
|
+
if (!issue || typeof issue !== "object") return String(issue);
|
|
84
|
+
const { path, message } = issue;
|
|
85
|
+
const field = Array.isArray(path) && path.length > 0 ? path.join(".") : null;
|
|
86
|
+
const text = typeof message === "string" ? message : "invalid value";
|
|
87
|
+
return field ? `${field}: ${text}` : text;
|
|
88
|
+
}).join("; ");
|
|
89
|
+
return `Invalid request \u2014 ${rendered}`;
|
|
90
|
+
}
|
|
91
|
+
if (err !== void 0) {
|
|
92
|
+
try {
|
|
93
|
+
return `${fallback}: ${JSON.stringify(err)}`;
|
|
94
|
+
} catch {
|
|
95
|
+
return fallback;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
75
100
|
|
|
76
101
|
// src/prompts/registry.ts
|
|
77
102
|
var prompts = [];
|
|
@@ -597,11 +622,176 @@ defineTool({
|
|
|
597
622
|
|
|
598
623
|
// src/tools/databases.ts
|
|
599
624
|
import { z as z5 } from "zod";
|
|
625
|
+
var DATABASE_VERSIONS = {
|
|
626
|
+
postgres: { default: "18", supported: ["18", "17", "16", "15"] },
|
|
627
|
+
redis: { default: "8", supported: ["8", "7", "6"] },
|
|
628
|
+
mysql: { default: "8.4", supported: ["8.4", "8.0", "5.7"] },
|
|
629
|
+
mariadb: { default: "11.4", supported: ["11.4", "10.11"] },
|
|
630
|
+
mongodb: { default: "8", supported: ["8", "7", "6"] }
|
|
631
|
+
};
|
|
632
|
+
var DB_ENGINES = ["postgres", "redis", "mysql", "mariadb", "mongodb"];
|
|
633
|
+
var VERSION_HELP = Object.keys(DATABASE_VERSIONS).map(
|
|
634
|
+
(e) => `${e}: ${DATABASE_VERSIONS[e].supported.join("/")} (default ${DATABASE_VERSIONS[e].default})`
|
|
635
|
+
).join("; ");
|
|
636
|
+
defineTool({
|
|
637
|
+
name: "create_database",
|
|
638
|
+
category: "databases",
|
|
639
|
+
description: [
|
|
640
|
+
"Provision a MANAGED database (Postgres, Redis, MySQL, MariaDB, MongoDB) in a project. This is the correct and ONLY supported way to add a database to a HostStack app.",
|
|
641
|
+
"",
|
|
642
|
+
'When to use: any time an app needs a datastore \u2014 "add a database", "I need Postgres", "set up Redis", "give this service a DB". Also the right call when scaffolding a new app that will need persistence.',
|
|
643
|
+
"",
|
|
644
|
+
'*** Do NOT hand-roll a database as a service. *** Deploying `postgres:16` (or redis/mysql/mongo) via create_service with a docker_image is NOT how databases work on this platform: it gets no managed backups, no automated version upgrades, no HA/failover path, no credential rotation, no metrics, no persistent volume wired up, and nothing will inject its connection URL into your app. If the user says "add a database", "I need Postgres", "set up Redis" \u2014 call THIS tool.',
|
|
645
|
+
"",
|
|
646
|
+
"How a database reaches your app (the whole flow \u2014 3 calls, no secrets handled):",
|
|
647
|
+
" 1. create_database({ project_id, name, engine }) \u2014 provisions it.",
|
|
648
|
+
' 2. link_resource_to_service({ service_id, resource_type: "database", resource_id: <database.id>, alias: "APP_DB" }) \u2014 binds it to the service that needs it.',
|
|
649
|
+
" 3. trigger_deploy({ service_id }) \u2014 on that deploy the platform injects the connection info as env vars.",
|
|
650
|
+
"After step 3 the container has `DATABASE_URL` (postgres/mysql/mariadb), `REDIS_URL` (redis) or `MONGO_URL` (mongodb) set automatically, plus alias-prefixed vars. An app reading `process.env.DATABASE_URL` needs zero code changes. You never have to fetch, generate or paste a password.",
|
|
651
|
+
"",
|
|
652
|
+
"Inputs:",
|
|
653
|
+
' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
|
|
654
|
+
" - name: database name (1\u2013100 chars).",
|
|
655
|
+
` - engine: ${DB_ENGINES.join(" | ")}.`,
|
|
656
|
+
` - version (optional): engine version. Supported \u2014 ${VERSION_HELP}. Omit to get the default (recommended).`,
|
|
657
|
+
' - plan (optional): "micro" | "starter" | "standard" | "pro" (default "starter"). "starter" is the smallest always-on tier that includes backups.',
|
|
658
|
+
" - environment_id (optional): bind to a specific environment; defaults to the project Production env.",
|
|
659
|
+
" - postgis (optional): provision the PostGIS image variant so `CREATE EXTENSION postgis` works. Postgres only.",
|
|
660
|
+
" - pgvector (optional): provision the pgvector image variant so `CREATE EXTENSION vector` works. Postgres only, mutually exclusive with postgis.",
|
|
661
|
+
"",
|
|
662
|
+
'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).',
|
|
663
|
+
"",
|
|
664
|
+
'Provisioning is async: the row comes back immediately, usually as `creating`. Poll get_database until `status === "available"` before linking or connecting.',
|
|
665
|
+
"",
|
|
666
|
+
'Example: create_database({ project_id: "prj_abc", name: "app-db", engine: "postgres" }) \u2192 { database: { id: 42, publicId: "db_\u2026", status: "creating" } }'
|
|
667
|
+
].join("\n"),
|
|
668
|
+
input: {
|
|
669
|
+
project_id: z5.union([z5.number().int().positive(), z5.string()]).describe('Target project \u2014 numeric id or publicId ("prj_\u2026").'),
|
|
670
|
+
name: z5.string().min(1).max(100).describe("Database name (1\u2013100 chars)."),
|
|
671
|
+
engine: z5.enum(DB_ENGINES).describe("Database engine."),
|
|
672
|
+
version: z5.string().max(20).optional().describe(`Engine version. ${VERSION_HELP}. Omit for the engine default.`),
|
|
673
|
+
plan: z5.enum(["micro", "starter", "standard", "pro"]).optional().describe('Plan tier (memory/CPU). Default "starter".'),
|
|
674
|
+
environment_id: z5.union([z5.number().int().positive(), z5.string()]).optional().describe("Environment to bind to. Defaults to the project Production env."),
|
|
675
|
+
postgis: z5.boolean().optional().describe("Postgres only \u2014 enable the PostGIS extension image."),
|
|
676
|
+
pgvector: z5.boolean().optional().describe(
|
|
677
|
+
"Postgres only \u2014 enable the pgvector extension image. Exclusive with postgis."
|
|
678
|
+
)
|
|
679
|
+
},
|
|
680
|
+
handler: async (args, ctx) => {
|
|
681
|
+
const teamId = await ctx.resolveTeamId();
|
|
682
|
+
const projectId = await ctx.hoststack.resolveId(args.project_id, {
|
|
683
|
+
kind: "project",
|
|
684
|
+
teamId
|
|
685
|
+
});
|
|
686
|
+
const input = {
|
|
687
|
+
name: args.name,
|
|
688
|
+
engine: args.engine,
|
|
689
|
+
projectId
|
|
690
|
+
};
|
|
691
|
+
if (args.version !== void 0) input.version = args.version;
|
|
692
|
+
if (args.plan !== void 0) input.plan = args.plan;
|
|
693
|
+
if (args.environment_id !== void 0) {
|
|
694
|
+
input.environmentId = await ctx.hoststack.resolveId(args.environment_id, {
|
|
695
|
+
kind: "environment",
|
|
696
|
+
teamId
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (args.postgis !== void 0) input.postgis = args.postgis;
|
|
700
|
+
if (args.pgvector !== void 0) input.pgvector = args.pgvector;
|
|
701
|
+
const response = await ctx.hoststack.databases.create(teamId, input);
|
|
702
|
+
const data = { database: shapeDatabase(response.database) };
|
|
703
|
+
const db = response.database;
|
|
704
|
+
return respond({
|
|
705
|
+
summary: `Created ${args.engine} database "${args.name}" (${db.publicId ?? "unknown"}, numeric id ${db.id ?? "?"}) \u2014 status ${db.status ?? "creating"}. Poll get_database until status=available, then link_resource_to_service to inject its URL into a service.`,
|
|
706
|
+
data
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
defineTool({
|
|
711
|
+
name: "delete_database",
|
|
712
|
+
category: "databases",
|
|
713
|
+
description: [
|
|
714
|
+
"Permanently delete a managed database \u2014 the container, its volume, and ALL data it holds.",
|
|
715
|
+
"",
|
|
716
|
+
'When to use: the user has explicitly asked to destroy a database they no longer want. This is irreversible and takes the data with it \u2014 confirm with the user before calling, and never call it to "clean up" as a side effect of another task. If the goal is to stop paying for an idle database while keeping the data, use suspend_database instead.',
|
|
717
|
+
"",
|
|
718
|
+
"Any service still linked to this database will lose the injected connection vars on its next deploy, and will fail at runtime if it depends on them. Check which services consume it first \u2014 the dashboard shows this under the database's Linked Services tab.",
|
|
719
|
+
"",
|
|
720
|
+
"Inputs:",
|
|
721
|
+
' - database_id: publicId of the database (e.g. "db_\u2026").',
|
|
722
|
+
"",
|
|
723
|
+
"Returns: { ok: true }.",
|
|
724
|
+
"",
|
|
725
|
+
'Example: delete_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
726
|
+
].join("\n"),
|
|
727
|
+
input: {
|
|
728
|
+
database_id: z5.string().describe("Database publicId (e.g. db_abc) to permanently delete.")
|
|
729
|
+
},
|
|
730
|
+
handler: async (args, ctx) => {
|
|
731
|
+
const teamId = await ctx.resolveTeamId();
|
|
732
|
+
await ctx.hoststack.databases.delete(teamId, args.database_id);
|
|
733
|
+
return respond({
|
|
734
|
+
summary: `Deleted database ${args.database_id} and all of its data. This cannot be undone.`
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
defineTool({
|
|
739
|
+
name: "suspend_database",
|
|
740
|
+
category: "databases",
|
|
741
|
+
description: [
|
|
742
|
+
"Suspend a managed database \u2014 stops its container while KEEPING the volume and all data. The reversible alternative to delete_database.",
|
|
743
|
+
"",
|
|
744
|
+
"When to use: a staging or preview database that nobody is using, to stop burning its compute tier. Connections fail while suspended; resume_database brings it back on the same connection URL.",
|
|
745
|
+
"",
|
|
746
|
+
"Inputs:",
|
|
747
|
+
' - database_id: publicId of the database (e.g. "db_\u2026").',
|
|
748
|
+
"",
|
|
749
|
+
"Returns: { ok: true }.",
|
|
750
|
+
"",
|
|
751
|
+
'Example: suspend_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
752
|
+
].join("\n"),
|
|
753
|
+
input: {
|
|
754
|
+
database_id: z5.string().describe("Database publicId (e.g. db_abc) to suspend.")
|
|
755
|
+
},
|
|
756
|
+
handler: async (args, ctx) => {
|
|
757
|
+
const teamId = await ctx.resolveTeamId();
|
|
758
|
+
await ctx.hoststack.databases.suspend(teamId, args.database_id);
|
|
759
|
+
return respond({
|
|
760
|
+
summary: `Suspended database ${args.database_id}. Data is preserved; resume_database restores it on the same URL.`
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
});
|
|
764
|
+
defineTool({
|
|
765
|
+
name: "resume_database",
|
|
766
|
+
category: "databases",
|
|
767
|
+
description: [
|
|
768
|
+
"Resume a suspended managed database \u2014 restarts its container against the existing volume. The connection URL is unchanged, so linked services do NOT need a redeploy.",
|
|
769
|
+
"",
|
|
770
|
+
"When to use: undoing a suspend_database, or bringing back a database that was suspended for non-payment once billing is resolved.",
|
|
771
|
+
"",
|
|
772
|
+
"Inputs:",
|
|
773
|
+
' - database_id: publicId of the database (e.g. "db_\u2026").',
|
|
774
|
+
"",
|
|
775
|
+
'Returns: { ok: true }. Poll get_database until `status === "available"`.',
|
|
776
|
+
"",
|
|
777
|
+
'Example: resume_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
778
|
+
].join("\n"),
|
|
779
|
+
input: {
|
|
780
|
+
database_id: z5.string().describe("Database publicId (e.g. db_abc) to resume.")
|
|
781
|
+
},
|
|
782
|
+
handler: async (args, ctx) => {
|
|
783
|
+
const teamId = await ctx.resolveTeamId();
|
|
784
|
+
await ctx.hoststack.databases.resume(teamId, args.database_id);
|
|
785
|
+
return respond({
|
|
786
|
+
summary: `Resume dispatched for ${args.database_id}. Poll get_database until status=available.`
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
});
|
|
600
790
|
defineTool({
|
|
601
791
|
name: "list_databases",
|
|
602
792
|
category: "databases",
|
|
603
793
|
description: [
|
|
604
|
-
"List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB
|
|
794
|
+
"List managed databases (Postgres, Redis, MySQL, MariaDB, MongoDB) inside a project. Meilisearch and NATS are NOT databases \u2014 they are separate managed resources (search / queue) and do not appear here; see list_service_resources for what a service consumes.",
|
|
605
795
|
"",
|
|
606
796
|
"When to use: an agent needs to know what data stores exist in a project before connecting a service or running a migration. Pair with list_projects to discover project IDs.",
|
|
607
797
|
"",
|
|
@@ -786,6 +976,34 @@ defineTool({
|
|
|
786
976
|
});
|
|
787
977
|
}
|
|
788
978
|
});
|
|
979
|
+
defineTool({
|
|
980
|
+
name: "restart_database",
|
|
981
|
+
category: "databases",
|
|
982
|
+
description: [
|
|
983
|
+
"Restart a managed database in place \u2014 `docker restart` of its container: same container, same volume, same connection URL. The database is briefly unavailable (a few seconds) while it bounces; no redeploy of connected services is needed.",
|
|
984
|
+
"",
|
|
985
|
+
"When to use: a database is wedged (stuck connection, needs to reload a config change, leaking memory) and a clean bounce is the fix \u2014 the database-equivalent of restarting a service.",
|
|
986
|
+
"",
|
|
987
|
+
"The database must be `available`. A suspended database has no running container (resume it first \u2192 409); a creating/migrating one is mid-operation (409). For a Patroni HA cluster this bounces the current leader, which triggers a normal failover while replicas keep serving reads.",
|
|
988
|
+
"",
|
|
989
|
+
"Inputs:",
|
|
990
|
+
" - database_id: publicId of the database to restart.",
|
|
991
|
+
"",
|
|
992
|
+
"Returns: { ok: true } once the restart command reaches an agent (dispatched optimistically, like a service restart). Poll get_service_logs / get_database to confirm it came back.",
|
|
993
|
+
"",
|
|
994
|
+
'Example: restart_database({ database_id: "db_abc" }) \u2192 { ok: true }'
|
|
995
|
+
].join("\n"),
|
|
996
|
+
input: {
|
|
997
|
+
database_id: z5.string().describe("Database publicId (e.g. db_abc) to restart.")
|
|
998
|
+
},
|
|
999
|
+
handler: async (args, ctx) => {
|
|
1000
|
+
const teamId = await ctx.resolveTeamId();
|
|
1001
|
+
await ctx.hoststack.databases.restart(teamId, args.database_id);
|
|
1002
|
+
return respond({
|
|
1003
|
+
summary: `Restart dispatched for ${args.database_id}. It will be briefly unavailable while the container bounces.`
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
});
|
|
789
1007
|
defineTool({
|
|
790
1008
|
name: "get_database_cluster",
|
|
791
1009
|
category: "databases",
|
|
@@ -911,7 +1129,7 @@ defineTool({
|
|
|
911
1129
|
description: [
|
|
912
1130
|
"Cancel a running deploy. Stops the build/deploy pipeline mid-flight; the previously live revision keeps serving traffic.",
|
|
913
1131
|
"",
|
|
914
|
-
"When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Has no effect on already-finished deploys.",
|
|
1132
|
+
"When to use: the user notices a bad commit was pushed and wants to abort before it lands, or a build is hanging. Also the right call for a deploy stuck in `deploying` because the new container is crash-looping against its health check \u2014 that state is cancellable and cancelling it frees the service (and the build queue) immediately instead of waiting out the grace period. Has no effect on already-finished deploys.",
|
|
915
1133
|
"",
|
|
916
1134
|
"Inputs:",
|
|
917
1135
|
" - service_id: publicId of the service.",
|
|
@@ -1073,7 +1291,7 @@ var DNS_RECORD_TYPES = [
|
|
|
1073
1291
|
async function resolveZonePublicId(hoststack, teamId, input) {
|
|
1074
1292
|
if (input.zone_id) {
|
|
1075
1293
|
const { zones: zones2 } = await hoststack.dns.listZones(teamId);
|
|
1076
|
-
const match = zones2.find((
|
|
1294
|
+
const match = zones2.find((z16) => z16.publicId === input.zone_id);
|
|
1077
1295
|
if (!match) {
|
|
1078
1296
|
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
1079
1297
|
}
|
|
@@ -1087,7 +1305,7 @@ async function resolveZonePublicId(hoststack, teamId, input) {
|
|
|
1087
1305
|
const labels = fqdn.split(".");
|
|
1088
1306
|
for (let i = 0; i < labels.length - 1; i++) {
|
|
1089
1307
|
const candidate = labels.slice(i).join(".");
|
|
1090
|
-
const match = zones.find((
|
|
1308
|
+
const match = zones.find((z16) => z16.domainName.toLowerCase() === candidate);
|
|
1091
1309
|
if (match && match.status !== "deleting") {
|
|
1092
1310
|
return { publicId: match.publicId, domainName: match.domainName };
|
|
1093
1311
|
}
|
|
@@ -1340,6 +1558,49 @@ defineTool({
|
|
|
1340
1558
|
});
|
|
1341
1559
|
}
|
|
1342
1560
|
});
|
|
1561
|
+
defineTool({
|
|
1562
|
+
name: "resync_dns_record",
|
|
1563
|
+
category: "dns",
|
|
1564
|
+
description: [
|
|
1565
|
+
'Re-push a DNS record to PowerDNS without changing its value. Recovers a record stuck at status="failed" after a transient provider outage \u2014 including managedBy="hoststack" records (auto-created for a service/domain) that create/update/delete refuse to touch.',
|
|
1566
|
+
"",
|
|
1567
|
+
`When to use: a record shows status="failed" with a lastSyncError, or a freshly-attached domain's auto-created A record never went active. Safe and idempotent \u2014 a rejected re-push leaves the live RRset untouched (PowerDNS PATCH is atomic), so this can never drop a working record.`,
|
|
1568
|
+
"",
|
|
1569
|
+
"Inputs:",
|
|
1570
|
+
' - record_id: record publicId (e.g. "dnr_abc").',
|
|
1571
|
+
"",
|
|
1572
|
+
'Returns: { record: Record } \u2014 the record with its refreshed status ("active" on success).',
|
|
1573
|
+
"",
|
|
1574
|
+
'Example: resync_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { status: "active", ... } }'
|
|
1575
|
+
].join("\n"),
|
|
1576
|
+
input: {
|
|
1577
|
+
record_id: z7.string().describe("Record publicId.")
|
|
1578
|
+
},
|
|
1579
|
+
handler: async (args, ctx) => {
|
|
1580
|
+
const teamId = await ctx.resolveTeamId();
|
|
1581
|
+
const target = await findRecordZone(ctx.hoststack, teamId, args.record_id);
|
|
1582
|
+
if (!target) {
|
|
1583
|
+
return respondError(
|
|
1584
|
+
`Record ${args.record_id} not found on any zone owned by this team.`
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
const response = await ctx.hoststack.dns.resyncRecord(
|
|
1588
|
+
teamId,
|
|
1589
|
+
target.zone.publicId,
|
|
1590
|
+
args.record_id
|
|
1591
|
+
);
|
|
1592
|
+
return respond({
|
|
1593
|
+
summary: `Re-synced record ${args.record_id} on ${target.zone.domainName} (status: ${response.record.status}).`,
|
|
1594
|
+
data: {
|
|
1595
|
+
zone: {
|
|
1596
|
+
publicId: target.zone.publicId,
|
|
1597
|
+
domainName: target.zone.domainName
|
|
1598
|
+
},
|
|
1599
|
+
record: shape(response.record)
|
|
1600
|
+
}
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
});
|
|
1343
1604
|
async function findRecordZone(hoststack, teamId, recordPublicId) {
|
|
1344
1605
|
const { zones } = await hoststack.dns.listZones(teamId);
|
|
1345
1606
|
for (const zone of zones) {
|
|
@@ -1403,9 +1664,10 @@ defineTool({
|
|
|
1403
1664
|
};
|
|
1404
1665
|
if (args.path_prefix !== void 0) input.pathPrefix = args.path_prefix;
|
|
1405
1666
|
const response = await ctx.hoststack.domains.add(teamId, input);
|
|
1406
|
-
const
|
|
1667
|
+
const dnsSyncWarning = response.domain.dnsSyncWarning;
|
|
1668
|
+
const data = dnsSyncWarning ? { domain: shapeDomain(response.domain), dnsSyncWarning } : { domain: shapeDomain(response.domain) };
|
|
1407
1669
|
return respond({
|
|
1408
|
-
summary: `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
|
|
1670
|
+
summary: dnsSyncWarning ? `Added domain ${args.hostname}, but its auto-created DNS record hasn't synced yet \u2014 ${dnsSyncWarning}` : `Added domain ${args.hostname}. Configure DNS, then call verify_domain.`,
|
|
1409
1671
|
data
|
|
1410
1672
|
});
|
|
1411
1673
|
}
|
|
@@ -1786,6 +2048,48 @@ defineTool({
|
|
|
1786
2048
|
}
|
|
1787
2049
|
});
|
|
1788
2050
|
|
|
2051
|
+
// src/tools/github.ts
|
|
2052
|
+
defineTool({
|
|
2053
|
+
name: "sync_github_repos",
|
|
2054
|
+
category: "github",
|
|
2055
|
+
description: [
|
|
2056
|
+
"Re-sync the repository list from GitHub for every connected GitHub App installation.",
|
|
2057
|
+
"",
|
|
2058
|
+
"When to use: right after pushing a brand-new repository, HostStack cannot see it until an installation re-sync runs \u2014 so create_service with a github_repo_id would fail. Call this first. (Same operation as the dashboard's refresh icon on the repo picker.)",
|
|
2059
|
+
"",
|
|
2060
|
+
"No inputs.",
|
|
2061
|
+
"",
|
|
2062
|
+
"Returns: { installations: number, repos: [{ account, count }], totalRepos } \u2014 the accounts synced and how many repos each now exposes.",
|
|
2063
|
+
"",
|
|
2064
|
+
'Example: sync_github_repos({}) \u2192 { installations: 1, repos: [{ account: "acme", count: 12 }], totalRepos: 12 }'
|
|
2065
|
+
].join("\n"),
|
|
2066
|
+
input: {},
|
|
2067
|
+
handler: async (_args, ctx) => {
|
|
2068
|
+
const teamId = await ctx.resolveTeamId();
|
|
2069
|
+
const { installations } = await ctx.api.get(
|
|
2070
|
+
`/api/github/${teamId}/installations`
|
|
2071
|
+
);
|
|
2072
|
+
if (installations.length === 0) {
|
|
2073
|
+
return respondError(
|
|
2074
|
+
"No GitHub installations connected. Install the HostStack GitHub App from the dashboard (Settings \u2192 GitHub) first."
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
const perAccount = [];
|
|
2078
|
+
let totalRepos = 0;
|
|
2079
|
+
for (const inst of installations) {
|
|
2080
|
+
const { repos } = await ctx.api.post(
|
|
2081
|
+
`/api/github/${teamId}/installations/${inst.id}/sync`
|
|
2082
|
+
);
|
|
2083
|
+
perAccount.push({ account: inst.accountLogin, count: repos.length });
|
|
2084
|
+
totalRepos += repos.length;
|
|
2085
|
+
}
|
|
2086
|
+
return respond({
|
|
2087
|
+
summary: `Synced ${totalRepos} repositories across ${installations.length} installation(s).`,
|
|
2088
|
+
data: { installations: installations.length, repos: perAccount, totalRepos }
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
});
|
|
2092
|
+
|
|
1789
2093
|
// src/tools/meta.ts
|
|
1790
2094
|
var DEV_ENV_TOOL_NAMES = [
|
|
1791
2095
|
"create_dev_environment",
|
|
@@ -1864,7 +2168,10 @@ var NOTIFICATION_EVENTS = [
|
|
|
1864
2168
|
"service.restart_failed",
|
|
1865
2169
|
"service.auto_suspended",
|
|
1866
2170
|
"service.acme_cert_failed",
|
|
1867
|
-
"
|
|
2171
|
+
"service.resource_alert",
|
|
2172
|
+
"git.auth_failed",
|
|
2173
|
+
"cron.execution_failed",
|
|
2174
|
+
"workflow.failed"
|
|
1868
2175
|
];
|
|
1869
2176
|
defineTool({
|
|
1870
2177
|
name: "list_notification_channels",
|
|
@@ -1903,7 +2210,7 @@ defineTool({
|
|
|
1903
2210
|
" - webhook_url: Slack/Discord webhook URL OR email address.",
|
|
1904
2211
|
" - 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).",
|
|
1905
2212
|
"",
|
|
1906
|
-
"Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, git.auth_failed.",
|
|
2213
|
+
"Valid events: deploy.started, deploy.succeeded, deploy.failed, deploy.failed_consecutive, service.created, service.deleted, service.suspended, service.resumed, service.restart_failed, service.auto_suspended, service.acme_cert_failed, service.resource_alert, git.auth_failed, cron.execution_failed, workflow.failed.",
|
|
1907
2214
|
"",
|
|
1908
2215
|
"Returns: { channel: Channel }.",
|
|
1909
2216
|
"",
|
|
@@ -2158,9 +2465,159 @@ defineTool({
|
|
|
2158
2465
|
}
|
|
2159
2466
|
});
|
|
2160
2467
|
|
|
2161
|
-
// src/tools/
|
|
2468
|
+
// src/tools/resource-links.ts
|
|
2162
2469
|
import { z as z13 } from "zod";
|
|
2163
|
-
var
|
|
2470
|
+
var RESOURCE_LINK_TYPES = [
|
|
2471
|
+
"database",
|
|
2472
|
+
"object_storage",
|
|
2473
|
+
"queue",
|
|
2474
|
+
"search",
|
|
2475
|
+
"email_domain"
|
|
2476
|
+
];
|
|
2477
|
+
var INJECTION_CONTRACT = [
|
|
2478
|
+
"What a link injects at deploy time:",
|
|
2479
|
+
' - Alias-prefixed vars for every field of the resource (alias "APP_DB" \u2192 APP_DB_HOST, APP_DB_PORT, APP_DB_USER, APP_DB_PASSWORD, APP_DB_URL, \u2026). This is how one service consumes several resources of the same type without collisions.',
|
|
2480
|
+
" - PLUS the conventional name for the first linked resource of each engine class: `DATABASE_URL` (postgres/mysql/mariadb), `REDIS_URL` (redis), `MONGO_URL` (mongodb). So an app reading `process.env.DATABASE_URL` works with no code changes.",
|
|
2481
|
+
" - The conventional names are set with `??=` \u2014 an env var you set yourself via set_env_var ALWAYS wins over the injected one."
|
|
2482
|
+
].join("\n");
|
|
2483
|
+
defineTool({
|
|
2484
|
+
name: "list_managed_resources",
|
|
2485
|
+
category: "resource-links",
|
|
2486
|
+
description: [
|
|
2487
|
+
"List EVERY managed resource the team owns in one call \u2014 databases, object storage buckets, queues, search indexes and email domains \u2014 from the unified `managed_resources` read-model.",
|
|
2488
|
+
"",
|
|
2489
|
+
'When to use: to find the NUMERIC `id` that link_resource_to_service needs, especially for resource types with no dedicated list tool of their own (object storage, queues, search, email domains \u2014 list_databases only covers databases). Also a fast inventory answer for "what do we actually have running".',
|
|
2490
|
+
"",
|
|
2491
|
+
"Team-wide, not project-scoped. Use list_databases when you specifically want databases in one project.",
|
|
2492
|
+
"",
|
|
2493
|
+
"Returns: { items: ManagedResource[] } \u2014 id (numeric, for linking), publicId, type, name, status, region, createdAt.",
|
|
2494
|
+
"",
|
|
2495
|
+
'Example: list_managed_resources() \u2192 { items: [{ id: 42, type: "database", name: "app-db", status: "available" }, { id: 8, type: "object_storage", name: "uploads", \u2026 }] }'
|
|
2496
|
+
].join("\n"),
|
|
2497
|
+
input: {},
|
|
2498
|
+
handler: async (_args, ctx) => {
|
|
2499
|
+
const teamId = await ctx.resolveTeamId();
|
|
2500
|
+
const response = await ctx.hoststack.serviceResourceLinks.listManagedResources(teamId);
|
|
2501
|
+
const data = shapeList(response, "resources", shape);
|
|
2502
|
+
const byType = /* @__PURE__ */ new Map();
|
|
2503
|
+
for (const item of data.items) {
|
|
2504
|
+
const type = item && typeof item === "object" && "type" in item ? String(item.type) : "unknown";
|
|
2505
|
+
byType.set(type, (byType.get(type) ?? 0) + 1);
|
|
2506
|
+
}
|
|
2507
|
+
const breakdown = [...byType.entries()].map(([t, n]) => `${n} ${t}`).join(", ");
|
|
2508
|
+
const summary = data.items.length === 0 ? "No managed resources on this team yet." : `${data.items.length} managed resource${data.items.length === 1 ? "" : "s"}: ${breakdown}.`;
|
|
2509
|
+
return respond({ summary, data });
|
|
2510
|
+
}
|
|
2511
|
+
});
|
|
2512
|
+
defineTool({
|
|
2513
|
+
name: "list_service_resources",
|
|
2514
|
+
category: "resource-links",
|
|
2515
|
+
description: [
|
|
2516
|
+
"List the managed resources (databases, object storage, queues, search, email domains) linked to a service \u2014 i.e. which resources get injected into its container as env vars on deploy.",
|
|
2517
|
+
"",
|
|
2518
|
+
'When to use: before adding a link (to avoid a duplicate or an alias collision), when debugging "why is DATABASE_URL empty in my app" (the answer is usually: no link exists, or the service was never redeployed after linking), or to find the linkId needed by unlink_resource_from_service.',
|
|
2519
|
+
"",
|
|
2520
|
+
INJECTION_CONTRACT,
|
|
2521
|
+
"",
|
|
2522
|
+
"Inputs:",
|
|
2523
|
+
' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
|
|
2524
|
+
"",
|
|
2525
|
+
"Returns: { items: ServiceResourceLink[] } \u2014 id (the linkId), resourceType, resourceId (numeric id of the linked resource), alias, createdAt.",
|
|
2526
|
+
"",
|
|
2527
|
+
'Example: list_service_resources({ service_id: "svc_abc" }) \u2192 { items: [{ id: 7, resourceType: "database", resourceId: 42, alias: "APP_DB" }] }'
|
|
2528
|
+
].join("\n"),
|
|
2529
|
+
input: {
|
|
2530
|
+
service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.')
|
|
2531
|
+
},
|
|
2532
|
+
handler: async (args, ctx) => {
|
|
2533
|
+
const teamId = await ctx.resolveTeamId();
|
|
2534
|
+
const response = await ctx.hoststack.serviceResourceLinks.list(teamId, args.service_id);
|
|
2535
|
+
const data = shapeList(response, "links", shape);
|
|
2536
|
+
const summary = data.items.length === 0 ? `No resources linked to service ${args.service_id}. Nothing is being injected \u2014 link a database with link_resource_to_service.` : `${data.items.length} resource${data.items.length === 1 ? "" : "s"} linked to service ${args.service_id}.`;
|
|
2537
|
+
return respond({ summary, data });
|
|
2538
|
+
}
|
|
2539
|
+
});
|
|
2540
|
+
defineTool({
|
|
2541
|
+
name: "link_resource_to_service",
|
|
2542
|
+
category: "resource-links",
|
|
2543
|
+
description: [
|
|
2544
|
+
"Link a managed resource to a service so its connection details are injected into the container as environment variables. This is the step that connects a database to an app \u2014 creating the database alone does nothing for the app.",
|
|
2545
|
+
"",
|
|
2546
|
+
"When to use: immediately after create_database (or when pointing an existing service at an existing resource). This is step 2 of 3 in the managed-database flow: create_database \u2192 link_resource_to_service \u2192 trigger_deploy.",
|
|
2547
|
+
"",
|
|
2548
|
+
'*** The link takes effect on the NEXT DEPLOY. *** After calling this, call trigger_deploy({ service_id }) or the running container will not see the new vars. A service that "cannot connect to the database" right after linking almost always just needs that redeploy.',
|
|
2549
|
+
"",
|
|
2550
|
+
INJECTION_CONTRACT,
|
|
2551
|
+
"",
|
|
2552
|
+
"Because the platform injects the credentials directly into the container, you do NOT need to read the password, build a connection string by hand, or store it with set_env_var. Don't.",
|
|
2553
|
+
"",
|
|
2554
|
+
"Inputs:",
|
|
2555
|
+
' - service_id: publicId ("svc_\u2026") or numeric id of the consuming service.',
|
|
2556
|
+
` - resource_type: ${RESOURCE_LINK_TYPES.join(" | ")}.`,
|
|
2557
|
+
' - resource_id: the NUMERIC id of the resource \u2014 e.g. the `id` field from create_database / list_databases, NOT the "db_\u2026" publicId.',
|
|
2558
|
+
' - alias: uppercase env-var prefix (A\u2013Z, 0\u20139, underscore; must start with a letter; \u226448 chars), e.g. "APP_DB", "CACHE", "CATALOG". Must be unique within the service.',
|
|
2559
|
+
"",
|
|
2560
|
+
"Returns: { link: ServiceResourceLink } \u2014 including `id`, the linkId used to unlink later.",
|
|
2561
|
+
"",
|
|
2562
|
+
"Fails with 409 if this exact resource is already linked to the service, or if the alias is already taken on it.",
|
|
2563
|
+
"",
|
|
2564
|
+
'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" } }'
|
|
2565
|
+
].join("\n"),
|
|
2566
|
+
input: {
|
|
2567
|
+
service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Consuming service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
2568
|
+
resource_type: z13.enum(RESOURCE_LINK_TYPES).describe("Kind of resource being linked."),
|
|
2569
|
+
resource_id: z13.number().int().positive().describe("NUMERIC id of the resource (e.g. database.id) \u2014 not the publicId."),
|
|
2570
|
+
alias: z13.string().min(1).max(48).regex(
|
|
2571
|
+
/^[A-Z][A-Z0-9_]*$/,
|
|
2572
|
+
"Alias must be uppercase letters, digits and underscores, starting with a letter."
|
|
2573
|
+
).describe('Uppercase env-var prefix, e.g. "APP_DB". Unique within the service.')
|
|
2574
|
+
},
|
|
2575
|
+
handler: async (args, ctx) => {
|
|
2576
|
+
const teamId = await ctx.resolveTeamId();
|
|
2577
|
+
const response = await ctx.hoststack.serviceResourceLinks.create(teamId, args.service_id, {
|
|
2578
|
+
resourceType: args.resource_type,
|
|
2579
|
+
resourceId: args.resource_id,
|
|
2580
|
+
alias: args.alias
|
|
2581
|
+
});
|
|
2582
|
+
const data = { link: shape(response.link) };
|
|
2583
|
+
return respond({
|
|
2584
|
+
summary: `Linked ${args.resource_type} ${args.resource_id} to service ${args.service_id} as "${args.alias}". Call trigger_deploy({ service_id: "${args.service_id}" }) to inject it \u2014 the running container will not see the vars until then.`,
|
|
2585
|
+
data
|
|
2586
|
+
});
|
|
2587
|
+
}
|
|
2588
|
+
});
|
|
2589
|
+
defineTool({
|
|
2590
|
+
name: "unlink_resource_from_service",
|
|
2591
|
+
category: "resource-links",
|
|
2592
|
+
description: [
|
|
2593
|
+
"Remove a resource link from a service. The resource itself is NOT deleted \u2014 only the binding.",
|
|
2594
|
+
"",
|
|
2595
|
+
"When to use: repointing a service at a different database, or detaching a resource a service no longer needs. The injected env vars disappear on the next deploy, so a service that still reads DATABASE_URL will break then, not immediately. Use delete_database if the intent is to destroy the data.",
|
|
2596
|
+
"",
|
|
2597
|
+
"Inputs:",
|
|
2598
|
+
' - service_id: publicId ("svc_\u2026") or numeric id of the service.',
|
|
2599
|
+
" - link_id: numeric linkId from list_service_resources (the `id` field on the link, not the resource id).",
|
|
2600
|
+
"",
|
|
2601
|
+
"Returns: { ok: true }.",
|
|
2602
|
+
"",
|
|
2603
|
+
'Example: unlink_resource_from_service({ service_id: "svc_abc", link_id: 7 }) \u2192 { ok: true }'
|
|
2604
|
+
].join("\n"),
|
|
2605
|
+
input: {
|
|
2606
|
+
service_id: z13.union([z13.number().int().positive(), z13.string()]).describe('Service \u2014 publicId ("svc_\u2026") or numeric id.'),
|
|
2607
|
+
link_id: z13.number().int().positive().describe("Numeric linkId from list_service_resources (the link's own `id`).")
|
|
2608
|
+
},
|
|
2609
|
+
handler: async (args, ctx) => {
|
|
2610
|
+
const teamId = await ctx.resolveTeamId();
|
|
2611
|
+
await ctx.hoststack.serviceResourceLinks.delete(teamId, args.service_id, args.link_id);
|
|
2612
|
+
return respond({
|
|
2613
|
+
summary: `Unlinked resource link ${args.link_id} from service ${args.service_id}. The resource still exists; its env vars vanish on the next deploy.`
|
|
2614
|
+
});
|
|
2615
|
+
}
|
|
2616
|
+
});
|
|
2617
|
+
|
|
2618
|
+
// src/tools/services.ts
|
|
2619
|
+
import { z as z14 } from "zod";
|
|
2620
|
+
var DEV_ENV_IMAGE = "registry.hoststack.dev/hoststack/dev-env:latest";
|
|
2164
2621
|
var DEV_ENV_VOLUME = { name: "workspace", mountPath: "/workspace", sizeGb: 10 };
|
|
2165
2622
|
var SERVICE_TYPES = [
|
|
2166
2623
|
"web_service",
|
|
@@ -2208,11 +2665,11 @@ defineTool({
|
|
|
2208
2665
|
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
2209
2666
|
].join("\n"),
|
|
2210
2667
|
input: {
|
|
2211
|
-
project_id:
|
|
2212
|
-
environment_id:
|
|
2213
|
-
status:
|
|
2214
|
-
type:
|
|
2215
|
-
dev_environment:
|
|
2668
|
+
project_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
2669
|
+
environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
2670
|
+
status: z14.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
2671
|
+
type: z14.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type."),
|
|
2672
|
+
dev_environment: z14.boolean().optional().describe(
|
|
2216
2673
|
"Include agentic Dev Boxes in the results (excluded by default; see list_dev_environments)."
|
|
2217
2674
|
)
|
|
2218
2675
|
},
|
|
@@ -2257,6 +2714,8 @@ defineTool({
|
|
|
2257
2714
|
"",
|
|
2258
2715
|
"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).",
|
|
2259
2716
|
"",
|
|
2717
|
+
'*** 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.',
|
|
2718
|
+
"",
|
|
2260
2719
|
"Inputs:",
|
|
2261
2720
|
' - project_id: numeric id or publicId ("prj_\u2026") of the target project.',
|
|
2262
2721
|
" - name: service name (1\u2013100 chars).",
|
|
@@ -2277,21 +2736,23 @@ defineTool({
|
|
|
2277
2736
|
'Example: create_service({ project_id: "prj_abc", name: "api", type: "web_service", github_repo_id: 42 }) \u2192 { service: { publicId: "svc_\u2026" }, deployId: 1234 }'
|
|
2278
2737
|
].join("\n"),
|
|
2279
2738
|
input: {
|
|
2280
|
-
project_id:
|
|
2281
|
-
name:
|
|
2282
|
-
type:
|
|
2283
|
-
docker_image:
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2739
|
+
project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
2740
|
+
name: z14.string().min(1).max(100).describe("Service name (1\u2013100 chars)."),
|
|
2741
|
+
type: z14.enum(SERVICE_TYPES).describe("Service type."),
|
|
2742
|
+
docker_image: z14.string().max(500).optional().describe(
|
|
2743
|
+
"Pre-built APPLICATION image ref. Mutually exclusive with github_repo_id. Not for databases \u2014 use create_database for postgres/redis/mysql/mariadb/mongodb."
|
|
2744
|
+
),
|
|
2745
|
+
github_repo_id: z14.number().int().positive().optional().describe("Linked GitHub repo numeric id. Mutually exclusive with docker_image."),
|
|
2746
|
+
branch: z14.string().max(200).optional().describe('Git branch (default "main").'),
|
|
2747
|
+
install_command: z14.string().max(1e3).optional().describe("Install shell command."),
|
|
2748
|
+
build_command: z14.string().max(1e3).optional().describe("Build shell command."),
|
|
2749
|
+
start_command: z14.string().max(1e3).optional().describe("Start shell command (required for web/private services without an image)."),
|
|
2750
|
+
cron_schedule: z14.string().max(100).optional().describe("Cron expression \u2014 required for cron_job."),
|
|
2751
|
+
publish_path: z14.string().max(500).optional().describe("Static-site output dir."),
|
|
2752
|
+
runtime: z14.string().max(50).optional().describe("Runtime hint (node/bun/python/\u2026)."),
|
|
2753
|
+
plan: z14.enum(SERVICE_PLANS).optional().describe('Service size (default "micro").'),
|
|
2754
|
+
environment_id: z14.union([z14.number().int().positive(), z14.string()]).optional().describe("Bind to a specific environment; defaults to Production."),
|
|
2755
|
+
auto_deploy: z14.boolean().optional().describe("Trigger the first deploy immediately (default true).")
|
|
2295
2756
|
},
|
|
2296
2757
|
handler: async (args, ctx) => {
|
|
2297
2758
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2354,18 +2815,18 @@ defineTool({
|
|
|
2354
2815
|
'Example: create_dev_environment({ project_id: "prj_abc", name: "scratch", hoststack_api_key: "hs_live_\u2026" })'
|
|
2355
2816
|
].join("\n"),
|
|
2356
2817
|
input: {
|
|
2357
|
-
project_id:
|
|
2358
|
-
name:
|
|
2359
|
-
plan:
|
|
2818
|
+
project_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Target project \u2014 numeric id or publicId."),
|
|
2819
|
+
name: z14.string().min(1).max(100).optional().describe('Service name (default "dev-environment").'),
|
|
2820
|
+
plan: z14.enum(SERVICE_PLANS).optional().describe(
|
|
2360
2821
|
'Box size (default "standard" \u2014 2 GB, the OOM-safe floor; a smaller plan is clamped up to "standard").'
|
|
2361
2822
|
),
|
|
2362
|
-
disk_gb:
|
|
2363
|
-
hoststack_api_key:
|
|
2364
|
-
poststack_api_key:
|
|
2365
|
-
repo_url:
|
|
2823
|
+
disk_gb: z14.number().int().min(1).max(100).optional().describe("/workspace volume size in GB (default 10)."),
|
|
2824
|
+
hoststack_api_key: z14.string().optional().describe("Value for HOSTSTACK_API_KEY (enables the hoststack MCP in-container)."),
|
|
2825
|
+
poststack_api_key: z14.string().optional().describe("Value for POSTSTACK_API_KEY (enables the poststack MCP in-container)."),
|
|
2826
|
+
repo_url: z14.string().max(500).optional().describe(
|
|
2366
2827
|
"Clone this git URL into /workspace on first boot (HTTPS, or SSH once a key is set)."
|
|
2367
2828
|
),
|
|
2368
|
-
branch:
|
|
2829
|
+
branch: z14.string().max(200).optional().describe("Branch to clone (with repo_url).")
|
|
2369
2830
|
},
|
|
2370
2831
|
handler: async (args, ctx) => {
|
|
2371
2832
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2476,9 +2937,9 @@ defineTool({
|
|
|
2476
2937
|
'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.'
|
|
2477
2938
|
].join("\n"),
|
|
2478
2939
|
input: {
|
|
2479
|
-
service_id:
|
|
2480
|
-
include_database_clone:
|
|
2481
|
-
name:
|
|
2940
|
+
service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("Source service to debug \u2014 numeric id or publicId."),
|
|
2941
|
+
include_database_clone: z14.boolean().optional().describe("Clone the linked database so the app runs on copied data (default true)."),
|
|
2942
|
+
name: z14.string().min(1).max(100).optional().describe('Dev box name (default "<source>-dev").')
|
|
2482
2943
|
},
|
|
2483
2944
|
handler: async (args, ctx) => {
|
|
2484
2945
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2519,7 +2980,7 @@ defineTool({
|
|
|
2519
2980
|
'Example: delete_dev_environment({ service_id: "svc_api_dev" }) \u2192 removes the dev box, its cloned database, and the /workspace volume.'
|
|
2520
2981
|
].join("\n"),
|
|
2521
2982
|
input: {
|
|
2522
|
-
service_id:
|
|
2983
|
+
service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The dev box to tear down \u2014 numeric id or publicId.")
|
|
2523
2984
|
},
|
|
2524
2985
|
handler: async (args, ctx) => {
|
|
2525
2986
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2555,8 +3016,8 @@ defineTool({
|
|
|
2555
3016
|
'Example: resize_dev_environment({ service_id: "svc_skyskraber_dev", size: "large" }) \u2192 bumps the box to the large tier, applied live.'
|
|
2556
3017
|
].join("\n"),
|
|
2557
3018
|
input: {
|
|
2558
|
-
service_id:
|
|
2559
|
-
size:
|
|
3019
|
+
service_id: z14.union([z14.number().int().positive(), z14.string()]).describe("The box to resize \u2014 numeric id or publicId."),
|
|
3020
|
+
size: z14.enum(SERVICE_PLANS).describe(
|
|
2560
3021
|
'Target size tier (service catalog size, e.g. "standard", "large", "xlarge").'
|
|
2561
3022
|
)
|
|
2562
3023
|
},
|
|
@@ -2616,7 +3077,7 @@ defineTool({
|
|
|
2616
3077
|
"",
|
|
2617
3078
|
'Returns: { templates: [{ id, name, image, volume: { name, mountPath, sizeGb }, minPlan, companions }] }. Today there is one preset ("dev-environment").',
|
|
2618
3079
|
"",
|
|
2619
|
-
'Example: list_templates() \u2192 { templates: [{ id: "dev-environment", image: "hoststack/dev-env:latest", volume: { mountPath: "/workspace", sizeGb: 10 }, minPlan: "standard", companions: ["postgres","redis","meilisearch"] }] }'
|
|
3080
|
+
'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"] }] }'
|
|
2620
3081
|
].join("\n"),
|
|
2621
3082
|
input: {},
|
|
2622
3083
|
handler: async () => {
|
|
@@ -2650,10 +3111,12 @@ defineTool({
|
|
|
2650
3111
|
"",
|
|
2651
3112
|
"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.",
|
|
2652
3113
|
"",
|
|
3114
|
+
'Every Dev Box \u2014 with or without companions \u2014 also ships NATIVE Postgres 17, Redis and Meilisearch INSIDE the box, started with `dev-services up` (postgres :5432 as user "dev" with trust auth, redis :6379, meilisearch :7700). That is the no-Docker replacement for `make db-up` and is the right answer for local development inside the box; there is no Docker daemon available in there. The `companions` option is different \u2014 it attaches SEPARATE managed databases to the box\'s environment for when the box needs a real, persistent, backed-up datastore.',
|
|
3115
|
+
"",
|
|
2653
3116
|
'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).',
|
|
2654
3117
|
"",
|
|
2655
3118
|
"Inputs:",
|
|
2656
|
-
|
|
3119
|
+
' - name (optional): the Dev Box name. Omit it and the box is named after its source (the repo name, or "dev-box" when blank), with a numeric suffix if that name is taken \u2014 so "give me a dev box" needs no invented name.',
|
|
2657
3120
|
' - source_kind: "github_repo" (clone a connected repo \u2014 needs github_repo_id), "url" (clone any http(s) git URL \u2014 needs clone_url), or "blank" (empty box).',
|
|
2658
3121
|
" - github_repo_id (for github_repo): numeric id of a connected GitHub repo.",
|
|
2659
3122
|
" - clone_url (for url): an http(s) git clone URL.",
|
|
@@ -2667,19 +3130,21 @@ defineTool({
|
|
|
2667
3130
|
'Example: create_standalone_dev_environment({ name: "app-dev", source_kind: "github_repo", github_repo_id: 42, databases: ["postgres","redis"] })'
|
|
2668
3131
|
].join("\n"),
|
|
2669
3132
|
input: {
|
|
2670
|
-
name:
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
3133
|
+
name: z14.string().min(1).max(100).optional().describe(
|
|
3134
|
+
'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.'
|
|
3135
|
+
),
|
|
3136
|
+
source_kind: z14.enum(["github_repo", "url", "blank"]).describe("Where the code comes from."),
|
|
3137
|
+
github_repo_id: z14.number().int().positive().optional().describe('Connected GitHub repo id (required when source_kind="github_repo").'),
|
|
3138
|
+
clone_url: z14.string().url().optional().describe('http(s) git clone URL (required when source_kind="url").'),
|
|
3139
|
+
branch: z14.string().min(1).max(255).optional().describe("Branch to clone."),
|
|
3140
|
+
databases: z14.array(z14.enum(["postgres", "redis", "meilisearch"])).optional().describe("Companion services to attach (fresh + empty)."),
|
|
3141
|
+
plan: z14.enum(SERVICE_PLANS).optional().describe(
|
|
2677
3142
|
'Box size (default "standard" \u2014 2 GB; a smaller plan is floored to "standard").'
|
|
2678
3143
|
),
|
|
2679
|
-
agent_accounts:
|
|
2680
|
-
|
|
2681
|
-
provider:
|
|
2682
|
-
account_id:
|
|
3144
|
+
agent_accounts: z14.array(
|
|
3145
|
+
z14.object({
|
|
3146
|
+
provider: z14.enum(["claude", "codex", "opencode"]),
|
|
3147
|
+
account_id: z14.number().int().positive()
|
|
2683
3148
|
})
|
|
2684
3149
|
).max(3).optional().describe(
|
|
2685
3150
|
"Bind saved agent logins by account id per provider. Omit to inherit the box owner's default logins automatically."
|
|
@@ -2716,7 +3181,7 @@ defineTool({
|
|
|
2716
3181
|
source = { kind: "blank" };
|
|
2717
3182
|
}
|
|
2718
3183
|
const input = {
|
|
2719
|
-
name: args.name,
|
|
3184
|
+
...args.name ? { name: args.name } : {},
|
|
2720
3185
|
source,
|
|
2721
3186
|
...args.databases ? { databases: args.databases } : {},
|
|
2722
3187
|
...args.plan ? { plan: args.plan } : {},
|
|
@@ -2749,12 +3214,12 @@ defineTool({
|
|
|
2749
3214
|
"Inputs:",
|
|
2750
3215
|
' - service_id: publicId of the service (e.g. "svc_abc123").',
|
|
2751
3216
|
"",
|
|
2752
|
-
|
|
3217
|
+
'Returns: { service: Service, config: ServiceConfig } \u2014 service has type/status/runtime/repoUrl/branch/autoDeploy/region/plan/timestamps; config has memoryMb, cpuShares, diskSizeGb, port, protocol, healthCheckEnabled, healthCheckInterval, healthCheckTimeout, healthCheckGracePeriodSec, restartPolicy, deployStrategy ("rolling" | "recreate"), preDeployCommand, min/maxInstances, scale thresholds.',
|
|
2753
3218
|
"",
|
|
2754
3219
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 }, config: { healthCheckGracePeriodSec: 120, \u2026 } }'
|
|
2755
3220
|
].join("\n"),
|
|
2756
3221
|
input: {
|
|
2757
|
-
service_id:
|
|
3222
|
+
service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
|
|
2758
3223
|
},
|
|
2759
3224
|
handler: async (args, ctx) => {
|
|
2760
3225
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2786,7 +3251,7 @@ defineTool({
|
|
|
2786
3251
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
2787
3252
|
].join("\n"),
|
|
2788
3253
|
input: {
|
|
2789
|
-
service_id:
|
|
3254
|
+
service_id: z14.string().describe("Service publicId.")
|
|
2790
3255
|
},
|
|
2791
3256
|
handler: async (args, ctx) => {
|
|
2792
3257
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2815,9 +3280,9 @@ defineTool({
|
|
|
2815
3280
|
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
|
|
2816
3281
|
].join("\n"),
|
|
2817
3282
|
input: {
|
|
2818
|
-
service_id:
|
|
2819
|
-
from:
|
|
2820
|
-
to:
|
|
3283
|
+
service_id: z14.string().describe("Service publicId."),
|
|
3284
|
+
from: z14.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
3285
|
+
to: z14.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
2821
3286
|
},
|
|
2822
3287
|
handler: async (args, ctx) => {
|
|
2823
3288
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2861,8 +3326,8 @@ defineTool({
|
|
|
2861
3326
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
2862
3327
|
].join("\n"),
|
|
2863
3328
|
input: {
|
|
2864
|
-
service_id:
|
|
2865
|
-
name:
|
|
3329
|
+
service_id: z14.string().describe("Service publicId."),
|
|
3330
|
+
name: z14.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
2866
3331
|
},
|
|
2867
3332
|
handler: async (args, ctx) => {
|
|
2868
3333
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -2899,6 +3364,7 @@ defineTool({
|
|
|
2899
3364
|
" - port (optional): integer 1\u201365535 \u2014 container port the platform forwards traffic to.",
|
|
2900
3365
|
' - protocol (optional): "http" | "tcp".',
|
|
2901
3366
|
' - restart_policy (optional): "always" | "on-failure" | "no".',
|
|
3367
|
+
` - deploy_strategy (optional): "rolling" (default) | "recreate". SET THIS TO "recreate" for any single-container service that holds an exclusive lock on a mounted volume \u2014 a database running inside the container, an embedded queue, anything with a lockfile or a fixed host port on shared storage. Under the default rolling strategy those services CANNOT deploy at all: the new container can't open the datadir the outgoing container still holds, so it exits; because it never goes healthy the old one is never stopped; because the old one is never stopped the lock is never released. The deploy deadlocks for the whole grace period and then reports "Health check timed out". "recreate" stops the old container first, at the cost of a brief outage.`,
|
|
2902
3368
|
" - pre_deploy_command (optional): shell command run before the new release accepts traffic (typical use: migrations).",
|
|
2903
3369
|
" - instance_count (optional): integer 1\u201350 \u2014 pin both min and max instances to this value.",
|
|
2904
3370
|
" - min_instances, max_instances (optional): integers \u2014 autoscale bounds. Use instead of instance_count when you want a range.",
|
|
@@ -2910,37 +3376,40 @@ defineTool({
|
|
|
2910
3376
|
'Example: update_service_config({ service_id: "svc_abc", health_check_grace_period_sec: 180 }) \u2192 { config: { healthCheckGracePeriodSec: 180, \u2026 } }'
|
|
2911
3377
|
].join("\n"),
|
|
2912
3378
|
input: {
|
|
2913
|
-
service_id:
|
|
2914
|
-
install_command:
|
|
2915
|
-
build_command:
|
|
2916
|
-
start_command:
|
|
2917
|
-
branch:
|
|
2918
|
-
root_directory:
|
|
2919
|
-
dockerfile_path:
|
|
2920
|
-
auto_deploy:
|
|
2921
|
-
health_check_path:
|
|
2922
|
-
health_check_enabled:
|
|
2923
|
-
health_check_interval:
|
|
2924
|
-
health_check_timeout:
|
|
2925
|
-
health_check_grace_period_sec:
|
|
3379
|
+
service_id: z14.string().describe("Service publicId."),
|
|
3380
|
+
install_command: z14.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
3381
|
+
build_command: z14.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
3382
|
+
start_command: z14.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
3383
|
+
branch: z14.string().optional().describe("Git branch to track."),
|
|
3384
|
+
root_directory: z14.string().optional().describe("Build context root."),
|
|
3385
|
+
dockerfile_path: z14.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
3386
|
+
auto_deploy: z14.boolean().optional().describe("Auto-deploy on push."),
|
|
3387
|
+
health_check_path: z14.string().nullable().optional().describe('HTTP health-check path (e.g. "/health"). Null = TCP-only check.'),
|
|
3388
|
+
health_check_enabled: z14.boolean().optional().describe("Toggle health checking on/off."),
|
|
3389
|
+
health_check_interval: z14.number().int().min(5).max(300).optional().describe("How often the check runs, in seconds (5\u2013300)."),
|
|
3390
|
+
health_check_timeout: z14.number().int().min(1).max(60).optional().describe("Single-attempt timeout in seconds (1\u201360)."),
|
|
3391
|
+
health_check_grace_period_sec: z14.number().int().min(1).max(1800).optional().describe(
|
|
2926
3392
|
"Startup grace period in seconds (1\u20131800). Raise this if the app needs more time to boot before health checks start counting failures."
|
|
2927
3393
|
),
|
|
2928
|
-
memory_mb:
|
|
2929
|
-
cpu_shares:
|
|
2930
|
-
disk_size_gb:
|
|
2931
|
-
port:
|
|
2932
|
-
protocol:
|
|
2933
|
-
restart_policy:
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
3394
|
+
memory_mb: z14.number().int().min(128).max(16384).optional().describe("Container memory cap in MB (128\u201316384)."),
|
|
3395
|
+
cpu_shares: z14.number().int().min(128).max(4096).optional().describe("Relative CPU weight (128\u20134096)."),
|
|
3396
|
+
disk_size_gb: z14.number().int().min(1).max(100).optional().describe("Ephemeral disk size in GB (1\u2013100)."),
|
|
3397
|
+
port: z14.number().int().min(1).max(65535).optional().describe("Container port the platform forwards traffic to."),
|
|
3398
|
+
protocol: z14.enum(["http", "tcp"]).optional().describe("Traffic protocol."),
|
|
3399
|
+
restart_policy: z14.enum(["always", "on-failure", "no"]).optional().describe("Docker restart policy."),
|
|
3400
|
+
deploy_strategy: z14.enum(["rolling", "recreate"]).optional().describe(
|
|
3401
|
+
'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.'
|
|
3402
|
+
),
|
|
3403
|
+
pre_deploy_command: z14.string().optional().describe("Shell command run before the new release accepts traffic."),
|
|
3404
|
+
instance_count: z14.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
3405
|
+
min_instances: z14.number().int().min(0).max(50).optional().describe("Autoscale lower bound. Use with max_instances for a range."),
|
|
3406
|
+
max_instances: z14.number().int().min(1).max(50).optional().describe("Autoscale upper bound. Use with min_instances for a range."),
|
|
3407
|
+
scale_cpu_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale CPU trigger percentage (10\u2013100)."),
|
|
3408
|
+
scale_memory_threshold: z14.number().int().min(10).max(100).optional().describe("Autoscale memory trigger percentage (10\u2013100)."),
|
|
3409
|
+
log_filter_rules: z14.array(
|
|
3410
|
+
z14.object({
|
|
3411
|
+
pattern: z14.string().min(1).max(200),
|
|
3412
|
+
action: z14.enum(["drop", "downgrade"])
|
|
2944
3413
|
})
|
|
2945
3414
|
).max(50).optional().describe(
|
|
2946
3415
|
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
@@ -2975,6 +3444,8 @@ defineTool({
|
|
|
2975
3444
|
if (args.port !== void 0) configUpdate["port"] = args.port;
|
|
2976
3445
|
if (args.protocol !== void 0) configUpdate["protocol"] = args.protocol;
|
|
2977
3446
|
if (args.restart_policy !== void 0) configUpdate["restartPolicy"] = args.restart_policy;
|
|
3447
|
+
if (args.deploy_strategy !== void 0)
|
|
3448
|
+
configUpdate["deployStrategy"] = args.deploy_strategy;
|
|
2978
3449
|
if (args.pre_deploy_command !== void 0)
|
|
2979
3450
|
configUpdate["preDeployCommand"] = args.pre_deploy_command;
|
|
2980
3451
|
if (args.instance_count !== void 0) {
|
|
@@ -3035,7 +3506,7 @@ defineTool({
|
|
|
3035
3506
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3036
3507
|
].join("\n"),
|
|
3037
3508
|
input: {
|
|
3038
|
-
service_id:
|
|
3509
|
+
service_id: z14.string().describe("Service publicId.")
|
|
3039
3510
|
},
|
|
3040
3511
|
handler: async (args, ctx) => {
|
|
3041
3512
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3059,7 +3530,7 @@ defineTool({
|
|
|
3059
3530
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
3060
3531
|
].join("\n"),
|
|
3061
3532
|
input: {
|
|
3062
|
-
service_id:
|
|
3533
|
+
service_id: z14.string().describe("Service publicId.")
|
|
3063
3534
|
},
|
|
3064
3535
|
handler: async (args, ctx) => {
|
|
3065
3536
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3067,6 +3538,35 @@ defineTool({
|
|
|
3067
3538
|
return respond({ summary: `Resumed service ${args.service_id}.`, data: { ok: true } });
|
|
3068
3539
|
}
|
|
3069
3540
|
});
|
|
3541
|
+
defineTool({
|
|
3542
|
+
name: "delete_service",
|
|
3543
|
+
category: "services",
|
|
3544
|
+
description: [
|
|
3545
|
+
"Permanently delete a service: stops and removes its containers, releases its hostname and routes, and cascade-deletes its attached volumes, env vars, domains and deploy history. Irreversible.",
|
|
3546
|
+
"",
|
|
3547
|
+
"When to use: the user explicitly asks to delete or remove a service, or you are cleaning up a service that was created by mistake or is no longer needed. Without this, a broken or abandoned service could only be removed from the dashboard \u2014 so a service that failed to come up had to be left in place and worked around by creating another one.",
|
|
3548
|
+
"",
|
|
3549
|
+
"ALWAYS confirm with the user before calling this on anything that has served traffic. To stop a service temporarily without losing it, use suspend_service instead. To tear down an agentic Dev Box (which also cascades its cloned database), use delete_dev_environment.",
|
|
3550
|
+
"",
|
|
3551
|
+
"Inputs:",
|
|
3552
|
+
" - service_id: publicId of the service to delete.",
|
|
3553
|
+
"",
|
|
3554
|
+
"Returns: { ok: true }.",
|
|
3555
|
+
"",
|
|
3556
|
+
'Example: delete_service({ service_id: "svc_abandoned" }) \u2192 { ok: true }'
|
|
3557
|
+
].join("\n"),
|
|
3558
|
+
input: {
|
|
3559
|
+
service_id: z14.string().describe("Service publicId.")
|
|
3560
|
+
},
|
|
3561
|
+
handler: async (args, ctx) => {
|
|
3562
|
+
const teamId = await ctx.resolveTeamId();
|
|
3563
|
+
await ctx.hoststack.services.delete(teamId, args.service_id);
|
|
3564
|
+
return respond({
|
|
3565
|
+
summary: `Deleted service ${args.service_id} and its attached resources. This cannot be undone.`,
|
|
3566
|
+
data: { ok: true }
|
|
3567
|
+
});
|
|
3568
|
+
}
|
|
3569
|
+
});
|
|
3070
3570
|
defineTool({
|
|
3071
3571
|
name: "get_service_logs",
|
|
3072
3572
|
category: "logs",
|
|
@@ -3093,16 +3593,16 @@ defineTool({
|
|
|
3093
3593
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
3094
3594
|
].join("\n"),
|
|
3095
3595
|
input: {
|
|
3096
|
-
service_id:
|
|
3097
|
-
lines:
|
|
3098
|
-
since:
|
|
3099
|
-
until:
|
|
3100
|
-
stream:
|
|
3101
|
-
level:
|
|
3596
|
+
service_id: z14.string().describe("Service publicId."),
|
|
3597
|
+
lines: z14.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
3598
|
+
since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
3599
|
+
until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
3600
|
+
stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
3601
|
+
level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
3102
3602
|
"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)."
|
|
3103
3603
|
),
|
|
3104
|
-
search:
|
|
3105
|
-
count_only:
|
|
3604
|
+
search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
3605
|
+
count_only: z14.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
3106
3606
|
},
|
|
3107
3607
|
handler: async (args, ctx) => {
|
|
3108
3608
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3151,14 +3651,14 @@ defineTool({
|
|
|
3151
3651
|
'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 } } }.'
|
|
3152
3652
|
].join("\n"),
|
|
3153
3653
|
input: {
|
|
3154
|
-
service_ids:
|
|
3155
|
-
lines_per_service:
|
|
3156
|
-
since:
|
|
3157
|
-
until:
|
|
3158
|
-
stream:
|
|
3159
|
-
level:
|
|
3160
|
-
search:
|
|
3161
|
-
count_only:
|
|
3654
|
+
service_ids: z14.array(z14.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
3655
|
+
lines_per_service: z14.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
3656
|
+
since: z14.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
3657
|
+
until: z14.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
3658
|
+
stream: z14.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
3659
|
+
level: z14.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
3660
|
+
search: z14.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
3661
|
+
count_only: z14.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
3162
3662
|
},
|
|
3163
3663
|
handler: async (args, ctx) => {
|
|
3164
3664
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3204,7 +3704,8 @@ defineTool({
|
|
|
3204
3704
|
});
|
|
3205
3705
|
|
|
3206
3706
|
// src/tools/volumes.ts
|
|
3207
|
-
import { z as
|
|
3707
|
+
import { z as z15 } from "zod";
|
|
3708
|
+
var MIN_VOLUME_SIZE_GB = 10;
|
|
3208
3709
|
defineTool({
|
|
3209
3710
|
name: "list_volumes",
|
|
3210
3711
|
category: "volumes",
|
|
@@ -3221,7 +3722,7 @@ defineTool({
|
|
|
3221
3722
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
3222
3723
|
].join("\n"),
|
|
3223
3724
|
input: {
|
|
3224
|
-
service_id:
|
|
3725
|
+
service_id: z15.string().describe("Service publicId (e.g. svc_abc123).")
|
|
3225
3726
|
},
|
|
3226
3727
|
handler: async (args, ctx) => {
|
|
3227
3728
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3243,17 +3744,24 @@ defineTool({
|
|
|
3243
3744
|
" - service_id: publicId of the service to attach to.",
|
|
3244
3745
|
" - name: lowercase alphanumeric + hyphens, \u226464 chars (used as the docker volume identifier \u2014 change with care once data is written).",
|
|
3245
3746
|
' - mount_path: in-container absolute path (e.g. "/var/data").',
|
|
3246
|
-
" - size_gb: optional,
|
|
3747
|
+
" - size_gb: optional, 10\u2013100, default 10. 10 GB is the platform minimum (the underlying block storage cannot provision anything smaller) \u2014 a smaller value is rejected here rather than failing the next deploy. Metered for billing.",
|
|
3247
3748
|
"",
|
|
3248
|
-
|
|
3749
|
+
'Returns: { volume: Volume } \u2014 the created record. A durable volume comes back `status: "pending"`; the disk is created and attached on the next deploy, which flips it to "active". A deploy is required before the mount exists.',
|
|
3249
3750
|
"",
|
|
3250
|
-
'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: "
|
|
3751
|
+
'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" } }'
|
|
3251
3752
|
].join("\n"),
|
|
3252
3753
|
input: {
|
|
3253
|
-
service_id:
|
|
3254
|
-
name:
|
|
3255
|
-
mount_path:
|
|
3256
|
-
|
|
3754
|
+
service_id: z15.string().describe("Service publicId."),
|
|
3755
|
+
name: z15.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
3756
|
+
mount_path: z15.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
3757
|
+
// 10 GB is the real floor: the block-storage backend rejects anything
|
|
3758
|
+
// smaller. Advertising 1 GB here (and defaulting to it) meant taking the
|
|
3759
|
+
// defaults produced a volume that provisioned with `Hetzner API error:
|
|
3760
|
+
// 422` on the NEXT deploy, with nothing tying the failure back to the
|
|
3761
|
+
// size. Reject it at the call instead.
|
|
3762
|
+
size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(
|
|
3763
|
+
`Disk size in GB (minimum ${MIN_VOLUME_SIZE_GB}, default ${MIN_VOLUME_SIZE_GB}, max 100 via MCP).`
|
|
3764
|
+
)
|
|
3257
3765
|
},
|
|
3258
3766
|
handler: async (args, ctx) => {
|
|
3259
3767
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3265,7 +3773,7 @@ defineTool({
|
|
|
3265
3773
|
const response = await ctx.hoststack.volumes.create(teamId, args.service_id, input);
|
|
3266
3774
|
const data = { volume: shape(response.volume) };
|
|
3267
3775
|
return respond({
|
|
3268
|
-
summary: `Attached volume "${args.name}" (${
|
|
3776
|
+
summary: `Attached volume "${args.name}" (${response.volume.sizeGb}GB) at ${args.mount_path} on service ${args.service_id}. Status is "${response.volume.status}" \u2014 deploy the service to provision and mount it.`,
|
|
3269
3777
|
data
|
|
3270
3778
|
});
|
|
3271
3779
|
}
|
|
@@ -3289,10 +3797,10 @@ defineTool({
|
|
|
3289
3797
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
3290
3798
|
].join("\n"),
|
|
3291
3799
|
input: {
|
|
3292
|
-
service_id:
|
|
3293
|
-
volume_id:
|
|
3294
|
-
mount_path:
|
|
3295
|
-
size_gb:
|
|
3800
|
+
service_id: z15.string().describe("Service publicId."),
|
|
3801
|
+
volume_id: z15.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
3802
|
+
mount_path: z15.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
3803
|
+
size_gb: z15.number().int().min(MIN_VOLUME_SIZE_GB).max(100).optional().describe(`New size in GB (minimum ${MIN_VOLUME_SIZE_GB}, grow-only).`)
|
|
3296
3804
|
},
|
|
3297
3805
|
handler: async (args, ctx) => {
|
|
3298
3806
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -3330,8 +3838,8 @@ defineTool({
|
|
|
3330
3838
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
3331
3839
|
].join("\n"),
|
|
3332
3840
|
input: {
|
|
3333
|
-
service_id:
|
|
3334
|
-
volume_id:
|
|
3841
|
+
service_id: z15.string().describe("Service publicId."),
|
|
3842
|
+
volume_id: z15.string().describe("Volume publicId.")
|
|
3335
3843
|
},
|
|
3336
3844
|
handler: async (args, ctx) => {
|
|
3337
3845
|
const teamId = await ctx.resolveTeamId();
|