@hoststack.dev/mcp 0.3.0 → 0.5.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.
@@ -415,8 +415,9 @@ defineTool({
415
415
  ' - action: filter to a specific action (e.g. "service.created", "deploy.triggered").',
416
416
  ' - resource_type: filter by resource type (e.g. "service", "deploy", "domain").',
417
417
  " - user_id: filter by acting user numeric ID.",
418
+ ' - 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).',
418
419
  "",
419
- "Returns: { items: ActivityLogEntry[], meta? } \u2014 each entry has id, action, resourceType, resourceId, actorEmail, ipAddress, createdAt, and a context payload.",
420
+ "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.",
420
421
  "",
421
422
  'Example: list_activity_log({ resource_type: "deploy", per_page: 10 }) \u2192 { items: [{ action: "deploy.triggered", actorEmail: "ada@\u2026", \u2026 }], meta: { total: 47, page: 1 } }'
422
423
  ].join("\n"),
@@ -425,7 +426,9 @@ defineTool({
425
426
  per_page: z2.number().int().positive().max(100).optional().describe("Items per page, hard cap 100."),
426
427
  action: z2.string().optional().describe('Action filter, e.g. "service.created".'),
427
428
  resource_type: z2.string().optional().describe("Resource type filter."),
428
- user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter.")
429
+ user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter."),
430
+ since: z2.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-15m", "-2h", "-7d").'),
431
+ until: z2.string().optional().describe("ISO-8601 timestamp or relative offset upper bound.")
429
432
  },
430
433
  handler: async (args2, ctx) => {
431
434
  const teamId = await ctx.resolveTeamId();
@@ -435,13 +438,24 @@ defineTool({
435
438
  if (args2.action !== void 0) params["action"] = args2.action;
436
439
  if (args2.resource_type !== void 0) params["resourceType"] = args2.resource_type;
437
440
  if (args2.user_id !== void 0) params["userId"] = String(args2.user_id);
441
+ if (args2.since !== void 0) params["since"] = args2.since;
442
+ if (args2.until !== void 0) params["until"] = args2.until;
438
443
  const response = await ctx.api.get(
439
444
  `/api/activity-log/${teamId}`,
440
445
  params
441
446
  );
442
447
  const items = Array.isArray(response.data) ? response.data.map(shapeActivity) : [];
443
448
  const data = { items };
444
- if (response.meta !== void 0) data.meta = shape(response.meta);
449
+ if (response.meta !== void 0) {
450
+ data.meta = shape(response.meta);
451
+ } else if (response.page !== void 0 || response.perPage !== void 0 || response.total !== void 0 || response.totalPages !== void 0) {
452
+ data.meta = shape({
453
+ page: response.page,
454
+ perPage: response.perPage,
455
+ total: response.total,
456
+ totalPages: response.totalPages
457
+ });
458
+ }
445
459
  return respond({
446
460
  summary: items.length === 0 ? "No activity log entries match the given filters." : `Returned ${items.length} activity log entr${items.length === 1 ? "y" : "ies"}.`,
447
461
  data
@@ -449,8 +463,51 @@ defineTool({
449
463
  }
450
464
  });
451
465
 
452
- // src/tools/cron.ts
466
+ // src/tools/alerts.ts
453
467
  import { z as z3 } from "zod";
468
+ defineTool({
469
+ name: "list_alerts",
470
+ category: "alerts",
471
+ description: [
472
+ "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.",
473
+ "",
474
+ "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.",
475
+ "",
476
+ '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.',
477
+ "",
478
+ "Inputs (all optional):",
479
+ ' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h.',
480
+ " - until: ISO-8601 upper bound (ignored when aggregating \u2014 aggregated view always extends to now).",
481
+ " - limit: max rows (default 100, hard cap 500).",
482
+ " - aggregate: true (default) collapses by (action, resourceId); false returns raw rows.",
483
+ "",
484
+ "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.",
485
+ "",
486
+ "Example: list_alerts({ since: '-1h' }) \u2192 { alerts: [{ action: 'deploy.failed', resourceId: 31, severity: 'error', count: 3, lastFiredAt: '\u2026', \u2026 }] }."
487
+ ].join("\n"),
488
+ input: {
489
+ since: z3.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-1h", "-2d"). Default: -24h.'),
490
+ until: z3.string().optional().describe("ISO-8601 upper bound. Only honored when aggregate=false."),
491
+ limit: z3.number().int().positive().max(500).optional().describe("Max rows (default 100, hard cap 500)."),
492
+ aggregate: z3.boolean().optional().describe("Collapse by (action, resourceId). Default true.")
493
+ },
494
+ handler: async (args2, ctx) => {
495
+ const teamId = await ctx.resolveTeamId();
496
+ const params = {};
497
+ if (args2.since !== void 0) params["since"] = args2.since;
498
+ if (args2.until !== void 0) params["until"] = args2.until;
499
+ if (args2.limit !== void 0) params["limit"] = String(args2.limit);
500
+ if (args2.aggregate === false) params["aggregate"] = "0";
501
+ const response = await ctx.api.get(`/api/alerts/${teamId}`, params);
502
+ const items = Array.isArray(response.alerts) ? response.alerts.map(shape) : [];
503
+ const aggregated = Boolean(response.aggregated);
504
+ 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"}.`;
505
+ return respond({ summary, data: { alerts: items, aggregated } });
506
+ }
507
+ });
508
+
509
+ // src/tools/cron.ts
510
+ import { z as z4 } from "zod";
454
511
  defineTool({
455
512
  name: "list_cron_executions",
456
513
  category: "cron",
@@ -468,8 +525,8 @@ defineTool({
468
525
  'Example: list_cron_executions({ service_id: "svc_cron" }) \u2192 { items: [{ status: "succeeded", exitCode: 0, \u2026 }, \u2026] }'
469
526
  ].join("\n"),
470
527
  input: {
471
- service_id: z3.string().describe("Cron service publicId."),
472
- limit: z3.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
528
+ service_id: z4.string().describe("Cron service publicId."),
529
+ limit: z4.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
473
530
  },
474
531
  handler: async (args2, ctx) => {
475
532
  const teamId = await ctx.resolveTeamId();
@@ -497,8 +554,8 @@ defineTool({
497
554
  'Example: get_cron_execution({ service_id: "svc_cron", execution_id: "exe_xyz" }) \u2192 { execution: { status: "failed", exitCode: 1, \u2026 } }'
498
555
  ].join("\n"),
499
556
  input: {
500
- service_id: z3.string().describe("Cron service publicId."),
501
- execution_id: z3.string().describe("Execution publicId.")
557
+ service_id: z4.string().describe("Cron service publicId."),
558
+ execution_id: z4.string().describe("Execution publicId.")
502
559
  },
503
560
  handler: async (args2, ctx) => {
504
561
  const teamId = await ctx.resolveTeamId();
@@ -510,7 +567,7 @@ defineTool({
510
567
  });
511
568
 
512
569
  // src/tools/databases.ts
513
- import { z as z4 } from "zod";
570
+ import { z as z5 } from "zod";
514
571
  defineTool({
515
572
  name: "list_databases",
516
573
  category: "databases",
@@ -527,7 +584,7 @@ defineTool({
527
584
  'Example: list_databases({ project_id: 12 }) \u2192 { items: [{ publicId: "db_\u2026", type: "postgres", status: "running", \u2026 }] }'
528
585
  ].join("\n"),
529
586
  input: {
530
- project_id: z4.number().int().positive().describe("Numeric project ID (from list_projects).")
587
+ project_id: z5.number().int().positive().describe("Numeric project ID (from list_projects).")
531
588
  },
532
589
  handler: async (args2, ctx) => {
533
590
  const teamId = await ctx.resolveTeamId();
@@ -537,6 +594,45 @@ defineTool({
537
594
  return respond({ summary, data });
538
595
  }
539
596
  });
597
+ defineTool({
598
+ name: "update_database",
599
+ category: "databases",
600
+ description: [
601
+ "Update a managed database \u2014 rename, change plan tier (memory/CPU), or grow disk size.",
602
+ "",
603
+ "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.",
604
+ "",
605
+ "Inputs:",
606
+ ' - database_id: publicId of the database (e.g. "db_\u2026").',
607
+ " - name (optional): new database name.",
608
+ ' - plan (optional): "free" | "starter" | "standard" | "pro" \u2014 changes memory tier.',
609
+ " - disk_size_gb (optional): new disk size in GB (must be \u2265 current).",
610
+ "",
611
+ "Returns: { database: Database } \u2014 the updated record.",
612
+ "",
613
+ 'Example: update_database({ database_id: "db_xyz", disk_size_gb: 50 }) \u2192 { database: { diskSizeGb: 50, \u2026 } }'
614
+ ].join("\n"),
615
+ input: {
616
+ database_id: z5.string().describe("Database publicId (e.g. db_xyz)."),
617
+ name: z5.string().min(1).max(100).optional().describe("New database name."),
618
+ plan: z5.enum(["free", "starter", "standard", "pro"]).optional().describe("Plan tier (memory/CPU)."),
619
+ disk_size_gb: z5.number().int().min(1).max(1024).optional().describe("New disk size in GB. Must be \u2265 current.")
620
+ },
621
+ handler: async (args2, ctx) => {
622
+ const teamId = await ctx.resolveTeamId();
623
+ const input = {};
624
+ if (args2.name !== void 0) input.name = args2.name;
625
+ if (args2.plan !== void 0) input.plan = args2.plan;
626
+ if (args2.disk_size_gb !== void 0) input.diskSizeGb = args2.disk_size_gb;
627
+ if (Object.keys(input).length === 0) {
628
+ return respond({ summary: "No fields to update.", data: {} });
629
+ }
630
+ const response = await ctx.hoststack.databases.update(teamId, args2.database_id, input);
631
+ const data = { database: shapeDatabase(response.database) };
632
+ const fields = Object.keys(input).join(", ");
633
+ return respond({ summary: `Updated ${fields} on database ${args2.database_id}.`, data });
634
+ }
635
+ });
540
636
  defineTool({
541
637
  name: "get_database",
542
638
  category: "databases",
@@ -553,7 +649,7 @@ defineTool({
553
649
  'Example: get_database({ database_id: "db_xyz" }) \u2192 { database: { type: "postgres", version: "16", status: "running", \u2026 } }'
554
650
  ].join("\n"),
555
651
  input: {
556
- database_id: z4.string().describe("Database publicId (e.g. db_xyz).")
652
+ database_id: z5.string().describe("Database publicId (e.g. db_xyz).")
557
653
  },
558
654
  handler: async (args2, ctx) => {
559
655
  const teamId = await ctx.resolveTeamId();
@@ -566,7 +662,7 @@ defineTool({
566
662
  });
567
663
 
568
664
  // src/tools/deploys.ts
569
- import { z as z5 } from "zod";
665
+ import { z as z6 } from "zod";
570
666
  defineTool({
571
667
  name: "list_deploys",
572
668
  category: "deploys",
@@ -578,12 +674,12 @@ defineTool({
578
674
  "Inputs:",
579
675
  ' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
580
676
  "",
581
- "Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt, durationMs.",
677
+ "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).",
582
678
  "",
583
679
  'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
584
680
  ].join("\n"),
585
681
  input: {
586
- service_id: z5.string().describe("Service publicId (e.g. svc_abc123).")
682
+ service_id: z6.string().describe("Service publicId (e.g. svc_abc123).")
587
683
  },
588
684
  handler: async (args2, ctx) => {
589
685
  const teamId = await ctx.resolveTeamId();
@@ -605,13 +701,13 @@ defineTool({
605
701
  " - service_id: publicId of the service.",
606
702
  ' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
607
703
  "",
608
- "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, durationMs, finishedAt, etc).",
704
+ "Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, buildDurationMs, totalDurationMs, finishedAt, etc).",
609
705
  "",
610
706
  'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
611
707
  ].join("\n"),
612
708
  input: {
613
- service_id: z5.string().describe("Service publicId."),
614
- deploy_id: z5.string().describe("Deploy publicId.")
709
+ service_id: z6.string().describe("Service publicId."),
710
+ deploy_id: z6.string().describe("Deploy publicId.")
615
711
  },
616
712
  handler: async (args2, ctx) => {
617
713
  const teamId = await ctx.resolveTeamId();
@@ -638,8 +734,8 @@ defineTool({
638
734
  'Example: trigger_deploy({ service_id: "svc_abc" }) \u2192 { deploy: { publicId: "dpl_\u2026", status: "pending", \u2026 } }'
639
735
  ].join("\n"),
640
736
  input: {
641
- service_id: z5.string().describe("Service publicId."),
642
- clear_cache: z5.boolean().optional().describe("Clear the build cache (default false).")
737
+ service_id: z6.string().describe("Service publicId."),
738
+ clear_cache: z6.boolean().optional().describe("Clear the build cache (default false).")
643
739
  },
644
740
  handler: async (args2, ctx) => {
645
741
  const teamId = await ctx.resolveTeamId();
@@ -671,8 +767,8 @@ defineTool({
671
767
  'Example: cancel_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { ok: true }'
672
768
  ].join("\n"),
673
769
  input: {
674
- service_id: z5.string().describe("Service publicId."),
675
- deploy_id: z5.string().describe("Deploy publicId.")
770
+ service_id: z6.string().describe("Service publicId."),
771
+ deploy_id: z6.string().describe("Deploy publicId.")
676
772
  },
677
773
  handler: async (args2, ctx) => {
678
774
  const teamId = await ctx.resolveTeamId();
@@ -700,10 +796,10 @@ defineTool({
700
796
  '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 }'
701
797
  ].join("\n"),
702
798
  input: {
703
- service_id: z5.string().describe("Service publicId."),
704
- deploy_id: z5.string().describe("Deploy publicId."),
705
- after_id: z5.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
706
- limit: z5.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
799
+ service_id: z6.string().describe("Service publicId."),
800
+ deploy_id: z6.string().describe("Deploy publicId."),
801
+ after_id: z6.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
802
+ limit: z6.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
707
803
  },
708
804
  handler: async (args2, ctx) => {
709
805
  const teamId = await ctx.resolveTeamId();
@@ -724,9 +820,90 @@ defineTool({
724
820
  });
725
821
  }
726
822
  });
823
+ defineTool({
824
+ name: "diagnose_deploy",
825
+ category: "deploys",
826
+ description: [
827
+ "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.",
828
+ "",
829
+ "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.",
830
+ "",
831
+ "Inputs:",
832
+ " - service_id: publicId of the service.",
833
+ " - deploy_id: publicId of the deploy to diagnose.",
834
+ " - build_log_lines (optional): how many trailing build log lines to include. Default 200, max 2000.",
835
+ " - 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).",
836
+ "",
837
+ "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.",
838
+ "",
839
+ 'Example: diagnose_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 all three sections in one call.'
840
+ ].join("\n"),
841
+ input: {
842
+ service_id: z6.string().describe("Service publicId."),
843
+ deploy_id: z6.string().describe("Deploy publicId."),
844
+ build_log_lines: z6.number().int().min(1).max(2e3).optional().describe("Trailing build log lines. Default 200, max 2000."),
845
+ runtime_log_lines: z6.number().int().min(0).max(1e3).optional().describe(
846
+ "Trailing runtime log lines since deploy start. Default 100, max 1000. 0 = skip."
847
+ )
848
+ },
849
+ handler: async (args2, ctx) => {
850
+ const teamId = await ctx.resolveTeamId();
851
+ const buildLimit = args2.build_log_lines ?? 200;
852
+ const runtimeLimit = args2.runtime_log_lines ?? 100;
853
+ const [deployResp, buildResp] = await Promise.all([
854
+ ctx.hoststack.deploys.get(teamId, args2.service_id, args2.deploy_id),
855
+ ctx.hoststack.deploys.getLogs(teamId, args2.service_id, args2.deploy_id, {
856
+ limit: buildLimit
857
+ })
858
+ ]);
859
+ const buildEntries = buildResp.logs;
860
+ const buildLogs = buildEntries.map((e) => e.message).join("\n");
861
+ const deployShape = shapeDeploy(deployResp.deploy);
862
+ const deployStatus = deployShape && "status" in deployShape ? deployShape.status : "unknown";
863
+ const startedAtRaw = deployShape && "startedAt" in deployShape ? deployShape.startedAt : void 0;
864
+ const startedAt = typeof startedAtRaw === "string" ? startedAtRaw : void 0;
865
+ const data = {
866
+ deploy: deployShape,
867
+ build: { logs: buildLogs, lineCount: buildEntries.length }
868
+ };
869
+ const hadContainer = deployStatus !== "pending" && deployStatus !== "building";
870
+ if (runtimeLimit > 0 && hadContainer) {
871
+ const runtimeResp = await ctx.hoststack.services.getRuntimeLogs(
872
+ teamId,
873
+ args2.service_id,
874
+ {
875
+ lines: runtimeLimit,
876
+ ...startedAt ? { since: startedAt } : {}
877
+ }
878
+ );
879
+ if ("count" in runtimeResp) {
880
+ data["runtime"] = { logs: "", lineCount: runtimeResp.count };
881
+ } else {
882
+ const runtimeEntries = Array.isArray(runtimeResp.logs) ? runtimeResp.logs : typeof runtimeResp.logs === "string" ? runtimeResp.logs.split("\n").map((line) => ({ message: line })) : [];
883
+ const runtimeText = runtimeEntries.map(
884
+ (e) => typeof e === "object" && "message" in e ? String(e.message) : String(e)
885
+ ).join("\n");
886
+ data["runtime"] = {
887
+ logs: runtimeText,
888
+ lineCount: runtimeEntries.length,
889
+ sinceWindow: startedAt ?? null
890
+ };
891
+ }
892
+ }
893
+ const failureSummary = shape({
894
+ status: deployStatus,
895
+ errorMessage: deployShape && "errorMessage" in deployShape ? deployShape.errorMessage : void 0
896
+ });
897
+ data["summary"] = failureSummary;
898
+ return respond({
899
+ summary: `Diagnostic bundle for deploy ${args2.deploy_id} (${deployStatus}): ${buildEntries.length} build log line${buildEntries.length === 1 ? "" : "s"}${runtimeLimit > 0 && hadContainer ? ", runtime log tail included" : ""}.`,
900
+ data
901
+ });
902
+ }
903
+ });
727
904
 
728
905
  // src/tools/domains.ts
729
- import { z as z6 } from "zod";
906
+ import { z as z7 } from "zod";
730
907
  defineTool({
731
908
  name: "list_domains",
732
909
  category: "domains",
@@ -765,8 +942,8 @@ defineTool({
765
942
  'Example: add_domain({ hostname: "api.example.com", service_id: "svc_abc" }) \u2192 { domain: { hostname: "api.example.com", dnsTargets: [...], verified: false, \u2026 } }'
766
943
  ].join("\n"),
767
944
  input: {
768
- hostname: z6.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
769
- service_id: z6.string().optional().describe("Optional service publicId to bind to.")
945
+ hostname: z7.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
946
+ service_id: z7.string().optional().describe("Optional service publicId to bind to.")
770
947
  },
771
948
  handler: async (args2, ctx) => {
772
949
  const teamId = await ctx.resolveTeamId();
@@ -796,12 +973,15 @@ defineTool({
796
973
  'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
797
974
  ].join("\n"),
798
975
  input: {
799
- domain_id: z6.string().describe("Domain publicId.")
976
+ domain_id: z7.string().describe("Domain publicId.")
800
977
  },
801
978
  handler: async (args2, ctx) => {
802
979
  const teamId = await ctx.resolveTeamId();
803
980
  await ctx.hoststack.domains.verify(teamId, args2.domain_id);
804
- return respond({ summary: `Triggered DNS verification for ${args2.domain_id}.`, data: { ok: true } });
981
+ return respond({
982
+ summary: `Triggered DNS verification for ${args2.domain_id}.`,
983
+ data: { ok: true }
984
+ });
805
985
  }
806
986
  });
807
987
  defineTool({
@@ -820,7 +1000,7 @@ defineTool({
820
1000
  'Example: remove_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
821
1001
  ].join("\n"),
822
1002
  input: {
823
- domain_id: z6.string().describe("Domain publicId.")
1003
+ domain_id: z7.string().describe("Domain publicId.")
824
1004
  },
825
1005
  handler: async (args2, ctx) => {
826
1006
  const teamId = await ctx.resolveTeamId();
@@ -830,7 +1010,7 @@ defineTool({
830
1010
  });
831
1011
 
832
1012
  // src/tools/env-vars.ts
833
- import { z as z7 } from "zod";
1013
+ import { z as z8 } from "zod";
834
1014
  defineTool({
835
1015
  name: "list_env_vars",
836
1016
  category: "env-vars",
@@ -847,7 +1027,7 @@ defineTool({
847
1027
  '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 }] }'
848
1028
  ].join("\n"),
849
1029
  input: {
850
- service_id: z7.string().describe("Service publicId.")
1030
+ service_id: z8.string().describe("Service publicId.")
851
1031
  },
852
1032
  handler: async (args2, ctx) => {
853
1033
  const teamId = await ctx.resolveTeamId();
@@ -876,10 +1056,10 @@ defineTool({
876
1056
  '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" }'
877
1057
  ].join("\n"),
878
1058
  input: {
879
- service_id: z7.string().describe("Service publicId."),
880
- key: z7.string().min(1).max(128).describe("Env-var key."),
881
- value: z7.string().describe("New value."),
882
- is_secret: z7.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
1059
+ service_id: z8.string().describe("Service publicId."),
1060
+ key: z8.string().min(1).max(128).describe("Env-var key."),
1061
+ value: z8.string().describe("New value."),
1062
+ is_secret: z8.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
883
1063
  },
884
1064
  handler: async (args2, ctx) => {
885
1065
  const teamId = await ctx.resolveTeamId();
@@ -887,10 +1067,15 @@ defineTool({
887
1067
  const existing = await ctx.hoststack.envVars.list(teamId, args2.service_id);
888
1068
  const match = existing.envVars.find((v) => v.key === args2.key);
889
1069
  if (match) {
890
- const response2 = await ctx.hoststack.envVars.update(teamId, args2.service_id, String(match.id), {
891
- value: args2.value,
892
- isSecret
893
- });
1070
+ const response2 = await ctx.hoststack.envVars.update(
1071
+ teamId,
1072
+ args2.service_id,
1073
+ String(match.id),
1074
+ {
1075
+ value: args2.value,
1076
+ isSecret
1077
+ }
1078
+ );
894
1079
  const data2 = {
895
1080
  envVar: shapeEnvVar(response2.envVar),
896
1081
  action: "updated"
@@ -926,18 +1111,18 @@ defineTool({
926
1111
  'Example: delete_env_var({ service_id: "svc_abc", key: "OLD_FLAG" }) \u2192 { ok: true }'
927
1112
  ].join("\n"),
928
1113
  input: {
929
- service_id: z7.string().describe("Service publicId."),
930
- key: z7.string().min(1).max(128).describe("Env-var key to delete.")
1114
+ service_id: z8.string().describe("Service publicId."),
1115
+ key: z8.string().min(1).max(128).describe("Env-var key to delete.")
931
1116
  },
932
1117
  handler: async (args2, ctx) => {
933
1118
  const teamId = await ctx.resolveTeamId();
934
1119
  const existing = await ctx.hoststack.envVars.list(teamId, args2.service_id);
935
1120
  const match = existing.envVars.find((v) => v.key === args2.key);
936
1121
  if (!match) {
937
- return respondError(
938
- `Env var "${args2.key}" not found on service ${args2.service_id}.`,
939
- { key: args2.key, service_id: args2.service_id }
940
- );
1122
+ return respondError(`Env var "${args2.key}" not found on service ${args2.service_id}.`, {
1123
+ key: args2.key,
1124
+ service_id: args2.service_id
1125
+ });
941
1126
  }
942
1127
  await ctx.hoststack.envVars.delete(teamId, args2.service_id, String(match.id));
943
1128
  return respond({
@@ -963,19 +1148,19 @@ defineTool({
963
1148
  '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 }'
964
1149
  ].join("\n"),
965
1150
  input: {
966
- service_id: z7.string().describe("Service publicId."),
967
- env_vars: z7.array(
968
- z7.object({
969
- key: z7.string().min(1).max(128),
970
- value: z7.string(),
971
- is_secret: z7.boolean().optional()
1151
+ service_id: z8.string().describe("Service publicId."),
1152
+ env_vars: z8.array(
1153
+ z8.object({
1154
+ key: z8.string().min(1).max(128),
1155
+ value: z8.string(),
1156
+ is_secret: z8.boolean().optional()
972
1157
  })
973
1158
  ).max(500).describe("Array of env-var rows. Hard cap 500.")
974
1159
  },
975
1160
  handler: async (args2, ctx) => {
976
1161
  const teamId = await ctx.resolveTeamId();
977
1162
  const payload = {
978
- envVars: args2.env_vars.map((v) => {
1163
+ vars: args2.env_vars.map((v) => {
979
1164
  const row = {
980
1165
  key: v.key,
981
1166
  value: v.value
@@ -992,6 +1177,146 @@ defineTool({
992
1177
  }
993
1178
  });
994
1179
 
1180
+ // src/tools/environments.ts
1181
+ import { z as z9 } from "zod";
1182
+ defineTool({
1183
+ name: "list_environments",
1184
+ category: "environments",
1185
+ description: [
1186
+ "List every environment for a project (production / staging / development / preview).",
1187
+ "",
1188
+ "When to use: the user wants to see what envs exist before creating a service in one or promoting a deploy. Every project has at least Production.",
1189
+ "",
1190
+ "Inputs:",
1191
+ ' - project_id: project publicId (e.g. "prj_abc123").',
1192
+ "",
1193
+ "Returns: { items: Environment[] } \u2014 id, publicId, name, type, isDefault, isProtected.",
1194
+ "",
1195
+ 'Example: list_environments({ project_id: "prj_abc" }) \u2192 { items: [{ name: "Production", type: "production", isDefault: true }, { name: "Staging", type: "staging" }] }'
1196
+ ].join("\n"),
1197
+ input: {
1198
+ project_id: z9.string().describe("Project publicId.")
1199
+ },
1200
+ handler: async (args2, ctx) => {
1201
+ const teamId = await ctx.resolveTeamId();
1202
+ const response = await ctx.hoststack.environments.list(teamId, args2.project_id);
1203
+ const data = shapeList(response, "environments", shape);
1204
+ const summary = data.items.length === 0 ? "No environments yet \u2014 every project should have Production by default." : `Found ${data.items.length} environment${data.items.length === 1 ? "" : "s"}.`;
1205
+ return respond({ summary, data });
1206
+ }
1207
+ });
1208
+ defineTool({
1209
+ name: "create_environment",
1210
+ category: "environments",
1211
+ description: [
1212
+ "Create a new environment in a project (e.g. Staging, QA, Preview).",
1213
+ "",
1214
+ "When to use: the user wants to add an env so they can run a sibling service alongside production for testing or staging before release.",
1215
+ "",
1216
+ "Inputs:",
1217
+ " - project_id: project publicId.",
1218
+ " - name: human-readable name (1\u201364 chars). Shown in the env switcher.",
1219
+ ' - type: "production" | "staging" | "development" | "preview". Determines the hostname suffix on services in this env (production stays clean; others get -staging / -dev / -preview).',
1220
+ " - is_protected (optional): require admin role for destructive actions in this env. Default false.",
1221
+ "",
1222
+ "Returns: { environment: Environment }.",
1223
+ "",
1224
+ 'Example: create_environment({ project_id: "prj_abc", name: "Staging", type: "staging" }) \u2192 { environment: { id: 7, publicId: "environment_\u2026", name: "Staging", type: "staging" } }'
1225
+ ].join("\n"),
1226
+ input: {
1227
+ project_id: z9.string().describe("Project publicId."),
1228
+ name: z9.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
1229
+ type: z9.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
1230
+ is_protected: z9.boolean().optional().describe("Require admin role for destructive actions. Default false.")
1231
+ },
1232
+ handler: async (args2, ctx) => {
1233
+ const teamId = await ctx.resolveTeamId();
1234
+ const input = {
1235
+ name: args2.name,
1236
+ type: args2.type
1237
+ };
1238
+ if (args2.is_protected !== void 0) input.isProtected = args2.is_protected;
1239
+ const response = await ctx.hoststack.environments.create(teamId, args2.project_id, input);
1240
+ const data = { environment: shape(response.environment) };
1241
+ return respond({
1242
+ summary: `Created environment "${args2.name}" (${args2.type}) in project ${args2.project_id}.`,
1243
+ data
1244
+ });
1245
+ }
1246
+ });
1247
+ defineTool({
1248
+ name: "delete_environment",
1249
+ category: "environments",
1250
+ description: [
1251
+ "Delete an environment from a project.",
1252
+ "",
1253
+ "When to use: an env is no longer used (e.g. tearing down a feature branch staging). Blocked when the env still has live services or databases attached \u2014 destroy or move them first. The default env (usually Production) cannot be deleted.",
1254
+ "",
1255
+ "Inputs:",
1256
+ " - project_id: project publicId.",
1257
+ " - environment_id: environment publicId or numeric id.",
1258
+ "",
1259
+ "Returns: { success: true } on success.",
1260
+ "",
1261
+ 'Example: delete_environment({ project_id: "prj_abc", environment_id: "environment_xyz" }) \u2192 { success: true }'
1262
+ ].join("\n"),
1263
+ input: {
1264
+ project_id: z9.string().describe("Project publicId."),
1265
+ environment_id: z9.union([z9.string(), z9.number()]).describe("Environment publicId or numeric id.")
1266
+ },
1267
+ handler: async (args2, ctx) => {
1268
+ const teamId = await ctx.resolveTeamId();
1269
+ await ctx.hoststack.environments.delete(teamId, args2.project_id, args2.environment_id);
1270
+ return respond({
1271
+ summary: `Deleted environment ${String(args2.environment_id)} from project ${args2.project_id}.`,
1272
+ data: { success: true }
1273
+ });
1274
+ }
1275
+ });
1276
+ defineTool({
1277
+ name: "promote_deploy",
1278
+ category: "environments",
1279
+ description: [
1280
+ "Promote a built deploy from one environment to another (build-once, run-many).",
1281
+ "",
1282
+ "When to use: the user has tested a deploy in staging and wants to ship that exact image to production without rebuilding. Atomic and image-based \u2014 same docker image runs on the sibling service in the target env.",
1283
+ "",
1284
+ "Inputs:",
1285
+ " - service_id: source service publicId (the one that owns the deploy).",
1286
+ " - deploy_id: source deploy publicId. Must have `dockerImageId` set (i.e. a successful build).",
1287
+ " - target_environment_id: env publicId or numeric id to promote into. Must be in the same project. Cannot be the source service's own env.",
1288
+ "",
1289
+ "Behaviour: if a sibling service with the same name already exists in the target env, the new deploy lands on it. Otherwise the API auto-clones the source service's build/runtime config into the target env first. Env-specific config (env vars, secret files, volumes, IP allowlists, custom domains) is NOT copied \u2014 those are per-env by design.",
1290
+ "",
1291
+ "Returns: { deploy: Deploy } \u2014 the new deploy on the target service.",
1292
+ "",
1293
+ '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" } }'
1294
+ ].join("\n"),
1295
+ input: {
1296
+ service_id: z9.string().describe("Source service publicId."),
1297
+ deploy_id: z9.string().describe("Source deploy publicId (must have a built image)."),
1298
+ target_environment_id: z9.union([z9.string(), z9.number()]).describe("Target environment publicId or numeric id.")
1299
+ },
1300
+ handler: async (args2, ctx) => {
1301
+ const teamId = await ctx.resolveTeamId();
1302
+ const targetEnvId = await ctx.hoststack.resolveId(args2.target_environment_id, {
1303
+ kind: "environment",
1304
+ teamId: await ctx.hoststack.resolveId(teamId, { kind: "team" })
1305
+ });
1306
+ const response = await ctx.hoststack.deploys.promote(
1307
+ teamId,
1308
+ args2.service_id,
1309
+ args2.deploy_id,
1310
+ targetEnvId
1311
+ );
1312
+ const data = { deploy: shape(response.deploy) };
1313
+ return respond({
1314
+ summary: `Promoted deploy ${args2.deploy_id} from service ${args2.service_id} to env ${String(args2.target_environment_id)}.`,
1315
+ data
1316
+ });
1317
+ }
1318
+ });
1319
+
995
1320
  // src/tools/meta.ts
996
1321
  defineTool({
997
1322
  name: "get_me",
@@ -1018,8 +1343,190 @@ defineTool({
1018
1343
  }
1019
1344
  });
1020
1345
 
1346
+ // src/tools/notifications.ts
1347
+ import { z as z10 } from "zod";
1348
+ var NOTIFICATION_EVENTS = [
1349
+ "deploy.started",
1350
+ "deploy.succeeded",
1351
+ "deploy.failed",
1352
+ "deploy.failed_consecutive",
1353
+ "service.created",
1354
+ "service.deleted",
1355
+ "service.suspended",
1356
+ "service.resumed",
1357
+ "service.restart_failed",
1358
+ "service.auto_suspended",
1359
+ "service.acme_cert_failed",
1360
+ "git.auth_failed"
1361
+ ];
1362
+ defineTool({
1363
+ name: "list_notification_channels",
1364
+ category: "alerts",
1365
+ description: [
1366
+ "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.",
1367
+ "",
1368
+ "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.",
1369
+ "",
1370
+ "Returns: { items: Channel[] } \u2014 each channel includes id, type, name, webhookUrl (masked), active, events, createdAt.",
1371
+ "",
1372
+ "Example: list_notification_channels() \u2192 { items: [{ id: 3, type: 'slack', name: 'eng-alerts', events: ['deploy.failed', 'git.auth_failed'], \u2026 }] }"
1373
+ ].join("\n"),
1374
+ input: {},
1375
+ handler: async (_args, ctx) => {
1376
+ const teamId = await ctx.resolveTeamId();
1377
+ const response = await ctx.hoststack.notifications.listChannels(teamId);
1378
+ const data = shapeList(response, "channels", shape);
1379
+ 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"}.`;
1380
+ return respond({ summary, data });
1381
+ }
1382
+ });
1383
+ defineTool({
1384
+ name: "create_notification_channel",
1385
+ category: "alerts",
1386
+ description: [
1387
+ "Create a Slack, Discord, or email notification channel and subscribe it to a list of events.",
1388
+ "",
1389
+ "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.",
1390
+ "",
1391
+ "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.",
1392
+ "",
1393
+ "Inputs:",
1394
+ ' - type: "slack" | "discord" | "email".',
1395
+ " - name: human-readable label (1\u2013128 chars).",
1396
+ " - webhook_url: Slack/Discord webhook URL OR email address.",
1397
+ " - 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).",
1398
+ "",
1399
+ "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.",
1400
+ "",
1401
+ "Returns: { channel: Channel }.",
1402
+ "",
1403
+ "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'] })"
1404
+ ].join("\n"),
1405
+ input: {
1406
+ type: z10.enum(["slack", "discord", "email"]).describe("Channel type."),
1407
+ name: z10.string().min(1).max(128).describe("Human-readable label."),
1408
+ webhook_url: z10.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
1409
+ events: z10.array(z10.enum(NOTIFICATION_EVENTS)).describe("List of events the channel subscribes to. Empty list = subscribe to nothing.")
1410
+ },
1411
+ handler: async (args2, ctx) => {
1412
+ const teamId = await ctx.resolveTeamId();
1413
+ const response = await ctx.hoststack.notifications.createChannel(teamId, {
1414
+ type: args2.type,
1415
+ name: args2.name,
1416
+ webhookUrl: args2.webhook_url,
1417
+ events: args2.events
1418
+ });
1419
+ const data = { channel: shape(response.channel) };
1420
+ return respond({
1421
+ summary: `Created ${args2.type} channel "${args2.name}" subscribed to ${args2.events.length} event${args2.events.length === 1 ? "" : "s"}.`,
1422
+ data
1423
+ });
1424
+ }
1425
+ });
1426
+ defineTool({
1427
+ name: "update_notification_channel",
1428
+ category: "alerts",
1429
+ description: [
1430
+ "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.",
1431
+ "",
1432
+ "When to use: silencing a noisy channel (active=false), adding a new event to an existing list, or relabeling.",
1433
+ "",
1434
+ "Inputs:",
1435
+ " - channel_id: numeric id of the channel.",
1436
+ " - name (optional): new label.",
1437
+ " - active (optional): false to silence, true to re-enable.",
1438
+ " - events (optional): replace the full subscription list. Passing [] silences all events without disabling the channel.",
1439
+ "",
1440
+ "Returns: { channel: Channel }.",
1441
+ "",
1442
+ "Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
1443
+ ].join("\n"),
1444
+ input: {
1445
+ channel_id: z10.number().int().positive().describe("Numeric channel id from list_notification_channels."),
1446
+ name: z10.string().min(1).max(128).optional().describe("New label."),
1447
+ active: z10.boolean().optional().describe("false silences without deleting."),
1448
+ events: z10.array(z10.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
1449
+ },
1450
+ handler: async (args2, ctx) => {
1451
+ const teamId = await ctx.resolveTeamId();
1452
+ const update = {};
1453
+ if (args2.name !== void 0) update.name = args2.name;
1454
+ if (args2.active !== void 0) update.active = args2.active;
1455
+ if (args2.events !== void 0) update.events = args2.events;
1456
+ if (Object.keys(update).length === 0) {
1457
+ return respond({ summary: "No fields to update.", data: {} });
1458
+ }
1459
+ const response = await ctx.hoststack.notifications.updateChannel(
1460
+ teamId,
1461
+ args2.channel_id,
1462
+ update
1463
+ );
1464
+ const data = { channel: shape(response.channel) };
1465
+ return respond({
1466
+ summary: `Updated notification channel #${args2.channel_id}.`,
1467
+ data
1468
+ });
1469
+ }
1470
+ });
1471
+ defineTool({
1472
+ name: "delete_notification_channel",
1473
+ category: "alerts",
1474
+ description: [
1475
+ "Permanently delete a notification channel. Use update_notification_channel({ active: false }) to silence without deleting if you might want it back later.",
1476
+ "",
1477
+ "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.",
1478
+ "",
1479
+ "Inputs:",
1480
+ " - channel_id: numeric id of the channel.",
1481
+ "",
1482
+ "Returns: { ok: true }.",
1483
+ "",
1484
+ "Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
1485
+ ].join("\n"),
1486
+ input: {
1487
+ channel_id: z10.number().int().positive().describe("Numeric channel id.")
1488
+ },
1489
+ handler: async (args2, ctx) => {
1490
+ const teamId = await ctx.resolveTeamId();
1491
+ await ctx.hoststack.notifications.deleteChannel(teamId, args2.channel_id);
1492
+ return respond({
1493
+ summary: `Deleted notification channel #${args2.channel_id}.`,
1494
+ data: { ok: true }
1495
+ });
1496
+ }
1497
+ });
1498
+ defineTool({
1499
+ name: "test_notification_channel",
1500
+ category: "alerts",
1501
+ description: [
1502
+ '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.',
1503
+ "",
1504
+ "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.",
1505
+ "",
1506
+ "Inputs:",
1507
+ " - channel_id: numeric id of the channel.",
1508
+ "",
1509
+ "Returns: { success: boolean, error?: string } \u2014 false + error string when the webhook returned non-2xx.",
1510
+ "",
1511
+ "Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
1512
+ ].join("\n"),
1513
+ input: {
1514
+ channel_id: z10.number().int().positive().describe("Numeric channel id.")
1515
+ },
1516
+ handler: async (args2, ctx) => {
1517
+ const teamId = await ctx.resolveTeamId();
1518
+ const response = await ctx.hoststack.notifications.testChannel(teamId, args2.channel_id);
1519
+ const data = shape(response);
1520
+ const okFlag = data && typeof data === "object" && "success" in data ? data.success : false;
1521
+ return respond({
1522
+ summary: okFlag ? `Test event delivered to channel #${args2.channel_id}.` : `Test event FAILED for channel #${args2.channel_id}: ${data.error ?? "unknown error"}.`,
1523
+ data
1524
+ });
1525
+ }
1526
+ });
1527
+
1021
1528
  // src/tools/projects.ts
1022
- import { z as z8 } from "zod";
1529
+ import { z as z11 } from "zod";
1023
1530
  defineTool({
1024
1531
  name: "list_projects",
1025
1532
  category: "projects",
@@ -1059,9 +1566,9 @@ defineTool({
1059
1566
  'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "fsn1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
1060
1567
  ].join("\n"),
1061
1568
  input: {
1062
- name: z8.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
1063
- description: z8.string().max(500).optional().describe("Short description (\u2264500 chars)."),
1064
- region: z8.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
1569
+ name: z11.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
1570
+ description: z11.string().max(500).optional().describe("Short description (\u2264500 chars)."),
1571
+ region: z11.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
1065
1572
  },
1066
1573
  handler: async (args2, ctx) => {
1067
1574
  const teamId = await ctx.resolveTeamId();
@@ -1092,9 +1599,9 @@ defineTool({
1092
1599
  'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
1093
1600
  ].join("\n"),
1094
1601
  input: {
1095
- project_id: z8.string().describe("Project publicId."),
1096
- name: z8.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
1097
- description: z8.string().max(500).optional().describe("New description (\u2264500 chars).")
1602
+ project_id: z11.string().describe("Project publicId."),
1603
+ name: z11.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
1604
+ description: z11.string().max(500).optional().describe("New description (\u2264500 chars).")
1098
1605
  },
1099
1606
  handler: async (args2, ctx) => {
1100
1607
  if (args2.name === void 0 && args2.description === void 0) {
@@ -1128,7 +1635,7 @@ defineTool({
1128
1635
  'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
1129
1636
  ].join("\n"),
1130
1637
  input: {
1131
- project_id: z8.string().describe("Project publicId (e.g. prj_abc123).")
1638
+ project_id: z11.string().describe("Project publicId (e.g. prj_abc123).")
1132
1639
  },
1133
1640
  handler: async (args2, ctx) => {
1134
1641
  const teamId = await ctx.resolveTeamId();
@@ -1140,25 +1647,59 @@ defineTool({
1140
1647
  });
1141
1648
 
1142
1649
  // src/tools/services.ts
1143
- import { z as z9 } from "zod";
1650
+ import { z as z12 } from "zod";
1144
1651
  defineTool({
1145
1652
  name: "list_services",
1146
1653
  category: "services",
1147
1654
  description: [
1148
- "List every service (web, worker, cron, private) in the active HostStack team.",
1655
+ "List services (web, worker, cron, private, static) in the active HostStack team.",
1149
1656
  "",
1150
1657
  "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.",
1151
1658
  "",
1659
+ "Inputs (all optional):",
1660
+ ' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
1661
+ ' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
1662
+ ' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
1663
+ ' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
1664
+ "",
1152
1665
  "Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
1153
1666
  "",
1154
- 'Example: list_services() \u2192 { items: [{ id: 31, publicId: "svc_\u2026", name: "api", type: "web", status: "running", \u2026 }] }'
1667
+ 'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
1155
1668
  ].join("\n"),
1156
- input: {},
1157
- handler: async (_args, ctx) => {
1669
+ input: {
1670
+ project_id: z12.union([z12.number().int().positive(), z12.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
1671
+ environment_id: z12.union([z12.number().int().positive(), z12.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
1672
+ status: z12.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
1673
+ type: z12.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
1674
+ },
1675
+ handler: async (args2, ctx) => {
1158
1676
  const teamId = await ctx.resolveTeamId();
1159
- const response = await ctx.hoststack.services.list(teamId);
1677
+ const filters = {};
1678
+ if (args2.project_id !== void 0) {
1679
+ const resolved = await ctx.hoststack.resolveId(args2.project_id, {
1680
+ kind: "project",
1681
+ teamId
1682
+ });
1683
+ filters.projectId = resolved;
1684
+ }
1685
+ if (args2.environment_id !== void 0) {
1686
+ const resolved = await ctx.hoststack.resolveId(args2.environment_id, {
1687
+ kind: "environment",
1688
+ teamId
1689
+ });
1690
+ filters.environmentId = resolved;
1691
+ }
1692
+ if (args2.status) filters.status = args2.status;
1693
+ if (args2.type) filters.type = args2.type;
1694
+ const response = await ctx.hoststack.services.list(teamId, filters);
1160
1695
  const data = shapeList(response, "services", shapeService);
1161
- 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"}.`;
1696
+ const filterDesc = [
1697
+ args2.project_id !== void 0 ? `project=${args2.project_id}` : null,
1698
+ args2.environment_id !== void 0 ? `env=${args2.environment_id}` : null,
1699
+ args2.status ? `status=${args2.status}` : null,
1700
+ args2.type ? `type=${args2.type}` : null
1701
+ ].filter(Boolean).join(", ");
1702
+ 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}` : ""}.`;
1162
1703
  return respond({ summary, data });
1163
1704
  }
1164
1705
  });
@@ -1178,7 +1719,7 @@ defineTool({
1178
1719
  'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 } }'
1179
1720
  ].join("\n"),
1180
1721
  input: {
1181
- service_id: z9.string().describe("Service publicId (e.g. svc_abc123).")
1722
+ service_id: z12.string().describe("Service publicId (e.g. svc_abc123).")
1182
1723
  },
1183
1724
  handler: async (args2, ctx) => {
1184
1725
  const teamId = await ctx.resolveTeamId();
@@ -1194,7 +1735,7 @@ defineTool({
1194
1735
  description: [
1195
1736
  "Fetch the latest CPU, memory, network, and request-rate metrics for a service.",
1196
1737
  "",
1197
- "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.",
1738
+ '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.',
1198
1739
  "",
1199
1740
  "Inputs:",
1200
1741
  " - service_id: publicId of the service.",
@@ -1204,7 +1745,7 @@ defineTool({
1204
1745
  'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
1205
1746
  ].join("\n"),
1206
1747
  input: {
1207
- service_id: z9.string().describe("Service publicId.")
1748
+ service_id: z12.string().describe("Service publicId.")
1208
1749
  },
1209
1750
  handler: async (args2, ctx) => {
1210
1751
  const teamId = await ctx.resolveTeamId();
@@ -1213,6 +1754,55 @@ defineTool({
1213
1754
  return respond({ summary: `Metrics snapshot for service ${args2.service_id}.`, data });
1214
1755
  }
1215
1756
  });
1757
+ defineTool({
1758
+ name: "get_service_metrics_history",
1759
+ category: "services",
1760
+ description: [
1761
+ "Fetch a metrics time series (CPU, memory, network, disk) for a service.",
1762
+ "",
1763
+ '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.',
1764
+ "",
1765
+ "Inputs (all optional \u2014 omit both for the trailing hour):",
1766
+ " - service_id: publicId of the service.",
1767
+ ' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
1768
+ " - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
1769
+ "",
1770
+ "Resolution: \u22647d \u2192 raw samples (~minute granularity), \u226430d \u2192 hourly pre-aggregates, >30d \u2192 daily. Up to ~500 points returned.",
1771
+ "",
1772
+ "Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
1773
+ "",
1774
+ 'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
1775
+ ].join("\n"),
1776
+ input: {
1777
+ service_id: z12.string().describe("Service publicId."),
1778
+ from: z12.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
1779
+ to: z12.string().optional().describe("ISO-8601 upper bound; defaults to now.")
1780
+ },
1781
+ handler: async (args2, ctx) => {
1782
+ const teamId = await ctx.resolveTeamId();
1783
+ const opts = {};
1784
+ const resolveRel = (raw) => {
1785
+ const rel = /^-(\d+)(s|m|h|d)$/.exec(raw.trim());
1786
+ if (!rel) return raw;
1787
+ const amount = Number.parseInt(rel[1], 10);
1788
+ const unit = rel[2];
1789
+ const ms = unit === "s" ? amount * 1e3 : unit === "m" ? amount * 6e4 : unit === "h" ? amount * 36e5 : amount * 864e5;
1790
+ return new Date(Date.now() - ms).toISOString();
1791
+ };
1792
+ if (args2.from) opts.from = resolveRel(args2.from);
1793
+ if (args2.to) opts.to = resolveRel(args2.to);
1794
+ const response = await ctx.hoststack.services.getMetricsHistory(
1795
+ teamId,
1796
+ args2.service_id,
1797
+ opts
1798
+ );
1799
+ const points = Array.isArray(response.history) ? response.history.length : 0;
1800
+ return respond({
1801
+ summary: `Returned ${points} metric point${points === 1 ? "" : "s"} for service ${args2.service_id}.`,
1802
+ data: { history: response.history }
1803
+ });
1804
+ }
1805
+ });
1216
1806
  defineTool({
1217
1807
  name: "update_service",
1218
1808
  category: "services",
@@ -1230,8 +1820,8 @@ defineTool({
1230
1820
  'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
1231
1821
  ].join("\n"),
1232
1822
  input: {
1233
- service_id: z9.string().describe("Service publicId."),
1234
- name: z9.string().min(1).max(60).describe("New service name (1\u201360 chars).")
1823
+ service_id: z12.string().describe("Service publicId."),
1824
+ name: z12.string().min(1).max(60).describe("New service name (1\u201360 chars).")
1235
1825
  },
1236
1826
  handler: async (args2, ctx) => {
1237
1827
  const teamId = await ctx.resolveTeamId();
@@ -1258,21 +1848,30 @@ defineTool({
1258
1848
  " - dockerfile_path (optional): path to Dockerfile relative to root_directory. Pass null to clear.",
1259
1849
  " - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
1260
1850
  " - instance_count (optional): integer \u22651 \u2014 pin both min and max instances to this value.",
1851
+ ' - 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.',
1261
1852
  "",
1262
1853
  "Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
1263
1854
  "",
1264
1855
  '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 } }'
1265
1856
  ].join("\n"),
1266
1857
  input: {
1267
- service_id: z9.string().describe("Service publicId."),
1268
- install_command: z9.string().nullable().optional().describe("Install shell command. Null clears."),
1269
- build_command: z9.string().nullable().optional().describe("Build shell command. Null clears."),
1270
- start_command: z9.string().nullable().optional().describe("Start shell command. Null clears."),
1271
- branch: z9.string().optional().describe("Git branch to track."),
1272
- root_directory: z9.string().optional().describe("Build context root."),
1273
- dockerfile_path: z9.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
1274
- auto_deploy: z9.boolean().optional().describe("Auto-deploy on push."),
1275
- instance_count: z9.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350).")
1858
+ service_id: z12.string().describe("Service publicId."),
1859
+ install_command: z12.string().nullable().optional().describe("Install shell command. Null clears."),
1860
+ build_command: z12.string().nullable().optional().describe("Build shell command. Null clears."),
1861
+ start_command: z12.string().nullable().optional().describe("Start shell command. Null clears."),
1862
+ branch: z12.string().optional().describe("Git branch to track."),
1863
+ root_directory: z12.string().optional().describe("Build context root."),
1864
+ dockerfile_path: z12.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
1865
+ auto_deploy: z12.boolean().optional().describe("Auto-deploy on push."),
1866
+ instance_count: z12.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
1867
+ log_filter_rules: z12.array(
1868
+ z12.object({
1869
+ pattern: z12.string().min(1).max(200),
1870
+ action: z12.enum(["drop", "downgrade"])
1871
+ })
1872
+ ).max(50).optional().describe(
1873
+ "Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
1874
+ )
1276
1875
  },
1277
1876
  handler: async (args2, ctx) => {
1278
1877
  const teamId = await ctx.resolveTeamId();
@@ -1284,14 +1883,16 @@ defineTool({
1284
1883
  if (args2.dockerfile_path !== void 0)
1285
1884
  serviceUpdate["dockerfilePath"] = args2.dockerfile_path;
1286
1885
  if (args2.branch !== void 0) serviceUpdate["branch"] = args2.branch;
1287
- if (args2.root_directory !== void 0)
1288
- serviceUpdate["rootDirectory"] = args2.root_directory;
1886
+ if (args2.root_directory !== void 0) serviceUpdate["rootDirectory"] = args2.root_directory;
1289
1887
  if (args2.auto_deploy !== void 0) serviceUpdate["autoDeploy"] = args2.auto_deploy;
1290
1888
  const configUpdate = {};
1291
1889
  if (args2.instance_count !== void 0) {
1292
1890
  configUpdate["minInstances"] = args2.instance_count;
1293
1891
  configUpdate["maxInstances"] = args2.instance_count;
1294
1892
  }
1893
+ if (args2.log_filter_rules !== void 0) {
1894
+ configUpdate["logFilterRules"] = args2.log_filter_rules;
1895
+ }
1295
1896
  if (Object.keys(serviceUpdate).length === 0 && Object.keys(configUpdate).length === 0) {
1296
1897
  return respond({ summary: "No fields to update.", data: {} });
1297
1898
  }
@@ -1337,7 +1938,7 @@ defineTool({
1337
1938
  'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
1338
1939
  ].join("\n"),
1339
1940
  input: {
1340
- service_id: z9.string().describe("Service publicId.")
1941
+ service_id: z12.string().describe("Service publicId.")
1341
1942
  },
1342
1943
  handler: async (args2, ctx) => {
1343
1944
  const teamId = await ctx.resolveTeamId();
@@ -1361,7 +1962,7 @@ defineTool({
1361
1962
  'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
1362
1963
  ].join("\n"),
1363
1964
  input: {
1364
- service_id: z9.string().describe("Service publicId.")
1965
+ service_id: z12.string().describe("Service publicId.")
1365
1966
  },
1366
1967
  handler: async (args2, ctx) => {
1367
1968
  const teamId = await ctx.resolveTeamId();
@@ -1380,18 +1981,31 @@ defineTool({
1380
1981
  "Inputs:",
1381
1982
  " - service_id: publicId of the service.",
1382
1983
  " - lines (optional): tail size (default 200, max 1000).",
1383
- " - since (optional): ISO-8601 timestamp; only return entries newer than this.",
1984
+ ' - since/until (optional): ISO-8601 timestamp OR a relative offset like "-5m", "-1h", "-2d".',
1384
1985
  ' - stream (optional): "stdout" | "stderr". Omit to combine.',
1986
+ " - 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).",
1987
+ " - search (optional): case-insensitive substring grep, \u2264100 chars.",
1988
+ ' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
1989
+ "",
1990
+ "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.",
1385
1991
  "",
1386
- "Returns: { logs: LogEntry[] | string } \u2014 each entry has { timestamp, level?, stream?, message }. Older HostStack agents return a single string blob.",
1992
+ 'Example: get_service_logs({ service_id: "svc_abc", search: "OOM", since: "-15m" }) \u2192 { logs: [...] }.',
1387
1993
  "",
1388
- 'Example: get_service_logs({ service_id: "svc_abc", lines: 100 }) \u2192 { logs: [{ timestamp: "2026-04-25T\u2026", message: "GET /health 200" }, \u2026] }'
1994
+ "More examples:",
1995
+ ' - Last 50 stderr lines from the past hour: get_service_logs({ service_id: "svc_abc", lines: 50, stream: "stderr", since: "-1h" })',
1996
+ ' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
1389
1997
  ].join("\n"),
1390
1998
  input: {
1391
- service_id: z9.string().describe("Service publicId."),
1392
- lines: z9.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
1393
- since: z9.string().datetime().optional().describe("ISO-8601 timestamp lower bound."),
1394
- stream: z9.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream.")
1999
+ service_id: z12.string().describe("Service publicId."),
2000
+ lines: z12.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
2001
+ since: z12.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2002
+ until: z12.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
2003
+ stream: z12.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
2004
+ level: z12.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
2005
+ "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)."
2006
+ ),
2007
+ search: z12.string().max(100).optional().describe("Case-insensitive substring filter."),
2008
+ count_only: z12.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
1395
2009
  },
1396
2010
  handler: async (args2, ctx) => {
1397
2011
  const teamId = await ctx.resolveTeamId();
@@ -1399,8 +2013,18 @@ defineTool({
1399
2013
  lines: args2.lines ?? 200
1400
2014
  };
1401
2015
  if (args2.since) opts.since = args2.since;
2016
+ if (args2.until) opts.until = args2.until;
1402
2017
  if (args2.stream) opts.stream = args2.stream;
2018
+ if (args2.level) opts.level = args2.level;
2019
+ if (args2.search) opts.search = args2.search;
2020
+ if (args2.count_only) opts.countOnly = args2.count_only;
1403
2021
  const response = await ctx.hoststack.services.getRuntimeLogs(teamId, args2.service_id, opts);
2022
+ if ("count" in response) {
2023
+ return respond({
2024
+ summary: `${response.count} matching log line${response.count === 1 ? "" : "s"} for service ${args2.service_id}.`,
2025
+ data: { count: response.count }
2026
+ });
2027
+ }
1404
2028
  const count = Array.isArray(response.logs) ? response.logs.length : typeof response.logs === "string" ? response.logs.split("\n").length : 0;
1405
2029
  return respond({
1406
2030
  summary: `Fetched ${count} log line${count === 1 ? "" : "s"} for service ${args2.service_id}.`,
@@ -1408,9 +2032,82 @@ defineTool({
1408
2032
  });
1409
2033
  }
1410
2034
  });
2035
+ defineTool({
2036
+ name: "get_service_logs_bulk",
2037
+ category: "logs",
2038
+ description: [
2039
+ "Fetch runtime logs for multiple services in parallel and return one response keyed by service_id. Single round-trip from the MCP client perspective.",
2040
+ "",
2041
+ '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.',
2042
+ "",
2043
+ "Inputs:",
2044
+ " - service_ids: list of service publicIds (svc_\u2026). Hard cap 10 to bound work \u2014 for larger fleets, partition the call.",
2045
+ " - lines_per_service (optional): tail size per service (default 100, max 500). Smaller default than get_service_logs since you are fanning out.",
2046
+ ' - since/until (optional): same shape as get_service_logs (ISO-8601 or "-5m", "-1h").',
2047
+ ' - stream (optional): "stdout" | "stderr".',
2048
+ " - level (optional): structured-log level filter (trace/debug/info/warn/error/fatal).",
2049
+ " - search (optional): case-insensitive substring filter applied to every service.",
2050
+ ' - 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.',
2051
+ "",
2052
+ "Returns: { results: { [service_id]: { logs?, count?, error? } } } \u2014 per-service result, with `error` populated when one service failed (the rest still succeed).",
2053
+ "",
2054
+ '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 } } }.'
2055
+ ].join("\n"),
2056
+ input: {
2057
+ service_ids: z12.array(z12.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
2058
+ lines_per_service: z12.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
2059
+ since: z12.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
2060
+ until: z12.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
2061
+ stream: z12.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
2062
+ level: z12.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
2063
+ search: z12.string().max(100).optional().describe("Case-insensitive substring filter."),
2064
+ count_only: z12.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
2065
+ },
2066
+ handler: async (args2, ctx) => {
2067
+ const teamId = await ctx.resolveTeamId();
2068
+ const linesPerService = args2.lines_per_service ?? 100;
2069
+ const baseOpts = { lines: linesPerService };
2070
+ if (args2.since) baseOpts.since = args2.since;
2071
+ if (args2.until) baseOpts.until = args2.until;
2072
+ if (args2.stream) baseOpts.stream = args2.stream;
2073
+ if (args2.level) baseOpts.level = args2.level;
2074
+ if (args2.search) baseOpts.search = args2.search;
2075
+ if (args2.count_only) baseOpts.countOnly = args2.count_only;
2076
+ const settled = await Promise.allSettled(
2077
+ args2.service_ids.map(async (sid) => {
2078
+ const response = await ctx.hoststack.services.getRuntimeLogs(teamId, sid, baseOpts);
2079
+ return { sid, response };
2080
+ })
2081
+ );
2082
+ const results = {};
2083
+ let okCount = 0;
2084
+ let errorCount = 0;
2085
+ for (let i = 0; i < settled.length; i += 1) {
2086
+ const outcome = settled[i];
2087
+ const sid = args2.service_ids[i];
2088
+ if (outcome.status === "fulfilled") {
2089
+ const { response } = outcome.value;
2090
+ if ("count" in response) {
2091
+ results[sid] = { count: response.count };
2092
+ } else {
2093
+ results[sid] = { logs: response.logs };
2094
+ }
2095
+ okCount += 1;
2096
+ } else {
2097
+ const reason = outcome.reason;
2098
+ results[sid] = {
2099
+ error: typeof reason === "string" ? reason : reason?.message ?? "Unknown error"
2100
+ };
2101
+ errorCount += 1;
2102
+ }
2103
+ }
2104
+ const summary = errorCount === 0 ? `Fetched logs from ${okCount} service${okCount === 1 ? "" : "s"}.` : `Fetched logs from ${okCount} of ${args2.service_ids.length} service${args2.service_ids.length === 1 ? "" : "s"} (${errorCount} failed \u2014 see per-service .error field).`;
2105
+ return respond({ summary, data: { results } });
2106
+ }
2107
+ });
1411
2108
 
1412
2109
  // src/tools/volumes.ts
1413
- import { z as z10 } from "zod";
2110
+ import { z as z13 } from "zod";
1414
2111
  defineTool({
1415
2112
  name: "list_volumes",
1416
2113
  category: "volumes",
@@ -1427,7 +2124,7 @@ defineTool({
1427
2124
  'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
1428
2125
  ].join("\n"),
1429
2126
  input: {
1430
- service_id: z10.string().describe("Service publicId (e.g. svc_abc123).")
2127
+ service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
1431
2128
  },
1432
2129
  handler: async (args2, ctx) => {
1433
2130
  const teamId = await ctx.resolveTeamId();
@@ -1456,10 +2153,10 @@ defineTool({
1456
2153
  '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" } }'
1457
2154
  ].join("\n"),
1458
2155
  input: {
1459
- service_id: z10.string().describe("Service publicId."),
1460
- name: z10.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
1461
- mount_path: z10.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
1462
- size_gb: z10.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
2156
+ service_id: z13.string().describe("Service publicId."),
2157
+ name: z13.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
2158
+ mount_path: z13.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
2159
+ size_gb: z13.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
1463
2160
  },
1464
2161
  handler: async (args2, ctx) => {
1465
2162
  const teamId = await ctx.resolveTeamId();
@@ -1495,10 +2192,10 @@ defineTool({
1495
2192
  'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
1496
2193
  ].join("\n"),
1497
2194
  input: {
1498
- service_id: z10.string().describe("Service publicId."),
1499
- volume_id: z10.string().describe("Volume publicId (e.g. vol_\u2026)."),
1500
- mount_path: z10.string().startsWith("/").max(500).optional().describe("New mount path."),
1501
- size_gb: z10.number().int().min(1).max(100).optional().describe("New size in GB.")
2195
+ service_id: z13.string().describe("Service publicId."),
2196
+ volume_id: z13.string().describe("Volume publicId (e.g. vol_\u2026)."),
2197
+ mount_path: z13.string().startsWith("/").max(500).optional().describe("New mount path."),
2198
+ size_gb: z13.number().int().min(1).max(100).optional().describe("New size in GB.")
1502
2199
  },
1503
2200
  handler: async (args2, ctx) => {
1504
2201
  const teamId = await ctx.resolveTeamId();
@@ -1536,8 +2233,8 @@ defineTool({
1536
2233
  'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
1537
2234
  ].join("\n"),
1538
2235
  input: {
1539
- service_id: z10.string().describe("Service publicId."),
1540
- volume_id: z10.string().describe("Volume publicId.")
2236
+ service_id: z13.string().describe("Service publicId."),
2237
+ volume_id: z13.string().describe("Volume publicId.")
1541
2238
  },
1542
2239
  handler: async (args2, ctx) => {
1543
2240
  const teamId = await ctx.resolveTeamId();