@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/README.md +20 -18
- package/dist/hoststack-mcp.js +803 -106
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +803 -106
- 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
|
|
@@ -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
|
|
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:
|
|
1067
|
-
description:
|
|
1068
|
-
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.")
|
|
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:
|
|
1100
|
-
name:
|
|
1101
|
-
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).")
|
|
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:
|
|
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
|
|
1654
|
+
import { z as z12 } from "zod";
|
|
1148
1655
|
defineTool({
|
|
1149
1656
|
name: "list_services",
|
|
1150
1657
|
category: "services",
|
|
1151
1658
|
description: [
|
|
1152
|
-
"List
|
|
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(
|
|
1671
|
+
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
1159
1672
|
].join("\n"),
|
|
1160
|
-
input: {
|
|
1161
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
1238
|
-
name:
|
|
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:
|
|
1272
|
-
install_command:
|
|
1273
|
-
build_command:
|
|
1274
|
-
start_command:
|
|
1275
|
-
branch:
|
|
1276
|
-
root_directory:
|
|
1277
|
-
dockerfile_path:
|
|
1278
|
-
auto_deploy:
|
|
1279
|
-
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
|
+
)
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1996
|
+
'Example: get_service_logs({ service_id: "svc_abc", search: "OOM", since: "-15m" }) \u2192 { logs: [...] }.',
|
|
1391
1997
|
"",
|
|
1392
|
-
|
|
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:
|
|
1396
|
-
lines:
|
|
1397
|
-
since:
|
|
1398
|
-
|
|
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
|
|
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:
|
|
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:
|
|
1464
|
-
name:
|
|
1465
|
-
mount_path:
|
|
1466
|
-
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).")
|
|
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:
|
|
1503
|
-
volume_id:
|
|
1504
|
-
mount_path:
|
|
1505
|
-
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.")
|
|
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:
|
|
1544
|
-
volume_id:
|
|
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();
|