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