@hoststack.dev/mcp 0.4.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/README.md +20 -19
- package/dist/hoststack-mcp.js +656 -120
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +656 -120
- package/dist/index.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +2 -2
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
|
|
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)
|
|
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/
|
|
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:
|
|
476
|
-
limit:
|
|
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:
|
|
505
|
-
execution_id:
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
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:
|
|
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,
|
|
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:
|
|
618
|
-
deploy_id:
|
|
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:
|
|
646
|
-
clear_cache:
|
|
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:
|
|
679
|
-
deploy_id:
|
|
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:
|
|
708
|
-
deploy_id:
|
|
709
|
-
after_id:
|
|
710
|
-
limit:
|
|
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
|
|
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:
|
|
773
|
-
service_id:
|
|
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:
|
|
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({
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
884
|
-
key:
|
|
885
|
-
value:
|
|
886
|
-
is_secret:
|
|
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(
|
|
895
|
-
|
|
896
|
-
|
|
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:
|
|
934
|
-
key:
|
|
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
|
-
|
|
943
|
-
|
|
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:
|
|
971
|
-
env_vars:
|
|
972
|
-
|
|
973
|
-
key:
|
|
974
|
-
value:
|
|
975
|
-
is_secret:
|
|
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
|
-
|
|
1167
|
+
vars: args.env_vars.map((v) => {
|
|
983
1168
|
const row = {
|
|
984
1169
|
key: v.key,
|
|
985
1170
|
value: v.value
|
|
@@ -997,7 +1182,7 @@ defineTool({
|
|
|
997
1182
|
});
|
|
998
1183
|
|
|
999
1184
|
// src/tools/environments.ts
|
|
1000
|
-
import { z as
|
|
1185
|
+
import { z as z9 } from "zod";
|
|
1001
1186
|
defineTool({
|
|
1002
1187
|
name: "list_environments",
|
|
1003
1188
|
category: "environments",
|
|
@@ -1014,7 +1199,7 @@ defineTool({
|
|
|
1014
1199
|
'Example: list_environments({ project_id: "prj_abc" }) \u2192 { items: [{ name: "Production", type: "production", isDefault: true }, { name: "Staging", type: "staging" }] }'
|
|
1015
1200
|
].join("\n"),
|
|
1016
1201
|
input: {
|
|
1017
|
-
project_id:
|
|
1202
|
+
project_id: z9.string().describe("Project publicId.")
|
|
1018
1203
|
},
|
|
1019
1204
|
handler: async (args, ctx) => {
|
|
1020
1205
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1043,10 +1228,10 @@ defineTool({
|
|
|
1043
1228
|
'Example: create_environment({ project_id: "prj_abc", name: "Staging", type: "staging" }) \u2192 { environment: { id: 7, publicId: "environment_\u2026", name: "Staging", type: "staging" } }'
|
|
1044
1229
|
].join("\n"),
|
|
1045
1230
|
input: {
|
|
1046
|
-
project_id:
|
|
1047
|
-
name:
|
|
1048
|
-
type:
|
|
1049
|
-
is_protected:
|
|
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.")
|
|
1050
1235
|
},
|
|
1051
1236
|
handler: async (args, ctx) => {
|
|
1052
1237
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1080,8 +1265,8 @@ defineTool({
|
|
|
1080
1265
|
'Example: delete_environment({ project_id: "prj_abc", environment_id: "environment_xyz" }) \u2192 { success: true }'
|
|
1081
1266
|
].join("\n"),
|
|
1082
1267
|
input: {
|
|
1083
|
-
project_id:
|
|
1084
|
-
environment_id:
|
|
1268
|
+
project_id: z9.string().describe("Project publicId."),
|
|
1269
|
+
environment_id: z9.union([z9.string(), z9.number()]).describe("Environment publicId or numeric id.")
|
|
1085
1270
|
},
|
|
1086
1271
|
handler: async (args, ctx) => {
|
|
1087
1272
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1112,9 +1297,9 @@ defineTool({
|
|
|
1112
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" } }'
|
|
1113
1298
|
].join("\n"),
|
|
1114
1299
|
input: {
|
|
1115
|
-
service_id:
|
|
1116
|
-
deploy_id:
|
|
1117
|
-
target_environment_id:
|
|
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.")
|
|
1118
1303
|
},
|
|
1119
1304
|
handler: async (args, ctx) => {
|
|
1120
1305
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1162,8 +1347,190 @@ defineTool({
|
|
|
1162
1347
|
}
|
|
1163
1348
|
});
|
|
1164
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
|
+
|
|
1165
1532
|
// src/tools/projects.ts
|
|
1166
|
-
import { z as
|
|
1533
|
+
import { z as z11 } from "zod";
|
|
1167
1534
|
defineTool({
|
|
1168
1535
|
name: "list_projects",
|
|
1169
1536
|
category: "projects",
|
|
@@ -1203,9 +1570,9 @@ defineTool({
|
|
|
1203
1570
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "fsn1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
1204
1571
|
].join("\n"),
|
|
1205
1572
|
input: {
|
|
1206
|
-
name:
|
|
1207
|
-
description:
|
|
1208
|
-
region:
|
|
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.")
|
|
1209
1576
|
},
|
|
1210
1577
|
handler: async (args, ctx) => {
|
|
1211
1578
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1236,9 +1603,9 @@ defineTool({
|
|
|
1236
1603
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
1237
1604
|
].join("\n"),
|
|
1238
1605
|
input: {
|
|
1239
|
-
project_id:
|
|
1240
|
-
name:
|
|
1241
|
-
description:
|
|
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).")
|
|
1242
1609
|
},
|
|
1243
1610
|
handler: async (args, ctx) => {
|
|
1244
1611
|
if (args.name === void 0 && args.description === void 0) {
|
|
@@ -1272,7 +1639,7 @@ defineTool({
|
|
|
1272
1639
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
1273
1640
|
].join("\n"),
|
|
1274
1641
|
input: {
|
|
1275
|
-
project_id:
|
|
1642
|
+
project_id: z11.string().describe("Project publicId (e.g. prj_abc123).")
|
|
1276
1643
|
},
|
|
1277
1644
|
handler: async (args, ctx) => {
|
|
1278
1645
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1284,25 +1651,59 @@ defineTool({
|
|
|
1284
1651
|
});
|
|
1285
1652
|
|
|
1286
1653
|
// src/tools/services.ts
|
|
1287
|
-
import { z as
|
|
1654
|
+
import { z as z12 } from "zod";
|
|
1288
1655
|
defineTool({
|
|
1289
1656
|
name: "list_services",
|
|
1290
1657
|
category: "services",
|
|
1291
1658
|
description: [
|
|
1292
|
-
"List
|
|
1659
|
+
"List services (web, worker, cron, private, static) in the active HostStack team.",
|
|
1293
1660
|
"",
|
|
1294
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.",
|
|
1295
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
|
+
"",
|
|
1296
1669
|
"Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
|
|
1297
1670
|
"",
|
|
1298
|
-
'Example: list_services(
|
|
1671
|
+
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
1299
1672
|
].join("\n"),
|
|
1300
|
-
input: {
|
|
1301
|
-
|
|
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) => {
|
|
1302
1680
|
const teamId = await ctx.resolveTeamId();
|
|
1303
|
-
const
|
|
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);
|
|
1304
1699
|
const data = shapeList(response, "services", shapeService);
|
|
1305
|
-
const
|
|
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}` : ""}.`;
|
|
1306
1707
|
return respond({ summary, data });
|
|
1307
1708
|
}
|
|
1308
1709
|
});
|
|
@@ -1322,7 +1723,7 @@ defineTool({
|
|
|
1322
1723
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 } }'
|
|
1323
1724
|
].join("\n"),
|
|
1324
1725
|
input: {
|
|
1325
|
-
service_id:
|
|
1726
|
+
service_id: z12.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1326
1727
|
},
|
|
1327
1728
|
handler: async (args, ctx) => {
|
|
1328
1729
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1338,7 +1739,7 @@ defineTool({
|
|
|
1338
1739
|
description: [
|
|
1339
1740
|
"Fetch the latest CPU, memory, network, and request-rate metrics for a service.",
|
|
1340
1741
|
"",
|
|
1341
|
-
|
|
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.',
|
|
1342
1743
|
"",
|
|
1343
1744
|
"Inputs:",
|
|
1344
1745
|
" - service_id: publicId of the service.",
|
|
@@ -1348,7 +1749,7 @@ defineTool({
|
|
|
1348
1749
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
1349
1750
|
].join("\n"),
|
|
1350
1751
|
input: {
|
|
1351
|
-
service_id:
|
|
1752
|
+
service_id: z12.string().describe("Service publicId.")
|
|
1352
1753
|
},
|
|
1353
1754
|
handler: async (args, ctx) => {
|
|
1354
1755
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1357,6 +1758,55 @@ defineTool({
|
|
|
1357
1758
|
return respond({ summary: `Metrics snapshot for service ${args.service_id}.`, data });
|
|
1358
1759
|
}
|
|
1359
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
|
+
});
|
|
1360
1810
|
defineTool({
|
|
1361
1811
|
name: "update_service",
|
|
1362
1812
|
category: "services",
|
|
@@ -1374,8 +1824,8 @@ defineTool({
|
|
|
1374
1824
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
1375
1825
|
].join("\n"),
|
|
1376
1826
|
input: {
|
|
1377
|
-
service_id:
|
|
1378
|
-
name:
|
|
1827
|
+
service_id: z12.string().describe("Service publicId."),
|
|
1828
|
+
name: z12.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
1379
1829
|
},
|
|
1380
1830
|
handler: async (args, ctx) => {
|
|
1381
1831
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1402,21 +1852,30 @@ defineTool({
|
|
|
1402
1852
|
" - dockerfile_path (optional): path to Dockerfile relative to root_directory. Pass null to clear.",
|
|
1403
1853
|
" - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
|
|
1404
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.',
|
|
1405
1856
|
"",
|
|
1406
1857
|
"Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
|
|
1407
1858
|
"",
|
|
1408
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 } }'
|
|
1409
1860
|
].join("\n"),
|
|
1410
1861
|
input: {
|
|
1411
|
-
service_id:
|
|
1412
|
-
install_command:
|
|
1413
|
-
build_command:
|
|
1414
|
-
start_command:
|
|
1415
|
-
branch:
|
|
1416
|
-
root_directory:
|
|
1417
|
-
dockerfile_path:
|
|
1418
|
-
auto_deploy:
|
|
1419
|
-
instance_count:
|
|
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
|
+
)
|
|
1420
1879
|
},
|
|
1421
1880
|
handler: async (args, ctx) => {
|
|
1422
1881
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1428,14 +1887,16 @@ defineTool({
|
|
|
1428
1887
|
if (args.dockerfile_path !== void 0)
|
|
1429
1888
|
serviceUpdate["dockerfilePath"] = args.dockerfile_path;
|
|
1430
1889
|
if (args.branch !== void 0) serviceUpdate["branch"] = args.branch;
|
|
1431
|
-
if (args.root_directory !== void 0)
|
|
1432
|
-
serviceUpdate["rootDirectory"] = args.root_directory;
|
|
1890
|
+
if (args.root_directory !== void 0) serviceUpdate["rootDirectory"] = args.root_directory;
|
|
1433
1891
|
if (args.auto_deploy !== void 0) serviceUpdate["autoDeploy"] = args.auto_deploy;
|
|
1434
1892
|
const configUpdate = {};
|
|
1435
1893
|
if (args.instance_count !== void 0) {
|
|
1436
1894
|
configUpdate["minInstances"] = args.instance_count;
|
|
1437
1895
|
configUpdate["maxInstances"] = args.instance_count;
|
|
1438
1896
|
}
|
|
1897
|
+
if (args.log_filter_rules !== void 0) {
|
|
1898
|
+
configUpdate["logFilterRules"] = args.log_filter_rules;
|
|
1899
|
+
}
|
|
1439
1900
|
if (Object.keys(serviceUpdate).length === 0 && Object.keys(configUpdate).length === 0) {
|
|
1440
1901
|
return respond({ summary: "No fields to update.", data: {} });
|
|
1441
1902
|
}
|
|
@@ -1481,7 +1942,7 @@ defineTool({
|
|
|
1481
1942
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
1482
1943
|
].join("\n"),
|
|
1483
1944
|
input: {
|
|
1484
|
-
service_id:
|
|
1945
|
+
service_id: z12.string().describe("Service publicId.")
|
|
1485
1946
|
},
|
|
1486
1947
|
handler: async (args, ctx) => {
|
|
1487
1948
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1505,7 +1966,7 @@ defineTool({
|
|
|
1505
1966
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
1506
1967
|
].join("\n"),
|
|
1507
1968
|
input: {
|
|
1508
|
-
service_id:
|
|
1969
|
+
service_id: z12.string().describe("Service publicId.")
|
|
1509
1970
|
},
|
|
1510
1971
|
handler: async (args, ctx) => {
|
|
1511
1972
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1526,11 +1987,11 @@ defineTool({
|
|
|
1526
1987
|
" - lines (optional): tail size (default 200, max 1000).",
|
|
1527
1988
|
' - since/until (optional): ISO-8601 timestamp OR a relative offset like "-5m", "-1h", "-2d".',
|
|
1528
1989
|
' - stream (optional): "stdout" | "stderr". Omit to combine.',
|
|
1529
|
-
" - level (optional):
|
|
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).",
|
|
1530
1991
|
" - search (optional): case-insensitive substring grep, \u2264100 chars.",
|
|
1531
1992
|
' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
|
|
1532
1993
|
"",
|
|
1533
|
-
"Returns: { logs: LogEntry[] | string } when count_only is false
|
|
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.",
|
|
1534
1995
|
"",
|
|
1535
1996
|
'Example: get_service_logs({ service_id: "svc_abc", search: "OOM", since: "-15m" }) \u2192 { logs: [...] }.',
|
|
1536
1997
|
"",
|
|
@@ -1539,14 +2000,16 @@ defineTool({
|
|
|
1539
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 }'
|
|
1540
2001
|
].join("\n"),
|
|
1541
2002
|
input: {
|
|
1542
|
-
service_id:
|
|
1543
|
-
lines:
|
|
1544
|
-
since:
|
|
1545
|
-
until:
|
|
1546
|
-
stream:
|
|
1547
|
-
level:
|
|
1548
|
-
|
|
1549
|
-
|
|
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.")
|
|
1550
2013
|
},
|
|
1551
2014
|
handler: async (args, ctx) => {
|
|
1552
2015
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1573,9 +2036,82 @@ defineTool({
|
|
|
1573
2036
|
});
|
|
1574
2037
|
}
|
|
1575
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
|
+
});
|
|
1576
2112
|
|
|
1577
2113
|
// src/tools/volumes.ts
|
|
1578
|
-
import { z as
|
|
2114
|
+
import { z as z13 } from "zod";
|
|
1579
2115
|
defineTool({
|
|
1580
2116
|
name: "list_volumes",
|
|
1581
2117
|
category: "volumes",
|
|
@@ -1592,7 +2128,7 @@ defineTool({
|
|
|
1592
2128
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
1593
2129
|
].join("\n"),
|
|
1594
2130
|
input: {
|
|
1595
|
-
service_id:
|
|
2131
|
+
service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1596
2132
|
},
|
|
1597
2133
|
handler: async (args, ctx) => {
|
|
1598
2134
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1621,10 +2157,10 @@ defineTool({
|
|
|
1621
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" } }'
|
|
1622
2158
|
].join("\n"),
|
|
1623
2159
|
input: {
|
|
1624
|
-
service_id:
|
|
1625
|
-
name:
|
|
1626
|
-
mount_path:
|
|
1627
|
-
size_gb:
|
|
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).")
|
|
1628
2164
|
},
|
|
1629
2165
|
handler: async (args, ctx) => {
|
|
1630
2166
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1660,10 +2196,10 @@ defineTool({
|
|
|
1660
2196
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
1661
2197
|
].join("\n"),
|
|
1662
2198
|
input: {
|
|
1663
|
-
service_id:
|
|
1664
|
-
volume_id:
|
|
1665
|
-
mount_path:
|
|
1666
|
-
size_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.")
|
|
1667
2203
|
},
|
|
1668
2204
|
handler: async (args, ctx) => {
|
|
1669
2205
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1701,8 +2237,8 @@ defineTool({
|
|
|
1701
2237
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
1702
2238
|
].join("\n"),
|
|
1703
2239
|
input: {
|
|
1704
|
-
service_id:
|
|
1705
|
-
volume_id:
|
|
2240
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2241
|
+
volume_id: z13.string().describe("Volume publicId.")
|
|
1706
2242
|
},
|
|
1707
2243
|
handler: async (args, ctx) => {
|
|
1708
2244
|
const teamId = await ctx.resolveTeamId();
|