@hoststack.dev/mcp 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -41,6 +41,14 @@ var ApiClient = class {
41
41
  });
42
42
  return this.handle(res);
43
43
  }
44
+ async put(path, body) {
45
+ const res = await fetch(`${this.baseUrl}${path}`, {
46
+ method: "PUT",
47
+ headers: this.headers,
48
+ body: JSON.stringify(body)
49
+ });
50
+ return this.handle(res);
51
+ }
44
52
  async delete(path) {
45
53
  const res = await fetch(`${this.baseUrl}${path}`, {
46
54
  method: "DELETE",
@@ -419,8 +427,9 @@ defineTool({
419
427
  ' - action: filter to a specific action (e.g. "service.created", "deploy.triggered").',
420
428
  ' - resource_type: filter by resource type (e.g. "service", "deploy", "domain").',
421
429
  " - user_id: filter by acting user numeric ID.",
430
+ ' - since/until: ISO-8601 timestamp OR a relative offset like "-15m" / "-2h" / "-7d" \u2014 narrow to a time window (handy for correlating a deploy with a log spike).',
422
431
  "",
423
- "Returns: { items: ActivityLogEntry[], meta? } \u2014 each entry has id, action, resourceType, resourceId, actorEmail, ipAddress, createdAt, and a context payload.",
432
+ "Returns: { items: ActivityLogEntry[], meta: { page, perPage, total, totalPages } } \u2014 each entry has id, action, resourceType, resourceId, actorEmail, ipAddress, createdAt, and a context payload. Use meta.totalPages to tell whether you have more.",
424
433
  "",
425
434
  'Example: list_activity_log({ resource_type: "deploy", per_page: 10 }) \u2192 { items: [{ action: "deploy.triggered", actorEmail: "ada@\u2026", \u2026 }], meta: { total: 47, page: 1 } }'
426
435
  ].join("\n"),
@@ -429,7 +438,9 @@ defineTool({
429
438
  per_page: z2.number().int().positive().max(100).optional().describe("Items per page, hard cap 100."),
430
439
  action: z2.string().optional().describe('Action filter, e.g. "service.created".'),
431
440
  resource_type: z2.string().optional().describe("Resource type filter."),
432
- user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter.")
441
+ user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter."),
442
+ since: z2.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-15m", "-2h", "-7d").'),
443
+ until: z2.string().optional().describe("ISO-8601 timestamp or relative offset upper bound.")
433
444
  },
434
445
  handler: async (args, ctx) => {
435
446
  const teamId = await ctx.resolveTeamId();
@@ -439,13 +450,24 @@ defineTool({
439
450
  if (args.action !== void 0) params["action"] = args.action;
440
451
  if (args.resource_type !== void 0) params["resourceType"] = args.resource_type;
441
452
  if (args.user_id !== void 0) params["userId"] = String(args.user_id);
453
+ if (args.since !== void 0) params["since"] = args.since;
454
+ if (args.until !== void 0) params["until"] = args.until;
442
455
  const response = await ctx.api.get(
443
456
  `/api/activity-log/${teamId}`,
444
457
  params
445
458
  );
446
459
  const items = Array.isArray(response.data) ? response.data.map(shapeActivity) : [];
447
460
  const data = { items };
448
- if (response.meta !== void 0) data.meta = shape(response.meta);
461
+ if (response.meta !== void 0) {
462
+ data.meta = shape(response.meta);
463
+ } else if (response.page !== void 0 || response.perPage !== void 0 || response.total !== void 0 || response.totalPages !== void 0) {
464
+ data.meta = shape({
465
+ page: response.page,
466
+ perPage: response.perPage,
467
+ total: response.total,
468
+ totalPages: response.totalPages
469
+ });
470
+ }
449
471
  return respond({
450
472
  summary: items.length === 0 ? "No activity log entries match the given filters." : `Returned ${items.length} activity log entr${items.length === 1 ? "y" : "ies"}.`,
451
473
  data
@@ -453,8 +475,51 @@ defineTool({
453
475
  }
454
476
  });
455
477
 
456
- // src/tools/cron.ts
478
+ // src/tools/alerts.ts
457
479
  import { z as z3 } from "zod";
480
+ defineTool({
481
+ name: "list_alerts",
482
+ category: "alerts",
483
+ description: [
484
+ "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.",
485
+ "",
486
+ "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.",
487
+ "",
488
+ '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.',
489
+ "",
490
+ "Inputs (all optional):",
491
+ ' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h.',
492
+ " - until: ISO-8601 upper bound (ignored when aggregating \u2014 aggregated view always extends to now).",
493
+ " - limit: max rows (default 100, hard cap 500).",
494
+ " - aggregate: true (default) collapses by (action, resourceId); false returns raw rows.",
495
+ "",
496
+ "Returns: { alerts: Array, aggregated: boolean }. Each aggregated entry includes action, resourceType, resourceId, severity, count, firstFiredAt, lastFiredAt, lastMetadata. Each raw entry includes id, action, resourceType, resourceId, severity, metadata, createdAt.",
497
+ "",
498
+ "Example: list_alerts({ since: '-1h' }) \u2192 { alerts: [{ action: 'deploy.failed', resourceId: 31, severity: 'error', count: 3, lastFiredAt: '\u2026', \u2026 }] }."
499
+ ].join("\n"),
500
+ input: {
501
+ since: z3.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-1h", "-2d"). Default: -24h.'),
502
+ until: z3.string().optional().describe("ISO-8601 upper bound. Only honored when aggregate=false."),
503
+ limit: z3.number().int().positive().max(500).optional().describe("Max rows (default 100, hard cap 500)."),
504
+ aggregate: z3.boolean().optional().describe("Collapse by (action, resourceId). Default true.")
505
+ },
506
+ handler: async (args, ctx) => {
507
+ const teamId = await ctx.resolveTeamId();
508
+ const params = {};
509
+ if (args.since !== void 0) params["since"] = args.since;
510
+ if (args.until !== void 0) params["until"] = args.until;
511
+ if (args.limit !== void 0) params["limit"] = String(args.limit);
512
+ if (args.aggregate === false) params["aggregate"] = "0";
513
+ const response = await ctx.api.get(`/api/alerts/${teamId}`, params);
514
+ const items = Array.isArray(response.alerts) ? response.alerts.map(shape) : [];
515
+ const aggregated = Boolean(response.aggregated);
516
+ const summary = items.length === 0 ? "No alerts in the requested window \u2014 everything is operating normally." : aggregated ? `Returned ${items.length} alert group${items.length === 1 ? "" : "s"} (flapping events collapsed).` : `Returned ${items.length} raw alert event${items.length === 1 ? "" : "s"}.`;
517
+ return respond({ summary, data: { alerts: items, aggregated } });
518
+ }
519
+ });
520
+
521
+ // src/tools/cron.ts
522
+ import { z as z4 } from "zod";
458
523
  defineTool({
459
524
  name: "list_cron_executions",
460
525
  category: "cron",
@@ -472,8 +537,8 @@ defineTool({
472
537
  'Example: list_cron_executions({ service_id: "svc_cron" }) \u2192 { items: [{ status: "succeeded", exitCode: 0, \u2026 }, \u2026] }'
473
538
  ].join("\n"),
474
539
  input: {
475
- service_id: z3.string().describe("Cron service publicId."),
476
- limit: z3.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
540
+ service_id: z4.string().describe("Cron service publicId."),
541
+ limit: z4.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
477
542
  },
478
543
  handler: async (args, ctx) => {
479
544
  const teamId = await ctx.resolveTeamId();
@@ -501,8 +566,8 @@ defineTool({
501
566
  'Example: get_cron_execution({ service_id: "svc_cron", execution_id: "exe_xyz" }) \u2192 { execution: { status: "failed", exitCode: 1, \u2026 } }'
502
567
  ].join("\n"),
503
568
  input: {
504
- service_id: z3.string().describe("Cron service publicId."),
505
- execution_id: z3.string().describe("Execution publicId.")
569
+ service_id: z4.string().describe("Cron service publicId."),
570
+ execution_id: z4.string().describe("Execution publicId.")
506
571
  },
507
572
  handler: async (args, ctx) => {
508
573
  const teamId = await ctx.resolveTeamId();
@@ -514,7 +579,7 @@ defineTool({
514
579
  });
515
580
 
516
581
  // src/tools/databases.ts
517
- import { z as z4 } from "zod";
582
+ import { z as z5 } from "zod";
518
583
  defineTool({
519
584
  name: "list_databases",
520
585
  category: "databases",
@@ -531,7 +596,7 @@ defineTool({
531
596
  'Example: list_databases({ project_id: 12 }) \u2192 { items: [{ publicId: "db_\u2026", type: "postgres", status: "running", \u2026 }] }'
532
597
  ].join("\n"),
533
598
  input: {
534
- project_id: z4.number().int().positive().describe("Numeric project ID (from list_projects).")
599
+ project_id: z5.number().int().positive().describe("Numeric project ID (from list_projects).")
535
600
  },
536
601
  handler: async (args, ctx) => {
537
602
  const teamId = await ctx.resolveTeamId();
@@ -541,6 +606,45 @@ defineTool({
541
606
  return respond({ summary, data });
542
607
  }
543
608
  });
609
+ defineTool({
610
+ name: "update_database",
611
+ category: "databases",
612
+ description: [
613
+ "Update a managed database \u2014 rename, change plan tier (memory/CPU), or grow disk size.",
614
+ "",
615
+ "When to use: the user wants to resize a database (disk or plan), or rename it. Disk can only grow \u2014 shrinking is rejected because the underlying filesystem would orphan data.",
616
+ "",
617
+ "Inputs:",
618
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
619
+ " - name (optional): new database name.",
620
+ ' - plan (optional): "free" | "starter" | "standard" | "pro" \u2014 changes memory tier.',
621
+ " - disk_size_gb (optional): new disk size in GB (must be \u2265 current).",
622
+ "",
623
+ "Returns: { database: Database } \u2014 the updated record.",
624
+ "",
625
+ 'Example: update_database({ database_id: "db_xyz", disk_size_gb: 50 }) \u2192 { database: { diskSizeGb: 50, \u2026 } }'
626
+ ].join("\n"),
627
+ input: {
628
+ database_id: z5.string().describe("Database publicId (e.g. db_xyz)."),
629
+ name: z5.string().min(1).max(100).optional().describe("New database name."),
630
+ plan: z5.enum(["free", "starter", "standard", "pro"]).optional().describe("Plan tier (memory/CPU)."),
631
+ disk_size_gb: z5.number().int().min(1).max(1024).optional().describe("New disk size in GB. Must be \u2265 current.")
632
+ },
633
+ handler: async (args, ctx) => {
634
+ const teamId = await ctx.resolveTeamId();
635
+ const input = {};
636
+ if (args.name !== void 0) input.name = args.name;
637
+ if (args.plan !== void 0) input.plan = args.plan;
638
+ if (args.disk_size_gb !== void 0) input.diskSizeGb = args.disk_size_gb;
639
+ if (Object.keys(input).length === 0) {
640
+ return respond({ summary: "No fields to update.", data: {} });
641
+ }
642
+ const response = await ctx.hoststack.databases.update(teamId, args.database_id, input);
643
+ const data = { database: shapeDatabase(response.database) };
644
+ const fields = Object.keys(input).join(", ");
645
+ return respond({ summary: `Updated ${fields} on database ${args.database_id}.`, data });
646
+ }
647
+ });
544
648
  defineTool({
545
649
  name: "get_database",
546
650
  category: "databases",
@@ -557,7 +661,7 @@ defineTool({
557
661
  'Example: get_database({ database_id: "db_xyz" }) \u2192 { database: { type: "postgres", version: "16", status: "running", \u2026 } }'
558
662
  ].join("\n"),
559
663
  input: {
560
- database_id: z4.string().describe("Database publicId (e.g. db_xyz).")
664
+ database_id: z5.string().describe("Database publicId (e.g. db_xyz).")
561
665
  },
562
666
  handler: async (args, ctx) => {
563
667
  const teamId = await ctx.resolveTeamId();
@@ -570,7 +674,7 @@ defineTool({
570
674
  });
571
675
 
572
676
  // src/tools/deploys.ts
573
- import { z as z5 } from "zod";
677
+ import { z as z6 } from "zod";
574
678
  defineTool({
575
679
  name: "list_deploys",
576
680
  category: "deploys",
@@ -582,12 +686,12 @@ defineTool({
582
686
  "Inputs:",
583
687
  ' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
584
688
  "",
585
- "Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt, durationMs.",
689
+ "Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt, buildDurationMs (just the docker build / image-pull step), totalDurationMs (full deploy wall-clock = finishedAt \u2212 startedAt).",
586
690
  "",
587
691
  'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
588
692
  ].join("\n"),
589
693
  input: {
590
- service_id: z5.string().describe("Service publicId (e.g. svc_abc123).")
694
+ service_id: z6.string().describe("Service publicId (e.g. svc_abc123).")
591
695
  },
592
696
  handler: async (args, ctx) => {
593
697
  const teamId = await ctx.resolveTeamId();
@@ -609,13 +713,13 @@ defineTool({
609
713
  " - service_id: publicId of the service.",
610
714
  ' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
611
715
  "",
612
- "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, durationMs, finishedAt, etc).",
716
+ "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, buildDurationMs, totalDurationMs, finishedAt, etc).",
613
717
  "",
614
718
  'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
615
719
  ].join("\n"),
616
720
  input: {
617
- service_id: z5.string().describe("Service publicId."),
618
- deploy_id: z5.string().describe("Deploy publicId.")
721
+ service_id: z6.string().describe("Service publicId."),
722
+ deploy_id: z6.string().describe("Deploy publicId.")
619
723
  },
620
724
  handler: async (args, ctx) => {
621
725
  const teamId = await ctx.resolveTeamId();
@@ -642,8 +746,8 @@ defineTool({
642
746
  'Example: trigger_deploy({ service_id: "svc_abc" }) \u2192 { deploy: { publicId: "dpl_\u2026", status: "pending", \u2026 } }'
643
747
  ].join("\n"),
644
748
  input: {
645
- service_id: z5.string().describe("Service publicId."),
646
- clear_cache: z5.boolean().optional().describe("Clear the build cache (default false).")
749
+ service_id: z6.string().describe("Service publicId."),
750
+ clear_cache: z6.boolean().optional().describe("Clear the build cache (default false).")
647
751
  },
648
752
  handler: async (args, ctx) => {
649
753
  const teamId = await ctx.resolveTeamId();
@@ -675,8 +779,8 @@ defineTool({
675
779
  'Example: cancel_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { ok: true }'
676
780
  ].join("\n"),
677
781
  input: {
678
- service_id: z5.string().describe("Service publicId."),
679
- deploy_id: z5.string().describe("Deploy publicId.")
782
+ service_id: z6.string().describe("Service publicId."),
783
+ deploy_id: z6.string().describe("Deploy publicId.")
680
784
  },
681
785
  handler: async (args, ctx) => {
682
786
  const teamId = await ctx.resolveTeamId();
@@ -704,10 +808,10 @@ defineTool({
704
808
  '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 }'
705
809
  ].join("\n"),
706
810
  input: {
707
- service_id: z5.string().describe("Service publicId."),
708
- deploy_id: z5.string().describe("Deploy publicId."),
709
- after_id: z5.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
710
- limit: z5.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
811
+ service_id: z6.string().describe("Service publicId."),
812
+ deploy_id: z6.string().describe("Deploy publicId."),
813
+ after_id: z6.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
814
+ limit: z6.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
711
815
  },
712
816
  handler: async (args, ctx) => {
713
817
  const teamId = await ctx.resolveTeamId();
@@ -728,9 +832,379 @@ defineTool({
728
832
  });
729
833
  }
730
834
  });
835
+ defineTool({
836
+ name: "diagnose_deploy",
837
+ category: "deploys",
838
+ description: [
839
+ "One-shot diagnostic bundle for a deploy: full deploy record + tail of build logs + tail of runtime logs starting at the deploy's start time. Lets you investigate any deploy failure mode (build-time failure, health-check timeout, post-deploy runtime crash) without juggling two log endpoints and two IDs.",
840
+ "",
841
+ "When to use: a deploy is failed/superseded/stuck and you want the whole story in one call. For just the build output, use get_deploy_logs. For just runtime logs of the running service, use get_service_logs.",
842
+ "",
843
+ "Inputs:",
844
+ " - service_id: publicId of the service.",
845
+ " - deploy_id: publicId of the deploy to diagnose.",
846
+ " - build_log_lines (optional): how many trailing build log lines to include. Default 200, max 2000.",
847
+ " - runtime_log_lines (optional): how many trailing runtime log lines (since the deploy started) to include. Default 100, max 1000. Set to 0 to skip runtime logs (saves a round-trip when investigating a pure build-time failure).",
848
+ "",
849
+ "Returns: { deploy, build: { logs, lineCount }, runtime: { logs, lineCount } } \u2014 runtime is omitted when runtime_log_lines is 0 or the deploy never started a container.",
850
+ "",
851
+ 'Example: diagnose_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 all three sections in one call.'
852
+ ].join("\n"),
853
+ input: {
854
+ service_id: z6.string().describe("Service publicId."),
855
+ deploy_id: z6.string().describe("Deploy publicId."),
856
+ build_log_lines: z6.number().int().min(1).max(2e3).optional().describe("Trailing build log lines. Default 200, max 2000."),
857
+ runtime_log_lines: z6.number().int().min(0).max(1e3).optional().describe(
858
+ "Trailing runtime log lines since deploy start. Default 100, max 1000. 0 = skip."
859
+ )
860
+ },
861
+ handler: async (args, ctx) => {
862
+ const teamId = await ctx.resolveTeamId();
863
+ const buildLimit = args.build_log_lines ?? 200;
864
+ const runtimeLimit = args.runtime_log_lines ?? 100;
865
+ const [deployResp, buildResp] = await Promise.all([
866
+ ctx.hoststack.deploys.get(teamId, args.service_id, args.deploy_id),
867
+ ctx.hoststack.deploys.getLogs(teamId, args.service_id, args.deploy_id, {
868
+ limit: buildLimit
869
+ })
870
+ ]);
871
+ const buildEntries = buildResp.logs;
872
+ const buildLogs = buildEntries.map((e) => e.message).join("\n");
873
+ const deployShape = shapeDeploy(deployResp.deploy);
874
+ const deployStatus = deployShape && "status" in deployShape ? deployShape.status : "unknown";
875
+ const startedAtRaw = deployShape && "startedAt" in deployShape ? deployShape.startedAt : void 0;
876
+ const startedAt = typeof startedAtRaw === "string" ? startedAtRaw : void 0;
877
+ const data = {
878
+ deploy: deployShape,
879
+ build: { logs: buildLogs, lineCount: buildEntries.length }
880
+ };
881
+ const hadContainer = deployStatus !== "pending" && deployStatus !== "building";
882
+ if (runtimeLimit > 0 && hadContainer) {
883
+ const runtimeResp = await ctx.hoststack.services.getRuntimeLogs(
884
+ teamId,
885
+ args.service_id,
886
+ {
887
+ lines: runtimeLimit,
888
+ ...startedAt ? { since: startedAt } : {}
889
+ }
890
+ );
891
+ if ("count" in runtimeResp) {
892
+ data["runtime"] = { logs: "", lineCount: runtimeResp.count };
893
+ } else {
894
+ const runtimeEntries = Array.isArray(runtimeResp.logs) ? runtimeResp.logs : typeof runtimeResp.logs === "string" ? runtimeResp.logs.split("\n").map((line) => ({ message: line })) : [];
895
+ const runtimeText = runtimeEntries.map(
896
+ (e) => typeof e === "object" && "message" in e ? String(e.message) : String(e)
897
+ ).join("\n");
898
+ data["runtime"] = {
899
+ logs: runtimeText,
900
+ lineCount: runtimeEntries.length,
901
+ sinceWindow: startedAt ?? null
902
+ };
903
+ }
904
+ }
905
+ const failureSummary = shape({
906
+ status: deployStatus,
907
+ errorMessage: deployShape && "errorMessage" in deployShape ? deployShape.errorMessage : void 0
908
+ });
909
+ data["summary"] = failureSummary;
910
+ return respond({
911
+ summary: `Diagnostic bundle for deploy ${args.deploy_id} (${deployStatus}): ${buildEntries.length} build log line${buildEntries.length === 1 ? "" : "s"}${runtimeLimit > 0 && hadContainer ? ", runtime log tail included" : ""}.`,
912
+ data
913
+ });
914
+ }
915
+ });
916
+
917
+ // src/tools/dns-records.ts
918
+ import { z as z7 } from "zod";
919
+ var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "ALIAS"];
920
+ async function resolveZonePublicId(api, teamId, input) {
921
+ if (input.zone_id) {
922
+ const { zones: zones2 } = await api.get(`/api/dns-zones/${teamId}`);
923
+ const match = zones2.find((z15) => z15.publicId === input.zone_id);
924
+ if (!match) {
925
+ throw new Error(`Zone ${input.zone_id} not found on this team.`);
926
+ }
927
+ return { publicId: match.publicId, domainName: match.domainName };
928
+ }
929
+ if (!input.domain) {
930
+ throw new Error("Provide either zone_id or domain.");
931
+ }
932
+ const { zones } = await api.get(`/api/dns-zones/${teamId}`);
933
+ const fqdn = input.domain.toLowerCase().replace(/\.$/, "");
934
+ const labels = fqdn.split(".");
935
+ for (let i = 0; i < labels.length - 1; i++) {
936
+ const candidate = labels.slice(i).join(".");
937
+ const match = zones.find((z15) => z15.domainName.toLowerCase() === candidate);
938
+ if (match && match.status !== "deleting") {
939
+ return { publicId: match.publicId, domainName: match.domainName };
940
+ }
941
+ }
942
+ throw new Error(
943
+ `No hosted zone matches ${input.domain}. Create the zone in the dashboard (Domains \u2192 DNS) first, then retry.`
944
+ );
945
+ }
946
+ defineTool({
947
+ name: "list_dns_zones",
948
+ category: "dns",
949
+ description: [
950
+ "List authoritative DNS zones the team owns on HostStack's PowerDNS infrastructure. Each zone is the apex domain (e.g. example.com) under which DNS records live.",
951
+ "",
952
+ "When to use: discover which apex domains support record management before calling create_dns_record / list_dns_records. The dashboard equivalent is Domains \u2192 DNS.",
953
+ "",
954
+ 'Returns: { items: Zone[] } \u2014 each zone exposes publicId, domainName, status ("active" | "syncing" | "failed" | "deleting"), nsRecords (the nameservers the parent registry must delegate to), provider, createdAt.',
955
+ "",
956
+ 'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
957
+ ].join("\n"),
958
+ input: {},
959
+ handler: async (_args, ctx) => {
960
+ const teamId = await ctx.resolveTeamId();
961
+ const response = await ctx.api.get(`/api/dns-zones/${teamId}`);
962
+ const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
963
+ const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.`;
964
+ return respond({ summary, data: { items } });
965
+ }
966
+ });
967
+ defineTool({
968
+ name: "list_dns_records",
969
+ category: "dns",
970
+ description: [
971
+ "List every active record in a hosted DNS zone. Records are addressed by their type + name + value tuple; PowerDNS gathers records with the same (name, type) into an RRset on the wire.",
972
+ "",
973
+ "When to use: audit what is currently published for a zone before editing, verify a recently-created record landed, or read existing TXT verification strings.",
974
+ "",
975
+ 'Provide EITHER zone_id (the zone publicId, e.g. "dnz_abc") OR domain (an apex or subdomain \u2014 the longest-matching hosted apex wins; "app.example.com" matches a hosted "example.com" zone).',
976
+ "",
977
+ 'Returns: { zone: { publicId, domainName }, items: Record[] } \u2014 each record includes publicId, type, name ("@" for apex, or label), value, ttl, priority?, status ("active" | "syncing" | "failed"), managedBy ("user" | "hoststack" | "poststack"), createdAt.',
978
+ "",
979
+ '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 }] }'
980
+ ].join("\n"),
981
+ input: {
982
+ zone_id: z7.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
983
+ domain: z7.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
984
+ },
985
+ handler: async (args, ctx) => {
986
+ const teamId = await ctx.resolveTeamId();
987
+ const zoneInput = {};
988
+ if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
989
+ if (args.domain !== void 0) zoneInput.domain = args.domain;
990
+ const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
991
+ const response = await ctx.api.get(
992
+ `/api/dns-zones/${teamId}/${zone.publicId}/records`
993
+ );
994
+ const items = Array.isArray(response.records) ? response.records.map(shape) : [];
995
+ const summary = items.length === 0 ? `Zone ${zone.domainName} has no records yet.` : `Returned ${items.length} record${items.length === 1 ? "" : "s"} for ${zone.domainName}.`;
996
+ return respond({
997
+ summary,
998
+ data: { zone: { publicId: zone.publicId, domainName: zone.domainName }, items }
999
+ });
1000
+ }
1001
+ });
1002
+ defineTool({
1003
+ name: "get_dns_record",
1004
+ category: "dns",
1005
+ description: [
1006
+ 'Fetch a single DNS record by its publicId. Useful for confirming the record landed after create_dns_record / update_dns_record (status transitions from "syncing" to "active" once PowerDNS acknowledges).',
1007
+ "",
1008
+ "When to use: verify a record exists, inspect its current ttl/value, or check the sync status of an in-flight write.",
1009
+ "",
1010
+ "Inputs:",
1011
+ ' - record_id: record publicId (e.g. "dnr_abc"). Returned by list_dns_records and create_dns_record.',
1012
+ "",
1013
+ "Returns: { record: Record }. Mirrors the shape returned by list_dns_records.",
1014
+ "",
1015
+ 'Example: get_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { type: "TXT", name: "@", value: "\u2026", status: "active", \u2026 } }'
1016
+ ].join("\n"),
1017
+ input: {
1018
+ record_id: z7.string().describe("Record publicId.")
1019
+ },
1020
+ handler: async (args, ctx) => {
1021
+ const teamId = await ctx.resolveTeamId();
1022
+ const { zones } = await ctx.api.get(`/api/dns-zones/${teamId}`);
1023
+ for (const zone of zones) {
1024
+ const { records } = await ctx.api.get(
1025
+ `/api/dns-zones/${teamId}/${zone.publicId}/records`
1026
+ );
1027
+ const match = records.find((r) => r.publicId === args.record_id);
1028
+ if (match) {
1029
+ return respond({
1030
+ summary: `Record ${match.type} ${match.name} on ${zone.domainName}.`,
1031
+ data: {
1032
+ zone: { publicId: zone.publicId, domainName: zone.domainName },
1033
+ record: shape(match)
1034
+ }
1035
+ });
1036
+ }
1037
+ }
1038
+ return respondError(`Record ${args.record_id} not found on any zone owned by this team.`);
1039
+ }
1040
+ });
1041
+ defineTool({
1042
+ name: "create_dns_record",
1043
+ category: "dns",
1044
+ description: [
1045
+ 'Create a DNS record on a hosted zone. Writes to the DB first, then mirrors to PowerDNS \u2014 record returns with status="syncing" until the provider acknowledges, then flips to "active". Re-creating the same (zone, type, name, value) tuple is idempotent and returns the existing row.',
1046
+ "",
1047
+ "When to use: add TXT verification records (Google Search Console, domain ownership challenges, DKIM), A/AAAA records pointing a subdomain at an IP, CNAME aliases, MX records, etc.",
1048
+ "",
1049
+ "Provide EITHER zone_id (zone publicId) OR domain (resolves via longest-suffix match against hosted zones).",
1050
+ "",
1051
+ "Inputs:",
1052
+ ' - type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "SRV" | "CAA" | "ALIAS".',
1053
+ ' - name: "@" for the zone apex, or a subdomain label ("www", "api", "_acme-challenge").',
1054
+ " - content: the literal record value. For TXT pass the unquoted string \u2014 the PowerDNS layer handles quoting.",
1055
+ " - ttl (optional): 60\u201386400 seconds, default 3600.",
1056
+ " - priority (optional, required for MX and SRV): 0\u201365535.",
1057
+ "",
1058
+ "Returns: { record: Record } \u2014 see get_dns_record for the shape.",
1059
+ "",
1060
+ 'Example: create_dns_record({ domain: "micci.dk", type: "TXT", name: "@", content: "google-site-verification=C0V0scK48g2\u2026", ttl: 300 })'
1061
+ ].join("\n"),
1062
+ input: {
1063
+ zone_id: z7.string().optional().describe("Zone publicId."),
1064
+ domain: z7.string().optional().describe("Apex or subdomain to resolve a hosted zone."),
1065
+ type: z7.enum(DNS_RECORD_TYPES).describe("DNS record type."),
1066
+ name: z7.string().min(1).max(253).describe('"@" for apex, or label (no trailing dot).'),
1067
+ content: z7.string().min(1).max(2e3).describe("Record value; TXT values are unquoted."),
1068
+ ttl: z7.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
1069
+ priority: z7.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
1070
+ },
1071
+ handler: async (args, ctx) => {
1072
+ if ((args.type === "MX" || args.type === "SRV") && args.priority === void 0) {
1073
+ return respondError(`Priority is required for ${args.type} records (0\u201365535).`);
1074
+ }
1075
+ const teamId = await ctx.resolveTeamId();
1076
+ const zoneInput = {};
1077
+ if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
1078
+ if (args.domain !== void 0) zoneInput.domain = args.domain;
1079
+ const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
1080
+ const body = {
1081
+ type: args.type,
1082
+ name: args.name,
1083
+ value: args.content
1084
+ };
1085
+ if (args.ttl !== void 0) body.ttl = args.ttl;
1086
+ if (args.priority !== void 0) body.priority = args.priority;
1087
+ const response = await ctx.api.post(
1088
+ `/api/dns-zones/${teamId}/${zone.publicId}/records`,
1089
+ body
1090
+ );
1091
+ return respond({
1092
+ summary: `Created ${args.type} ${args.name} on ${zone.domainName}.`,
1093
+ data: {
1094
+ zone: { publicId: zone.publicId, domainName: zone.domainName },
1095
+ record: shape(response.record)
1096
+ }
1097
+ });
1098
+ }
1099
+ });
1100
+ defineTool({
1101
+ name: "update_dns_record",
1102
+ category: "dns",
1103
+ description: [
1104
+ "Update an existing DNS record by its publicId. Use this to change a TTL, swap a value, or move the record to a different (name, type) bucket. Full-record PUT semantics: every mutable field must be provided.",
1105
+ "",
1106
+ "When to use: rotate a DKIM key, repoint an A record at a new IP, bump TTL ahead of a planned change.",
1107
+ "",
1108
+ "Inputs:",
1109
+ " - record_id: record publicId.",
1110
+ " - type, name, content: the new values (the route is full-replace, not patch \u2014 fetch with get_dns_record first if you only want to tweak one field).",
1111
+ " - ttl (optional), priority (optional, required for MX / SRV).",
1112
+ "",
1113
+ "Returns: { record: Record } reflecting the new state.",
1114
+ "",
1115
+ 'Example: update_dns_record({ record_id: "dnr_abc", type: "A", name: "api", content: "203.0.113.42", ttl: 300 })'
1116
+ ].join("\n"),
1117
+ input: {
1118
+ record_id: z7.string().describe("Record publicId."),
1119
+ type: z7.enum(DNS_RECORD_TYPES).describe("DNS record type."),
1120
+ name: z7.string().min(1).max(253).describe('"@" for apex, or label.'),
1121
+ content: z7.string().min(1).max(2e3).describe("Record value; TXT unquoted."),
1122
+ ttl: z7.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
1123
+ priority: z7.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
1124
+ },
1125
+ handler: async (args, ctx) => {
1126
+ if ((args.type === "MX" || args.type === "SRV") && args.priority === void 0) {
1127
+ return respondError(`Priority is required for ${args.type} records (0\u201365535).`);
1128
+ }
1129
+ const teamId = await ctx.resolveTeamId();
1130
+ const target = await findRecordZone(ctx.api, teamId, args.record_id);
1131
+ if (!target) {
1132
+ return respondError(
1133
+ `Record ${args.record_id} not found on any zone owned by this team.`
1134
+ );
1135
+ }
1136
+ const body = {
1137
+ type: args.type,
1138
+ name: args.name,
1139
+ value: args.content
1140
+ };
1141
+ if (args.ttl !== void 0) body.ttl = args.ttl;
1142
+ if (args.priority !== void 0) body.priority = args.priority;
1143
+ const response = await ctx.api.put(
1144
+ `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`,
1145
+ body
1146
+ );
1147
+ return respond({
1148
+ summary: `Updated ${args.type} ${args.name} on ${target.zone.domainName}.`,
1149
+ data: {
1150
+ zone: {
1151
+ publicId: target.zone.publicId,
1152
+ domainName: target.zone.domainName
1153
+ },
1154
+ record: shape(response.record)
1155
+ }
1156
+ });
1157
+ }
1158
+ });
1159
+ defineTool({
1160
+ name: "delete_dns_record",
1161
+ category: "dns",
1162
+ description: [
1163
+ "Soft-delete a DNS record and remove it from PowerDNS. DESTRUCTIVE: resolvers stop returning the value as soon as the change propagates. Same code path as the dashboard delete button \u2014 the row is marked deleted and the RRset is re-pushed without it.",
1164
+ "",
1165
+ "When to use: retire a TXT verification record once the challenge has been confirmed; drop an A record for a decommissioned subdomain.",
1166
+ "",
1167
+ "Inputs:",
1168
+ " - record_id: record publicId.",
1169
+ "",
1170
+ "Returns: { ok: true }.",
1171
+ "",
1172
+ 'Example: delete_dns_record({ record_id: "dnr_abc" }) \u2192 { ok: true }'
1173
+ ].join("\n"),
1174
+ input: {
1175
+ record_id: z7.string().describe("Record publicId.")
1176
+ },
1177
+ handler: async (args, ctx) => {
1178
+ const teamId = await ctx.resolveTeamId();
1179
+ const target = await findRecordZone(ctx.api, teamId, args.record_id);
1180
+ if (!target) {
1181
+ return respondError(
1182
+ `Record ${args.record_id} not found on any zone owned by this team.`
1183
+ );
1184
+ }
1185
+ await ctx.api.delete(
1186
+ `/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`
1187
+ );
1188
+ return respond({
1189
+ summary: `Deleted record ${args.record_id} from ${target.zone.domainName}.`,
1190
+ data: { ok: true }
1191
+ });
1192
+ }
1193
+ });
1194
+ async function findRecordZone(api, teamId, recordPublicId) {
1195
+ const { zones } = await api.get(`/api/dns-zones/${teamId}`);
1196
+ for (const zone of zones) {
1197
+ const { records } = await api.get(
1198
+ `/api/dns-zones/${teamId}/${zone.publicId}/records`
1199
+ );
1200
+ const match = records.find((r) => r.publicId === recordPublicId);
1201
+ if (match) return { zone, record: match };
1202
+ }
1203
+ return null;
1204
+ }
731
1205
 
732
1206
  // src/tools/domains.ts
733
- import { z as z6 } from "zod";
1207
+ import { z as z8 } from "zod";
734
1208
  defineTool({
735
1209
  name: "list_domains",
736
1210
  category: "domains",
@@ -769,8 +1243,8 @@ defineTool({
769
1243
  'Example: add_domain({ hostname: "api.example.com", service_id: "svc_abc" }) \u2192 { domain: { hostname: "api.example.com", dnsTargets: [...], verified: false, \u2026 } }'
770
1244
  ].join("\n"),
771
1245
  input: {
772
- hostname: z6.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
773
- service_id: z6.string().optional().describe("Optional service publicId to bind to.")
1246
+ hostname: z8.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
1247
+ service_id: z8.string().optional().describe("Optional service publicId to bind to.")
774
1248
  },
775
1249
  handler: async (args, ctx) => {
776
1250
  const teamId = await ctx.resolveTeamId();
@@ -800,12 +1274,15 @@ defineTool({
800
1274
  'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
801
1275
  ].join("\n"),
802
1276
  input: {
803
- domain_id: z6.string().describe("Domain publicId.")
1277
+ domain_id: z8.string().describe("Domain publicId.")
804
1278
  },
805
1279
  handler: async (args, ctx) => {
806
1280
  const teamId = await ctx.resolveTeamId();
807
1281
  await ctx.hoststack.domains.verify(teamId, args.domain_id);
808
- return respond({ summary: `Triggered DNS verification for ${args.domain_id}.`, data: { ok: true } });
1282
+ return respond({
1283
+ summary: `Triggered DNS verification for ${args.domain_id}.`,
1284
+ data: { ok: true }
1285
+ });
809
1286
  }
810
1287
  });
811
1288
  defineTool({
@@ -824,7 +1301,7 @@ defineTool({
824
1301
  'Example: remove_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
825
1302
  ].join("\n"),
826
1303
  input: {
827
- domain_id: z6.string().describe("Domain publicId.")
1304
+ domain_id: z8.string().describe("Domain publicId.")
828
1305
  },
829
1306
  handler: async (args, ctx) => {
830
1307
  const teamId = await ctx.resolveTeamId();
@@ -834,7 +1311,7 @@ defineTool({
834
1311
  });
835
1312
 
836
1313
  // src/tools/env-vars.ts
837
- import { z as z7 } from "zod";
1314
+ import { z as z9 } from "zod";
838
1315
  defineTool({
839
1316
  name: "list_env_vars",
840
1317
  category: "env-vars",
@@ -851,7 +1328,7 @@ defineTool({
851
1328
  '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 }] }'
852
1329
  ].join("\n"),
853
1330
  input: {
854
- service_id: z7.string().describe("Service publicId.")
1331
+ service_id: z9.string().describe("Service publicId.")
855
1332
  },
856
1333
  handler: async (args, ctx) => {
857
1334
  const teamId = await ctx.resolveTeamId();
@@ -880,10 +1357,10 @@ defineTool({
880
1357
  '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" }'
881
1358
  ].join("\n"),
882
1359
  input: {
883
- service_id: z7.string().describe("Service publicId."),
884
- key: z7.string().min(1).max(128).describe("Env-var key."),
885
- value: z7.string().describe("New value."),
886
- is_secret: z7.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
1360
+ service_id: z9.string().describe("Service publicId."),
1361
+ key: z9.string().min(1).max(128).describe("Env-var key."),
1362
+ value: z9.string().describe("New value."),
1363
+ is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
887
1364
  },
888
1365
  handler: async (args, ctx) => {
889
1366
  const teamId = await ctx.resolveTeamId();
@@ -891,10 +1368,15 @@ defineTool({
891
1368
  const existing = await ctx.hoststack.envVars.list(teamId, args.service_id);
892
1369
  const match = existing.envVars.find((v) => v.key === args.key);
893
1370
  if (match) {
894
- const response2 = await ctx.hoststack.envVars.update(teamId, args.service_id, String(match.id), {
895
- value: args.value,
896
- isSecret
897
- });
1371
+ const response2 = await ctx.hoststack.envVars.update(
1372
+ teamId,
1373
+ args.service_id,
1374
+ String(match.id),
1375
+ {
1376
+ value: args.value,
1377
+ isSecret
1378
+ }
1379
+ );
898
1380
  const data2 = {
899
1381
  envVar: shapeEnvVar(response2.envVar),
900
1382
  action: "updated"
@@ -930,18 +1412,18 @@ defineTool({
930
1412
  'Example: delete_env_var({ service_id: "svc_abc", key: "OLD_FLAG" }) \u2192 { ok: true }'
931
1413
  ].join("\n"),
932
1414
  input: {
933
- service_id: z7.string().describe("Service publicId."),
934
- key: z7.string().min(1).max(128).describe("Env-var key to delete.")
1415
+ service_id: z9.string().describe("Service publicId."),
1416
+ key: z9.string().min(1).max(128).describe("Env-var key to delete.")
935
1417
  },
936
1418
  handler: async (args, ctx) => {
937
1419
  const teamId = await ctx.resolveTeamId();
938
1420
  const existing = await ctx.hoststack.envVars.list(teamId, args.service_id);
939
1421
  const match = existing.envVars.find((v) => v.key === args.key);
940
1422
  if (!match) {
941
- return respondError(
942
- `Env var "${args.key}" not found on service ${args.service_id}.`,
943
- { key: args.key, service_id: args.service_id }
944
- );
1423
+ return respondError(`Env var "${args.key}" not found on service ${args.service_id}.`, {
1424
+ key: args.key,
1425
+ service_id: args.service_id
1426
+ });
945
1427
  }
946
1428
  await ctx.hoststack.envVars.delete(teamId, args.service_id, String(match.id));
947
1429
  return respond({
@@ -967,19 +1449,19 @@ defineTool({
967
1449
  '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 }'
968
1450
  ].join("\n"),
969
1451
  input: {
970
- service_id: z7.string().describe("Service publicId."),
971
- env_vars: z7.array(
972
- z7.object({
973
- key: z7.string().min(1).max(128),
974
- value: z7.string(),
975
- is_secret: z7.boolean().optional()
1452
+ service_id: z9.string().describe("Service publicId."),
1453
+ env_vars: z9.array(
1454
+ z9.object({
1455
+ key: z9.string().min(1).max(128),
1456
+ value: z9.string(),
1457
+ is_secret: z9.boolean().optional()
976
1458
  })
977
1459
  ).max(500).describe("Array of env-var rows. Hard cap 500.")
978
1460
  },
979
1461
  handler: async (args, ctx) => {
980
1462
  const teamId = await ctx.resolveTeamId();
981
1463
  const payload = {
982
- envVars: args.env_vars.map((v) => {
1464
+ vars: args.env_vars.map((v) => {
983
1465
  const row = {
984
1466
  key: v.key,
985
1467
  value: v.value
@@ -997,7 +1479,7 @@ defineTool({
997
1479
  });
998
1480
 
999
1481
  // src/tools/environments.ts
1000
- import { z as z8 } from "zod";
1482
+ import { z as z10 } from "zod";
1001
1483
  defineTool({
1002
1484
  name: "list_environments",
1003
1485
  category: "environments",
@@ -1014,7 +1496,7 @@ defineTool({
1014
1496
  'Example: list_environments({ project_id: "prj_abc" }) \u2192 { items: [{ name: "Production", type: "production", isDefault: true }, { name: "Staging", type: "staging" }] }'
1015
1497
  ].join("\n"),
1016
1498
  input: {
1017
- project_id: z8.string().describe("Project publicId.")
1499
+ project_id: z10.string().describe("Project publicId.")
1018
1500
  },
1019
1501
  handler: async (args, ctx) => {
1020
1502
  const teamId = await ctx.resolveTeamId();
@@ -1043,10 +1525,10 @@ defineTool({
1043
1525
  'Example: create_environment({ project_id: "prj_abc", name: "Staging", type: "staging" }) \u2192 { environment: { id: 7, publicId: "environment_\u2026", name: "Staging", type: "staging" } }'
1044
1526
  ].join("\n"),
1045
1527
  input: {
1046
- project_id: z8.string().describe("Project publicId."),
1047
- name: z8.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
1048
- type: z8.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
1049
- is_protected: z8.boolean().optional().describe("Require admin role for destructive actions. Default false.")
1528
+ project_id: z10.string().describe("Project publicId."),
1529
+ name: z10.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
1530
+ type: z10.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
1531
+ is_protected: z10.boolean().optional().describe("Require admin role for destructive actions. Default false.")
1050
1532
  },
1051
1533
  handler: async (args, ctx) => {
1052
1534
  const teamId = await ctx.resolveTeamId();
@@ -1080,8 +1562,8 @@ defineTool({
1080
1562
  'Example: delete_environment({ project_id: "prj_abc", environment_id: "environment_xyz" }) \u2192 { success: true }'
1081
1563
  ].join("\n"),
1082
1564
  input: {
1083
- project_id: z8.string().describe("Project publicId."),
1084
- environment_id: z8.union([z8.string(), z8.number()]).describe("Environment publicId or numeric id.")
1565
+ project_id: z10.string().describe("Project publicId."),
1566
+ environment_id: z10.union([z10.string(), z10.number()]).describe("Environment publicId or numeric id.")
1085
1567
  },
1086
1568
  handler: async (args, ctx) => {
1087
1569
  const teamId = await ctx.resolveTeamId();
@@ -1112,9 +1594,9 @@ defineTool({
1112
1594
  'Example: promote_deploy({ service_id: "svc_staging", deploy_id: "dpl_built", target_environment_id: "environment_prod" }) \u2192 { deploy: { id: 99, status: "pending", trigger: "rollback", dockerImageId: "sha256:\u2026" } }'
1113
1595
  ].join("\n"),
1114
1596
  input: {
1115
- service_id: z8.string().describe("Source service publicId."),
1116
- deploy_id: z8.string().describe("Source deploy publicId (must have a built image)."),
1117
- target_environment_id: z8.union([z8.string(), z8.number()]).describe("Target environment publicId or numeric id.")
1597
+ service_id: z10.string().describe("Source service publicId."),
1598
+ deploy_id: z10.string().describe("Source deploy publicId (must have a built image)."),
1599
+ target_environment_id: z10.union([z10.string(), z10.number()]).describe("Target environment publicId or numeric id.")
1118
1600
  },
1119
1601
  handler: async (args, ctx) => {
1120
1602
  const teamId = await ctx.resolveTeamId();
@@ -1162,8 +1644,190 @@ defineTool({
1162
1644
  }
1163
1645
  });
1164
1646
 
1647
+ // src/tools/notifications.ts
1648
+ import { z as z11 } from "zod";
1649
+ var NOTIFICATION_EVENTS = [
1650
+ "deploy.started",
1651
+ "deploy.succeeded",
1652
+ "deploy.failed",
1653
+ "deploy.failed_consecutive",
1654
+ "service.created",
1655
+ "service.deleted",
1656
+ "service.suspended",
1657
+ "service.resumed",
1658
+ "service.restart_failed",
1659
+ "service.auto_suspended",
1660
+ "service.acme_cert_failed",
1661
+ "git.auth_failed"
1662
+ ];
1663
+ defineTool({
1664
+ name: "list_notification_channels",
1665
+ category: "alerts",
1666
+ description: [
1667
+ "List configured notification channels for the team (Slack/Discord webhooks + email recipients). Each channel has a list of events it subscribes to \u2014 fire only happens when an event in that list occurs.",
1668
+ "",
1669
+ "When to use: setting up alerts ('which channels are notifying us on deploy failures?'), auditing why someone got/didn't get paged, or before subscribing a new event.",
1670
+ "",
1671
+ "Returns: { items: Channel[] } \u2014 each channel includes id, type, name, webhookUrl (masked), active, events, createdAt.",
1672
+ "",
1673
+ "Example: list_notification_channels() \u2192 { items: [{ id: 3, type: 'slack', name: 'eng-alerts', events: ['deploy.failed', 'git.auth_failed'], \u2026 }] }"
1674
+ ].join("\n"),
1675
+ input: {},
1676
+ handler: async (_args, ctx) => {
1677
+ const teamId = await ctx.resolveTeamId();
1678
+ const response = await ctx.hoststack.notifications.listChannels(teamId);
1679
+ const data = shapeList(response, "channels", shape);
1680
+ const summary = data.items.length === 0 ? "No notification channels yet \u2014 create one with create_notification_channel." : `Found ${data.items.length} notification channel${data.items.length === 1 ? "" : "s"}.`;
1681
+ return respond({ summary, data });
1682
+ }
1683
+ });
1684
+ defineTool({
1685
+ name: "create_notification_channel",
1686
+ category: "alerts",
1687
+ description: [
1688
+ "Create a Slack, Discord, or email notification channel and subscribe it to a list of events.",
1689
+ "",
1690
+ "When to use: setting up alert delivery for a team \u2014 paging the on-call when a deploy fails, posting to #incidents when git auth breaks, emailing the owner on ACME renewal failure.",
1691
+ "",
1692
+ "For type=email, `webhook_url` is the recipient email address. For type=slack or type=discord, it is the incoming webhook URL. The URL is stored encrypted server-side and masked in list responses.",
1693
+ "",
1694
+ "Inputs:",
1695
+ ' - type: "slack" | "discord" | "email".',
1696
+ " - name: human-readable label (1\u2013128 chars).",
1697
+ " - webhook_url: Slack/Discord webhook URL OR email address.",
1698
+ " - 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).",
1699
+ "",
1700
+ "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.",
1701
+ "",
1702
+ "Returns: { channel: Channel }.",
1703
+ "",
1704
+ "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'] })"
1705
+ ].join("\n"),
1706
+ input: {
1707
+ type: z11.enum(["slack", "discord", "email"]).describe("Channel type."),
1708
+ name: z11.string().min(1).max(128).describe("Human-readable label."),
1709
+ webhook_url: z11.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
1710
+ events: z11.array(z11.enum(NOTIFICATION_EVENTS)).describe("List of events the channel subscribes to. Empty list = subscribe to nothing.")
1711
+ },
1712
+ handler: async (args, ctx) => {
1713
+ const teamId = await ctx.resolveTeamId();
1714
+ const response = await ctx.hoststack.notifications.createChannel(teamId, {
1715
+ type: args.type,
1716
+ name: args.name,
1717
+ webhookUrl: args.webhook_url,
1718
+ events: args.events
1719
+ });
1720
+ const data = { channel: shape(response.channel) };
1721
+ return respond({
1722
+ summary: `Created ${args.type} channel "${args.name}" subscribed to ${args.events.length} event${args.events.length === 1 ? "" : "s"}.`,
1723
+ data
1724
+ });
1725
+ }
1726
+ });
1727
+ defineTool({
1728
+ name: "update_notification_channel",
1729
+ category: "alerts",
1730
+ description: [
1731
+ "Update a notification channel: rename, enable/disable, or change its event subscription list. The webhook URL and type are immutable \u2014 create a new channel if those need to change.",
1732
+ "",
1733
+ "When to use: silencing a noisy channel (active=false), adding a new event to an existing list, or relabeling.",
1734
+ "",
1735
+ "Inputs:",
1736
+ " - channel_id: numeric id of the channel.",
1737
+ " - name (optional): new label.",
1738
+ " - active (optional): false to silence, true to re-enable.",
1739
+ " - events (optional): replace the full subscription list. Passing [] silences all events without disabling the channel.",
1740
+ "",
1741
+ "Returns: { channel: Channel }.",
1742
+ "",
1743
+ "Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
1744
+ ].join("\n"),
1745
+ input: {
1746
+ channel_id: z11.number().int().positive().describe("Numeric channel id from list_notification_channels."),
1747
+ name: z11.string().min(1).max(128).optional().describe("New label."),
1748
+ active: z11.boolean().optional().describe("false silences without deleting."),
1749
+ events: z11.array(z11.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
1750
+ },
1751
+ handler: async (args, ctx) => {
1752
+ const teamId = await ctx.resolveTeamId();
1753
+ const update = {};
1754
+ if (args.name !== void 0) update.name = args.name;
1755
+ if (args.active !== void 0) update.active = args.active;
1756
+ if (args.events !== void 0) update.events = args.events;
1757
+ if (Object.keys(update).length === 0) {
1758
+ return respond({ summary: "No fields to update.", data: {} });
1759
+ }
1760
+ const response = await ctx.hoststack.notifications.updateChannel(
1761
+ teamId,
1762
+ args.channel_id,
1763
+ update
1764
+ );
1765
+ const data = { channel: shape(response.channel) };
1766
+ return respond({
1767
+ summary: `Updated notification channel #${args.channel_id}.`,
1768
+ data
1769
+ });
1770
+ }
1771
+ });
1772
+ defineTool({
1773
+ name: "delete_notification_channel",
1774
+ category: "alerts",
1775
+ description: [
1776
+ "Permanently delete a notification channel. Use update_notification_channel({ active: false }) to silence without deleting if you might want it back later.",
1777
+ "",
1778
+ "When to use: a channel is no longer needed (decommissioned Slack workspace, team member left, webhook leaked) and you want it gone from the team config entirely.",
1779
+ "",
1780
+ "Inputs:",
1781
+ " - channel_id: numeric id of the channel.",
1782
+ "",
1783
+ "Returns: { ok: true }.",
1784
+ "",
1785
+ "Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
1786
+ ].join("\n"),
1787
+ input: {
1788
+ channel_id: z11.number().int().positive().describe("Numeric channel id.")
1789
+ },
1790
+ handler: async (args, ctx) => {
1791
+ const teamId = await ctx.resolveTeamId();
1792
+ await ctx.hoststack.notifications.deleteChannel(teamId, args.channel_id);
1793
+ return respond({
1794
+ summary: `Deleted notification channel #${args.channel_id}.`,
1795
+ data: { ok: true }
1796
+ });
1797
+ }
1798
+ });
1799
+ defineTool({
1800
+ name: "test_notification_channel",
1801
+ category: "alerts",
1802
+ description: [
1803
+ 'Fire a synthetic test event to the channel so the user can confirm the webhook is wired up correctly. The webhook receives an unmistakeable "test message" payload.',
1804
+ "",
1805
+ "When to use: after creating a channel, or when alerts stop arriving \u2014 quickest way to tell whether the issue is on HostStack's side or the channel's.",
1806
+ "",
1807
+ "Inputs:",
1808
+ " - channel_id: numeric id of the channel.",
1809
+ "",
1810
+ "Returns: { success: boolean, error?: string } \u2014 false + error string when the webhook returned non-2xx.",
1811
+ "",
1812
+ "Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
1813
+ ].join("\n"),
1814
+ input: {
1815
+ channel_id: z11.number().int().positive().describe("Numeric channel id.")
1816
+ },
1817
+ handler: async (args, ctx) => {
1818
+ const teamId = await ctx.resolveTeamId();
1819
+ const response = await ctx.hoststack.notifications.testChannel(teamId, args.channel_id);
1820
+ const data = shape(response);
1821
+ const okFlag = data && typeof data === "object" && "success" in data ? data.success : false;
1822
+ return respond({
1823
+ summary: okFlag ? `Test event delivered to channel #${args.channel_id}.` : `Test event FAILED for channel #${args.channel_id}: ${data.error ?? "unknown error"}.`,
1824
+ data
1825
+ });
1826
+ }
1827
+ });
1828
+
1165
1829
  // src/tools/projects.ts
1166
- import { z as z9 } from "zod";
1830
+ import { z as z12 } from "zod";
1167
1831
  defineTool({
1168
1832
  name: "list_projects",
1169
1833
  category: "projects",
@@ -1203,9 +1867,9 @@ defineTool({
1203
1867
  'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "fsn1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
1204
1868
  ].join("\n"),
1205
1869
  input: {
1206
- name: z9.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
1207
- description: z9.string().max(500).optional().describe("Short description (\u2264500 chars)."),
1208
- region: z9.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
1870
+ name: z12.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
1871
+ description: z12.string().max(500).optional().describe("Short description (\u2264500 chars)."),
1872
+ region: z12.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
1209
1873
  },
1210
1874
  handler: async (args, ctx) => {
1211
1875
  const teamId = await ctx.resolveTeamId();
@@ -1236,9 +1900,9 @@ defineTool({
1236
1900
  'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
1237
1901
  ].join("\n"),
1238
1902
  input: {
1239
- project_id: z9.string().describe("Project publicId."),
1240
- name: z9.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
1241
- description: z9.string().max(500).optional().describe("New description (\u2264500 chars).")
1903
+ project_id: z12.string().describe("Project publicId."),
1904
+ name: z12.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
1905
+ description: z12.string().max(500).optional().describe("New description (\u2264500 chars).")
1242
1906
  },
1243
1907
  handler: async (args, ctx) => {
1244
1908
  if (args.name === void 0 && args.description === void 0) {
@@ -1272,7 +1936,7 @@ defineTool({
1272
1936
  'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
1273
1937
  ].join("\n"),
1274
1938
  input: {
1275
- project_id: z9.string().describe("Project publicId (e.g. prj_abc123).")
1939
+ project_id: z12.string().describe("Project publicId (e.g. prj_abc123).")
1276
1940
  },
1277
1941
  handler: async (args, ctx) => {
1278
1942
  const teamId = await ctx.resolveTeamId();
@@ -1284,25 +1948,59 @@ defineTool({
1284
1948
  });
1285
1949
 
1286
1950
  // src/tools/services.ts
1287
- import { z as z10 } from "zod";
1951
+ import { z as z13 } from "zod";
1288
1952
  defineTool({
1289
1953
  name: "list_services",
1290
1954
  category: "services",
1291
1955
  description: [
1292
- "List every service (web, worker, cron, private) in the active HostStack team.",
1956
+ "List services (web, worker, cron, private, static) in the active HostStack team.",
1293
1957
  "",
1294
1958
  "When to use: the agent needs to find a service by name, check what is deployed, or pick a target for a follow-up tool (logs, deploys, env vars). This is the canonical way to resolve a publicId from a human-friendly name.",
1295
1959
  "",
1960
+ "Inputs (all optional):",
1961
+ ' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
1962
+ ' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
1963
+ ' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
1964
+ ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
1965
+ "",
1296
1966
  "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
1297
1967
  "",
1298
- 'Example: list_services() \u2192 { items: [{ id: 31, publicId: "svc_\u2026", name: "api", type: "web", status: "running", \u2026 }] }'
1968
+ 'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
1299
1969
  ].join("\n"),
1300
- input: {},
1301
- handler: async (_args, ctx) => {
1970
+ input: {
1971
+ project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
1972
+ environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
1973
+ status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
1974
+ type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
1975
+ },
1976
+ handler: async (args, ctx) => {
1302
1977
  const teamId = await ctx.resolveTeamId();
1303
- const response = await ctx.hoststack.services.list(teamId);
1978
+ const filters = {};
1979
+ if (args.project_id !== void 0) {
1980
+ const resolved = await ctx.hoststack.resolveId(args.project_id, {
1981
+ kind: "project",
1982
+ teamId
1983
+ });
1984
+ filters.projectId = resolved;
1985
+ }
1986
+ if (args.environment_id !== void 0) {
1987
+ const resolved = await ctx.hoststack.resolveId(args.environment_id, {
1988
+ kind: "environment",
1989
+ teamId
1990
+ });
1991
+ filters.environmentId = resolved;
1992
+ }
1993
+ if (args.status) filters.status = args.status;
1994
+ if (args.type) filters.type = args.type;
1995
+ const response = await ctx.hoststack.services.list(teamId, filters);
1304
1996
  const data = shapeList(response, "services", shapeService);
1305
- const summary = data.items.length === 0 ? "No services yet \u2014 create one with create_service or via the dashboard." : `Found ${data.items.length} service${data.items.length === 1 ? "" : "s"}.`;
1997
+ const filterDesc = [
1998
+ args.project_id !== void 0 ? `project=${args.project_id}` : null,
1999
+ args.environment_id !== void 0 ? `env=${args.environment_id}` : null,
2000
+ args.status ? `status=${args.status}` : null,
2001
+ args.type ? `type=${args.type}` : null
2002
+ ].filter(Boolean).join(", ");
2003
+ const summary = data.items.length === 0 ? filterDesc ? `No services match the given filters (${filterDesc}).` : "No services yet \u2014 create one with create_service or via the dashboard." : `Found ${data.items.length} service${data.items.length === 1 ? "" : "s"}${filterDesc ? ` matching ${filterDesc}` : ""}.`;
1306
2004
  return respond({ summary, data });
1307
2005
  }
1308
2006
  });
@@ -1322,7 +2020,7 @@ defineTool({
1322
2020
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 } }'
1323
2021
  ].join("\n"),
1324
2022
  input: {
1325
- service_id: z10.string().describe("Service publicId (e.g. svc_abc123).")
2023
+ service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
1326
2024
  },
1327
2025
  handler: async (args, ctx) => {
1328
2026
  const teamId = await ctx.resolveTeamId();
@@ -1338,7 +2036,7 @@ defineTool({
1338
2036
  description: [
1339
2037
  "Fetch the latest CPU, memory, network, and request-rate metrics for a service.",
1340
2038
  "",
1341
- "When to use: a service is reportedly slow, you suspect resource pressure, or you want a quick health snapshot before scaling. Returns a single point-in-time sample, not a time series.",
2039
+ 'When to use: a service is reportedly slow, you suspect resource pressure, or you want a quick health snapshot before scaling. Returns a single point-in-time sample. For a time series \u2014 "is memory growing?", "did CPU spike during that deploy?" \u2014 use get_service_metrics_history instead.',
1342
2040
  "",
1343
2041
  "Inputs:",
1344
2042
  " - service_id: publicId of the service.",
@@ -1348,7 +2046,7 @@ defineTool({
1348
2046
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
1349
2047
  ].join("\n"),
1350
2048
  input: {
1351
- service_id: z10.string().describe("Service publicId.")
2049
+ service_id: z13.string().describe("Service publicId.")
1352
2050
  },
1353
2051
  handler: async (args, ctx) => {
1354
2052
  const teamId = await ctx.resolveTeamId();
@@ -1357,6 +2055,55 @@ defineTool({
1357
2055
  return respond({ summary: `Metrics snapshot for service ${args.service_id}.`, data });
1358
2056
  }
1359
2057
  });
2058
+ defineTool({
2059
+ name: "get_service_metrics_history",
2060
+ category: "services",
2061
+ description: [
2062
+ "Fetch a metrics time series (CPU, memory, network, disk) for a service.",
2063
+ "",
2064
+ 'When to use: answering "is memory growing over the last hour?", "did CPU spike during deploy X?", or "what does normal look like for this service?". Pairs well with get_deploy to align spikes against deploy times.',
2065
+ "",
2066
+ "Inputs (all optional \u2014 omit both for the trailing hour):",
2067
+ " - service_id: publicId of the service.",
2068
+ ' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
2069
+ " - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
2070
+ "",
2071
+ "Resolution: \u22647d \u2192 raw samples (~minute granularity), \u226430d \u2192 hourly pre-aggregates, >30d \u2192 daily. Up to ~500 points returned.",
2072
+ "",
2073
+ "Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
2074
+ "",
2075
+ 'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
2076
+ ].join("\n"),
2077
+ input: {
2078
+ service_id: z13.string().describe("Service publicId."),
2079
+ from: z13.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
2080
+ to: z13.string().optional().describe("ISO-8601 upper bound; defaults to now.")
2081
+ },
2082
+ handler: async (args, ctx) => {
2083
+ const teamId = await ctx.resolveTeamId();
2084
+ const opts = {};
2085
+ const resolveRel = (raw) => {
2086
+ const rel = /^-(\d+)(s|m|h|d)$/.exec(raw.trim());
2087
+ if (!rel) return raw;
2088
+ const amount = Number.parseInt(rel[1], 10);
2089
+ const unit = rel[2];
2090
+ const ms = unit === "s" ? amount * 1e3 : unit === "m" ? amount * 6e4 : unit === "h" ? amount * 36e5 : amount * 864e5;
2091
+ return new Date(Date.now() - ms).toISOString();
2092
+ };
2093
+ if (args.from) opts.from = resolveRel(args.from);
2094
+ if (args.to) opts.to = resolveRel(args.to);
2095
+ const response = await ctx.hoststack.services.getMetricsHistory(
2096
+ teamId,
2097
+ args.service_id,
2098
+ opts
2099
+ );
2100
+ const points = Array.isArray(response.history) ? response.history.length : 0;
2101
+ return respond({
2102
+ summary: `Returned ${points} metric point${points === 1 ? "" : "s"} for service ${args.service_id}.`,
2103
+ data: { history: response.history }
2104
+ });
2105
+ }
2106
+ });
1360
2107
  defineTool({
1361
2108
  name: "update_service",
1362
2109
  category: "services",
@@ -1374,8 +2121,8 @@ defineTool({
1374
2121
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
1375
2122
  ].join("\n"),
1376
2123
  input: {
1377
- service_id: z10.string().describe("Service publicId."),
1378
- name: z10.string().min(1).max(60).describe("New service name (1\u201360 chars).")
2124
+ service_id: z13.string().describe("Service publicId."),
2125
+ name: z13.string().min(1).max(60).describe("New service name (1\u201360 chars).")
1379
2126
  },
1380
2127
  handler: async (args, ctx) => {
1381
2128
  const teamId = await ctx.resolveTeamId();
@@ -1402,21 +2149,30 @@ defineTool({
1402
2149
  " - dockerfile_path (optional): path to Dockerfile relative to root_directory. Pass null to clear.",
1403
2150
  " - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
1404
2151
  " - instance_count (optional): integer \u22651 \u2014 pin both min and max instances to this value.",
2152
+ ' - log_filter_rules (optional): list of { pattern, action } rules applied to runtime logs at query time. Pattern matches the message by case-insensitive substring; action is "drop" (filter out) or "downgrade" (flip stderr \u2192 stdout so it stops looking like an error). Pass [] to clear all rules. Capped at 50 rules.',
1405
2153
  "",
1406
2154
  "Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
1407
2155
  "",
1408
2156
  'Example: update_service_config({ service_id: "svc_abc", start_command: "bun apps/api/src/index.ts" }) \u2192 { service: { startCommand: "bun apps/api/src/index.ts", \u2026 } }'
1409
2157
  ].join("\n"),
1410
2158
  input: {
1411
- service_id: z10.string().describe("Service publicId."),
1412
- install_command: z10.string().nullable().optional().describe("Install shell command. Null clears."),
1413
- build_command: z10.string().nullable().optional().describe("Build shell command. Null clears."),
1414
- start_command: z10.string().nullable().optional().describe("Start shell command. Null clears."),
1415
- branch: z10.string().optional().describe("Git branch to track."),
1416
- root_directory: z10.string().optional().describe("Build context root."),
1417
- dockerfile_path: z10.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
1418
- auto_deploy: z10.boolean().optional().describe("Auto-deploy on push."),
1419
- instance_count: z10.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350).")
2159
+ service_id: z13.string().describe("Service publicId."),
2160
+ install_command: z13.string().nullable().optional().describe("Install shell command. Null clears."),
2161
+ build_command: z13.string().nullable().optional().describe("Build shell command. Null clears."),
2162
+ start_command: z13.string().nullable().optional().describe("Start shell command. Null clears."),
2163
+ branch: z13.string().optional().describe("Git branch to track."),
2164
+ root_directory: z13.string().optional().describe("Build context root."),
2165
+ dockerfile_path: z13.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
2166
+ auto_deploy: z13.boolean().optional().describe("Auto-deploy on push."),
2167
+ instance_count: z13.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
2168
+ log_filter_rules: z13.array(
2169
+ z13.object({
2170
+ pattern: z13.string().min(1).max(200),
2171
+ action: z13.enum(["drop", "downgrade"])
2172
+ })
2173
+ ).max(50).optional().describe(
2174
+ "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
2175
+ )
1420
2176
  },
1421
2177
  handler: async (args, ctx) => {
1422
2178
  const teamId = await ctx.resolveTeamId();
@@ -1428,14 +2184,16 @@ defineTool({
1428
2184
  if (args.dockerfile_path !== void 0)
1429
2185
  serviceUpdate["dockerfilePath"] = args.dockerfile_path;
1430
2186
  if (args.branch !== void 0) serviceUpdate["branch"] = args.branch;
1431
- if (args.root_directory !== void 0)
1432
- serviceUpdate["rootDirectory"] = args.root_directory;
2187
+ if (args.root_directory !== void 0) serviceUpdate["rootDirectory"] = args.root_directory;
1433
2188
  if (args.auto_deploy !== void 0) serviceUpdate["autoDeploy"] = args.auto_deploy;
1434
2189
  const configUpdate = {};
1435
2190
  if (args.instance_count !== void 0) {
1436
2191
  configUpdate["minInstances"] = args.instance_count;
1437
2192
  configUpdate["maxInstances"] = args.instance_count;
1438
2193
  }
2194
+ if (args.log_filter_rules !== void 0) {
2195
+ configUpdate["logFilterRules"] = args.log_filter_rules;
2196
+ }
1439
2197
  if (Object.keys(serviceUpdate).length === 0 && Object.keys(configUpdate).length === 0) {
1440
2198
  return respond({ summary: "No fields to update.", data: {} });
1441
2199
  }
@@ -1481,7 +2239,7 @@ defineTool({
1481
2239
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
1482
2240
  ].join("\n"),
1483
2241
  input: {
1484
- service_id: z10.string().describe("Service publicId.")
2242
+ service_id: z13.string().describe("Service publicId.")
1485
2243
  },
1486
2244
  handler: async (args, ctx) => {
1487
2245
  const teamId = await ctx.resolveTeamId();
@@ -1505,7 +2263,7 @@ defineTool({
1505
2263
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
1506
2264
  ].join("\n"),
1507
2265
  input: {
1508
- service_id: z10.string().describe("Service publicId.")
2266
+ service_id: z13.string().describe("Service publicId.")
1509
2267
  },
1510
2268
  handler: async (args, ctx) => {
1511
2269
  const teamId = await ctx.resolveTeamId();
@@ -1526,11 +2284,11 @@ defineTool({
1526
2284
  " - lines (optional): tail size (default 200, max 1000).",
1527
2285
  ' - since/until (optional): ISO-8601 timestamp OR a relative offset like "-5m", "-1h", "-2d".',
1528
2286
  ' - stream (optional): "stdout" | "stderr". Omit to combine.',
1529
- " - level (optional): friendly synonym \u2014 info/debug \u2192 stdout, warn/error \u2192 stderr.",
2287
+ " - level (optional): real log level \u2014 trace/debug/info/warn/error/fatal. For structured JSON logs (pino, bunyan, OpenTelemetry severity) this filters on the parsed inner level field. For plain text logs it falls back to a stream-alias hint (info/debug \u2192 stdout, warn/error/fatal \u2192 stderr).",
1530
2288
  " - search (optional): case-insensitive substring grep, \u2264100 chars.",
1531
2289
  ' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
1532
2290
  "",
1533
- "Returns: { logs: LogEntry[] | string } when count_only is false (each entry has { timestamp, level?, stream?, message }), or { count: number } when count_only is true.",
2291
+ "Returns: { logs: LogEntry[] | string } when count_only is false. Each entry has { timestamp, level?, stream, message }. `level` is the parsed inner level when the message is a structured JSON envelope (pino numeric or string), and undefined for plain-text logs. `stream` is always one of stdout/stderr. Or { count: number } when count_only is true.",
1534
2292
  "",
1535
2293
  'Example: get_service_logs({ service_id: "svc_abc", search: "OOM", since: "-15m" }) \u2192 { logs: [...] }.',
1536
2294
  "",
@@ -1539,14 +2297,16 @@ defineTool({
1539
2297
  ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
1540
2298
  ].join("\n"),
1541
2299
  input: {
1542
- service_id: z10.string().describe("Service publicId."),
1543
- lines: z10.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
1544
- since: z10.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
1545
- until: z10.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
1546
- stream: z10.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
1547
- level: z10.enum(["stdout", "stderr", "info", "warn", "error", "debug"]).optional().describe("Friendly stream alias: info/debug\u2192stdout, warn/error\u2192stderr."),
1548
- search: z10.string().max(100).optional().describe("Case-insensitive substring filter."),
1549
- count_only: z10.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
2300
+ service_id: z13.string().describe("Service publicId."),
2301
+ lines: z13.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
2302
+ since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2303
+ until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
2304
+ stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
2305
+ level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
2306
+ "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)."
2307
+ ),
2308
+ search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
2309
+ count_only: z13.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
1550
2310
  },
1551
2311
  handler: async (args, ctx) => {
1552
2312
  const teamId = await ctx.resolveTeamId();
@@ -1573,9 +2333,82 @@ defineTool({
1573
2333
  });
1574
2334
  }
1575
2335
  });
2336
+ defineTool({
2337
+ name: "get_service_logs_bulk",
2338
+ category: "logs",
2339
+ description: [
2340
+ "Fetch runtime logs for multiple services in parallel and return one response keyed by service_id. Single round-trip from the MCP client perspective.",
2341
+ "",
2342
+ 'When to use: investigating something that spans a project \u2014 "what does every service in the auth-fleet say about the OOM at 14:00?", "did anything log an error in the last 5 minutes across these 4 services?". Pair with list_services (filter by project/status) to get the service_ids list first.',
2343
+ "",
2344
+ "Inputs:",
2345
+ " - service_ids: list of service publicIds (svc_\u2026). Hard cap 10 to bound work \u2014 for larger fleets, partition the call.",
2346
+ " - lines_per_service (optional): tail size per service (default 100, max 500). Smaller default than get_service_logs since you are fanning out.",
2347
+ ' - since/until (optional): same shape as get_service_logs (ISO-8601 or "-5m", "-1h").',
2348
+ ' - stream (optional): "stdout" | "stderr".',
2349
+ " - level (optional): structured-log level filter (trace/debug/info/warn/error/fatal).",
2350
+ " - search (optional): case-insensitive substring filter applied to every service.",
2351
+ ' - count_only (optional): when true, return { results: { [service_id]: { count } } } \u2014 much cheaper for "how many error lines per service in the last 5m" surveys.',
2352
+ "",
2353
+ "Returns: { results: { [service_id]: { logs?, count?, error? } } } \u2014 per-service result, with `error` populated when one service failed (the rest still succeed).",
2354
+ "",
2355
+ '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 } } }.'
2356
+ ].join("\n"),
2357
+ input: {
2358
+ service_ids: z13.array(z13.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
2359
+ lines_per_service: z13.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
2360
+ since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2361
+ until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
2362
+ stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
2363
+ level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
2364
+ search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
2365
+ count_only: z13.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
2366
+ },
2367
+ handler: async (args, ctx) => {
2368
+ const teamId = await ctx.resolveTeamId();
2369
+ const linesPerService = args.lines_per_service ?? 100;
2370
+ const baseOpts = { lines: linesPerService };
2371
+ if (args.since) baseOpts.since = args.since;
2372
+ if (args.until) baseOpts.until = args.until;
2373
+ if (args.stream) baseOpts.stream = args.stream;
2374
+ if (args.level) baseOpts.level = args.level;
2375
+ if (args.search) baseOpts.search = args.search;
2376
+ if (args.count_only) baseOpts.countOnly = args.count_only;
2377
+ const settled = await Promise.allSettled(
2378
+ args.service_ids.map(async (sid) => {
2379
+ const response = await ctx.hoststack.services.getRuntimeLogs(teamId, sid, baseOpts);
2380
+ return { sid, response };
2381
+ })
2382
+ );
2383
+ const results = {};
2384
+ let okCount = 0;
2385
+ let errorCount = 0;
2386
+ for (let i = 0; i < settled.length; i += 1) {
2387
+ const outcome = settled[i];
2388
+ const sid = args.service_ids[i];
2389
+ if (outcome.status === "fulfilled") {
2390
+ const { response } = outcome.value;
2391
+ if ("count" in response) {
2392
+ results[sid] = { count: response.count };
2393
+ } else {
2394
+ results[sid] = { logs: response.logs };
2395
+ }
2396
+ okCount += 1;
2397
+ } else {
2398
+ const reason = outcome.reason;
2399
+ results[sid] = {
2400
+ error: typeof reason === "string" ? reason : reason?.message ?? "Unknown error"
2401
+ };
2402
+ errorCount += 1;
2403
+ }
2404
+ }
2405
+ const summary = errorCount === 0 ? `Fetched logs from ${okCount} service${okCount === 1 ? "" : "s"}.` : `Fetched logs from ${okCount} of ${args.service_ids.length} service${args.service_ids.length === 1 ? "" : "s"} (${errorCount} failed \u2014 see per-service .error field).`;
2406
+ return respond({ summary, data: { results } });
2407
+ }
2408
+ });
1576
2409
 
1577
2410
  // src/tools/volumes.ts
1578
- import { z as z11 } from "zod";
2411
+ import { z as z14 } from "zod";
1579
2412
  defineTool({
1580
2413
  name: "list_volumes",
1581
2414
  category: "volumes",
@@ -1592,7 +2425,7 @@ defineTool({
1592
2425
  'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
1593
2426
  ].join("\n"),
1594
2427
  input: {
1595
- service_id: z11.string().describe("Service publicId (e.g. svc_abc123).")
2428
+ service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
1596
2429
  },
1597
2430
  handler: async (args, ctx) => {
1598
2431
  const teamId = await ctx.resolveTeamId();
@@ -1621,10 +2454,10 @@ defineTool({
1621
2454
  '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: "active" } }'
1622
2455
  ].join("\n"),
1623
2456
  input: {
1624
- service_id: z11.string().describe("Service publicId."),
1625
- name: z11.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
1626
- mount_path: z11.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
1627
- size_gb: z11.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
2457
+ service_id: z14.string().describe("Service publicId."),
2458
+ name: z14.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
2459
+ mount_path: z14.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
2460
+ size_gb: z14.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
1628
2461
  },
1629
2462
  handler: async (args, ctx) => {
1630
2463
  const teamId = await ctx.resolveTeamId();
@@ -1660,10 +2493,10 @@ defineTool({
1660
2493
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
1661
2494
  ].join("\n"),
1662
2495
  input: {
1663
- service_id: z11.string().describe("Service publicId."),
1664
- volume_id: z11.string().describe("Volume publicId (e.g. vol_\u2026)."),
1665
- mount_path: z11.string().startsWith("/").max(500).optional().describe("New mount path."),
1666
- size_gb: z11.number().int().min(1).max(100).optional().describe("New size in GB.")
2496
+ service_id: z14.string().describe("Service publicId."),
2497
+ volume_id: z14.string().describe("Volume publicId (e.g. vol_\u2026)."),
2498
+ mount_path: z14.string().startsWith("/").max(500).optional().describe("New mount path."),
2499
+ size_gb: z14.number().int().min(1).max(100).optional().describe("New size in GB.")
1667
2500
  },
1668
2501
  handler: async (args, ctx) => {
1669
2502
  const teamId = await ctx.resolveTeamId();
@@ -1701,8 +2534,8 @@ defineTool({
1701
2534
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
1702
2535
  ].join("\n"),
1703
2536
  input: {
1704
- service_id: z11.string().describe("Service publicId."),
1705
- volume_id: z11.string().describe("Volume publicId.")
2537
+ service_id: z14.string().describe("Service publicId."),
2538
+ volume_id: z14.string().describe("Volume publicId.")
1706
2539
  },
1707
2540
  handler: async (args, ctx) => {
1708
2541
  const teamId = await ctx.resolveTeamId();
@@ -1716,7 +2549,7 @@ defineTool({
1716
2549
 
1717
2550
  // src/server-factory.ts
1718
2551
  var PACKAGE_NAME = "hoststack";
1719
- var PACKAGE_VERSION = "0.1.2";
2552
+ var PACKAGE_VERSION = "0.6.0";
1720
2553
  function createMcpServer(options) {
1721
2554
  const baseUrl = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
1722
2555
  const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl });