@hoststack.dev/mcp 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -19
- package/dist/hoststack-mcp.js +954 -121
- package/dist/hoststack-mcp.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +954 -121
- package/dist/index.js.map +1 -1
- package/manifest.json +2 -2
- package/package.json +3 -3
package/dist/hoststack-mcp.js
CHANGED
|
@@ -46,6 +46,14 @@ var ApiClient = class {
|
|
|
46
46
|
});
|
|
47
47
|
return this.handle(res);
|
|
48
48
|
}
|
|
49
|
+
async put(path, body) {
|
|
50
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
51
|
+
method: "PUT",
|
|
52
|
+
headers: this.headers,
|
|
53
|
+
body: JSON.stringify(body)
|
|
54
|
+
});
|
|
55
|
+
return this.handle(res);
|
|
56
|
+
}
|
|
49
57
|
async delete(path) {
|
|
50
58
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
51
59
|
method: "DELETE",
|
|
@@ -415,8 +423,9 @@ defineTool({
|
|
|
415
423
|
' - action: filter to a specific action (e.g. "service.created", "deploy.triggered").',
|
|
416
424
|
' - resource_type: filter by resource type (e.g. "service", "deploy", "domain").',
|
|
417
425
|
" - user_id: filter by acting user numeric ID.",
|
|
426
|
+
' - since/until: ISO-8601 timestamp OR a relative offset like "-15m" / "-2h" / "-7d" \u2014 narrow to a time window (handy for correlating a deploy with a log spike).',
|
|
418
427
|
"",
|
|
419
|
-
"Returns: { items: ActivityLogEntry[], meta
|
|
428
|
+
"Returns: { items: ActivityLogEntry[], meta: { page, perPage, total, totalPages } } \u2014 each entry has id, action, resourceType, resourceId, actorEmail, ipAddress, createdAt, and a context payload. Use meta.totalPages to tell whether you have more.",
|
|
420
429
|
"",
|
|
421
430
|
'Example: list_activity_log({ resource_type: "deploy", per_page: 10 }) \u2192 { items: [{ action: "deploy.triggered", actorEmail: "ada@\u2026", \u2026 }], meta: { total: 47, page: 1 } }'
|
|
422
431
|
].join("\n"),
|
|
@@ -425,7 +434,9 @@ defineTool({
|
|
|
425
434
|
per_page: z2.number().int().positive().max(100).optional().describe("Items per page, hard cap 100."),
|
|
426
435
|
action: z2.string().optional().describe('Action filter, e.g. "service.created".'),
|
|
427
436
|
resource_type: z2.string().optional().describe("Resource type filter."),
|
|
428
|
-
user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter.")
|
|
437
|
+
user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter."),
|
|
438
|
+
since: z2.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-15m", "-2h", "-7d").'),
|
|
439
|
+
until: z2.string().optional().describe("ISO-8601 timestamp or relative offset upper bound.")
|
|
429
440
|
},
|
|
430
441
|
handler: async (args2, ctx) => {
|
|
431
442
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -435,13 +446,24 @@ defineTool({
|
|
|
435
446
|
if (args2.action !== void 0) params["action"] = args2.action;
|
|
436
447
|
if (args2.resource_type !== void 0) params["resourceType"] = args2.resource_type;
|
|
437
448
|
if (args2.user_id !== void 0) params["userId"] = String(args2.user_id);
|
|
449
|
+
if (args2.since !== void 0) params["since"] = args2.since;
|
|
450
|
+
if (args2.until !== void 0) params["until"] = args2.until;
|
|
438
451
|
const response = await ctx.api.get(
|
|
439
452
|
`/api/activity-log/${teamId}`,
|
|
440
453
|
params
|
|
441
454
|
);
|
|
442
455
|
const items = Array.isArray(response.data) ? response.data.map(shapeActivity) : [];
|
|
443
456
|
const data = { items };
|
|
444
|
-
if (response.meta !== void 0)
|
|
457
|
+
if (response.meta !== void 0) {
|
|
458
|
+
data.meta = shape(response.meta);
|
|
459
|
+
} else if (response.page !== void 0 || response.perPage !== void 0 || response.total !== void 0 || response.totalPages !== void 0) {
|
|
460
|
+
data.meta = shape({
|
|
461
|
+
page: response.page,
|
|
462
|
+
perPage: response.perPage,
|
|
463
|
+
total: response.total,
|
|
464
|
+
totalPages: response.totalPages
|
|
465
|
+
});
|
|
466
|
+
}
|
|
445
467
|
return respond({
|
|
446
468
|
summary: items.length === 0 ? "No activity log entries match the given filters." : `Returned ${items.length} activity log entr${items.length === 1 ? "y" : "ies"}.`,
|
|
447
469
|
data
|
|
@@ -449,8 +471,51 @@ defineTool({
|
|
|
449
471
|
}
|
|
450
472
|
});
|
|
451
473
|
|
|
452
|
-
// src/tools/
|
|
474
|
+
// src/tools/alerts.ts
|
|
453
475
|
import { z as z3 } from "zod";
|
|
476
|
+
defineTool({
|
|
477
|
+
name: "list_alerts",
|
|
478
|
+
category: "alerts",
|
|
479
|
+
description: [
|
|
480
|
+
"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.",
|
|
481
|
+
"",
|
|
482
|
+
"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.",
|
|
483
|
+
"",
|
|
484
|
+
'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.',
|
|
485
|
+
"",
|
|
486
|
+
"Inputs (all optional):",
|
|
487
|
+
' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h.',
|
|
488
|
+
" - until: ISO-8601 upper bound (ignored when aggregating \u2014 aggregated view always extends to now).",
|
|
489
|
+
" - limit: max rows (default 100, hard cap 500).",
|
|
490
|
+
" - aggregate: true (default) collapses by (action, resourceId); false returns raw rows.",
|
|
491
|
+
"",
|
|
492
|
+
"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.",
|
|
493
|
+
"",
|
|
494
|
+
"Example: list_alerts({ since: '-1h' }) \u2192 { alerts: [{ action: 'deploy.failed', resourceId: 31, severity: 'error', count: 3, lastFiredAt: '\u2026', \u2026 }] }."
|
|
495
|
+
].join("\n"),
|
|
496
|
+
input: {
|
|
497
|
+
since: z3.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-1h", "-2d"). Default: -24h.'),
|
|
498
|
+
until: z3.string().optional().describe("ISO-8601 upper bound. Only honored when aggregate=false."),
|
|
499
|
+
limit: z3.number().int().positive().max(500).optional().describe("Max rows (default 100, hard cap 500)."),
|
|
500
|
+
aggregate: z3.boolean().optional().describe("Collapse by (action, resourceId). Default true.")
|
|
501
|
+
},
|
|
502
|
+
handler: async (args2, ctx) => {
|
|
503
|
+
const teamId = await ctx.resolveTeamId();
|
|
504
|
+
const params = {};
|
|
505
|
+
if (args2.since !== void 0) params["since"] = args2.since;
|
|
506
|
+
if (args2.until !== void 0) params["until"] = args2.until;
|
|
507
|
+
if (args2.limit !== void 0) params["limit"] = String(args2.limit);
|
|
508
|
+
if (args2.aggregate === false) params["aggregate"] = "0";
|
|
509
|
+
const response = await ctx.api.get(`/api/alerts/${teamId}`, params);
|
|
510
|
+
const items = Array.isArray(response.alerts) ? response.alerts.map(shape) : [];
|
|
511
|
+
const aggregated = Boolean(response.aggregated);
|
|
512
|
+
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"}.`;
|
|
513
|
+
return respond({ summary, data: { alerts: items, aggregated } });
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// src/tools/cron.ts
|
|
518
|
+
import { z as z4 } from "zod";
|
|
454
519
|
defineTool({
|
|
455
520
|
name: "list_cron_executions",
|
|
456
521
|
category: "cron",
|
|
@@ -468,8 +533,8 @@ defineTool({
|
|
|
468
533
|
'Example: list_cron_executions({ service_id: "svc_cron" }) \u2192 { items: [{ status: "succeeded", exitCode: 0, \u2026 }, \u2026] }'
|
|
469
534
|
].join("\n"),
|
|
470
535
|
input: {
|
|
471
|
-
service_id:
|
|
472
|
-
limit:
|
|
536
|
+
service_id: z4.string().describe("Cron service publicId."),
|
|
537
|
+
limit: z4.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
|
|
473
538
|
},
|
|
474
539
|
handler: async (args2, ctx) => {
|
|
475
540
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -497,8 +562,8 @@ defineTool({
|
|
|
497
562
|
'Example: get_cron_execution({ service_id: "svc_cron", execution_id: "exe_xyz" }) \u2192 { execution: { status: "failed", exitCode: 1, \u2026 } }'
|
|
498
563
|
].join("\n"),
|
|
499
564
|
input: {
|
|
500
|
-
service_id:
|
|
501
|
-
execution_id:
|
|
565
|
+
service_id: z4.string().describe("Cron service publicId."),
|
|
566
|
+
execution_id: z4.string().describe("Execution publicId.")
|
|
502
567
|
},
|
|
503
568
|
handler: async (args2, ctx) => {
|
|
504
569
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -510,7 +575,7 @@ defineTool({
|
|
|
510
575
|
});
|
|
511
576
|
|
|
512
577
|
// src/tools/databases.ts
|
|
513
|
-
import { z as
|
|
578
|
+
import { z as z5 } from "zod";
|
|
514
579
|
defineTool({
|
|
515
580
|
name: "list_databases",
|
|
516
581
|
category: "databases",
|
|
@@ -527,7 +592,7 @@ defineTool({
|
|
|
527
592
|
'Example: list_databases({ project_id: 12 }) \u2192 { items: [{ publicId: "db_\u2026", type: "postgres", status: "running", \u2026 }] }'
|
|
528
593
|
].join("\n"),
|
|
529
594
|
input: {
|
|
530
|
-
project_id:
|
|
595
|
+
project_id: z5.number().int().positive().describe("Numeric project ID (from list_projects).")
|
|
531
596
|
},
|
|
532
597
|
handler: async (args2, ctx) => {
|
|
533
598
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -537,6 +602,45 @@ defineTool({
|
|
|
537
602
|
return respond({ summary, data });
|
|
538
603
|
}
|
|
539
604
|
});
|
|
605
|
+
defineTool({
|
|
606
|
+
name: "update_database",
|
|
607
|
+
category: "databases",
|
|
608
|
+
description: [
|
|
609
|
+
"Update a managed database \u2014 rename, change plan tier (memory/CPU), or grow disk size.",
|
|
610
|
+
"",
|
|
611
|
+
"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.",
|
|
612
|
+
"",
|
|
613
|
+
"Inputs:",
|
|
614
|
+
' - database_id: publicId of the database (e.g. "db_\u2026").',
|
|
615
|
+
" - name (optional): new database name.",
|
|
616
|
+
' - plan (optional): "free" | "starter" | "standard" | "pro" \u2014 changes memory tier.',
|
|
617
|
+
" - disk_size_gb (optional): new disk size in GB (must be \u2265 current).",
|
|
618
|
+
"",
|
|
619
|
+
"Returns: { database: Database } \u2014 the updated record.",
|
|
620
|
+
"",
|
|
621
|
+
'Example: update_database({ database_id: "db_xyz", disk_size_gb: 50 }) \u2192 { database: { diskSizeGb: 50, \u2026 } }'
|
|
622
|
+
].join("\n"),
|
|
623
|
+
input: {
|
|
624
|
+
database_id: z5.string().describe("Database publicId (e.g. db_xyz)."),
|
|
625
|
+
name: z5.string().min(1).max(100).optional().describe("New database name."),
|
|
626
|
+
plan: z5.enum(["free", "starter", "standard", "pro"]).optional().describe("Plan tier (memory/CPU)."),
|
|
627
|
+
disk_size_gb: z5.number().int().min(1).max(1024).optional().describe("New disk size in GB. Must be \u2265 current.")
|
|
628
|
+
},
|
|
629
|
+
handler: async (args2, ctx) => {
|
|
630
|
+
const teamId = await ctx.resolveTeamId();
|
|
631
|
+
const input = {};
|
|
632
|
+
if (args2.name !== void 0) input.name = args2.name;
|
|
633
|
+
if (args2.plan !== void 0) input.plan = args2.plan;
|
|
634
|
+
if (args2.disk_size_gb !== void 0) input.diskSizeGb = args2.disk_size_gb;
|
|
635
|
+
if (Object.keys(input).length === 0) {
|
|
636
|
+
return respond({ summary: "No fields to update.", data: {} });
|
|
637
|
+
}
|
|
638
|
+
const response = await ctx.hoststack.databases.update(teamId, args2.database_id, input);
|
|
639
|
+
const data = { database: shapeDatabase(response.database) };
|
|
640
|
+
const fields = Object.keys(input).join(", ");
|
|
641
|
+
return respond({ summary: `Updated ${fields} on database ${args2.database_id}.`, data });
|
|
642
|
+
}
|
|
643
|
+
});
|
|
540
644
|
defineTool({
|
|
541
645
|
name: "get_database",
|
|
542
646
|
category: "databases",
|
|
@@ -553,7 +657,7 @@ defineTool({
|
|
|
553
657
|
'Example: get_database({ database_id: "db_xyz" }) \u2192 { database: { type: "postgres", version: "16", status: "running", \u2026 } }'
|
|
554
658
|
].join("\n"),
|
|
555
659
|
input: {
|
|
556
|
-
database_id:
|
|
660
|
+
database_id: z5.string().describe("Database publicId (e.g. db_xyz).")
|
|
557
661
|
},
|
|
558
662
|
handler: async (args2, ctx) => {
|
|
559
663
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -566,7 +670,7 @@ defineTool({
|
|
|
566
670
|
});
|
|
567
671
|
|
|
568
672
|
// src/tools/deploys.ts
|
|
569
|
-
import { z as
|
|
673
|
+
import { z as z6 } from "zod";
|
|
570
674
|
defineTool({
|
|
571
675
|
name: "list_deploys",
|
|
572
676
|
category: "deploys",
|
|
@@ -578,12 +682,12 @@ defineTool({
|
|
|
578
682
|
"Inputs:",
|
|
579
683
|
' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
|
|
580
684
|
"",
|
|
581
|
-
"Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt,
|
|
685
|
+
"Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt, buildDurationMs (just the docker build / image-pull step), totalDurationMs (full deploy wall-clock = finishedAt \u2212 startedAt).",
|
|
582
686
|
"",
|
|
583
687
|
'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
|
|
584
688
|
].join("\n"),
|
|
585
689
|
input: {
|
|
586
|
-
service_id:
|
|
690
|
+
service_id: z6.string().describe("Service publicId (e.g. svc_abc123).")
|
|
587
691
|
},
|
|
588
692
|
handler: async (args2, ctx) => {
|
|
589
693
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -605,13 +709,13 @@ defineTool({
|
|
|
605
709
|
" - service_id: publicId of the service.",
|
|
606
710
|
' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
|
|
607
711
|
"",
|
|
608
|
-
"Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch,
|
|
712
|
+
"Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, buildDurationMs, totalDurationMs, finishedAt, etc).",
|
|
609
713
|
"",
|
|
610
714
|
'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
|
|
611
715
|
].join("\n"),
|
|
612
716
|
input: {
|
|
613
|
-
service_id:
|
|
614
|
-
deploy_id:
|
|
717
|
+
service_id: z6.string().describe("Service publicId."),
|
|
718
|
+
deploy_id: z6.string().describe("Deploy publicId.")
|
|
615
719
|
},
|
|
616
720
|
handler: async (args2, ctx) => {
|
|
617
721
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -638,8 +742,8 @@ defineTool({
|
|
|
638
742
|
'Example: trigger_deploy({ service_id: "svc_abc" }) \u2192 { deploy: { publicId: "dpl_\u2026", status: "pending", \u2026 } }'
|
|
639
743
|
].join("\n"),
|
|
640
744
|
input: {
|
|
641
|
-
service_id:
|
|
642
|
-
clear_cache:
|
|
745
|
+
service_id: z6.string().describe("Service publicId."),
|
|
746
|
+
clear_cache: z6.boolean().optional().describe("Clear the build cache (default false).")
|
|
643
747
|
},
|
|
644
748
|
handler: async (args2, ctx) => {
|
|
645
749
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -671,8 +775,8 @@ defineTool({
|
|
|
671
775
|
'Example: cancel_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { ok: true }'
|
|
672
776
|
].join("\n"),
|
|
673
777
|
input: {
|
|
674
|
-
service_id:
|
|
675
|
-
deploy_id:
|
|
778
|
+
service_id: z6.string().describe("Service publicId."),
|
|
779
|
+
deploy_id: z6.string().describe("Deploy publicId.")
|
|
676
780
|
},
|
|
677
781
|
handler: async (args2, ctx) => {
|
|
678
782
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -700,10 +804,10 @@ defineTool({
|
|
|
700
804
|
'Example: get_deploy_logs({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { logs: "Building\u2026\\nStep 1/8\u2026\\n\u2026", lineCount: 42, nextAfterId: 1234 }'
|
|
701
805
|
].join("\n"),
|
|
702
806
|
input: {
|
|
703
|
-
service_id:
|
|
704
|
-
deploy_id:
|
|
705
|
-
after_id:
|
|
706
|
-
limit:
|
|
807
|
+
service_id: z6.string().describe("Service publicId."),
|
|
808
|
+
deploy_id: z6.string().describe("Deploy publicId."),
|
|
809
|
+
after_id: z6.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
|
|
810
|
+
limit: z6.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
|
|
707
811
|
},
|
|
708
812
|
handler: async (args2, ctx) => {
|
|
709
813
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -724,9 +828,379 @@ defineTool({
|
|
|
724
828
|
});
|
|
725
829
|
}
|
|
726
830
|
});
|
|
831
|
+
defineTool({
|
|
832
|
+
name: "diagnose_deploy",
|
|
833
|
+
category: "deploys",
|
|
834
|
+
description: [
|
|
835
|
+
"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.",
|
|
836
|
+
"",
|
|
837
|
+
"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.",
|
|
838
|
+
"",
|
|
839
|
+
"Inputs:",
|
|
840
|
+
" - service_id: publicId of the service.",
|
|
841
|
+
" - deploy_id: publicId of the deploy to diagnose.",
|
|
842
|
+
" - build_log_lines (optional): how many trailing build log lines to include. Default 200, max 2000.",
|
|
843
|
+
" - 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).",
|
|
844
|
+
"",
|
|
845
|
+
"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.",
|
|
846
|
+
"",
|
|
847
|
+
'Example: diagnose_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 all three sections in one call.'
|
|
848
|
+
].join("\n"),
|
|
849
|
+
input: {
|
|
850
|
+
service_id: z6.string().describe("Service publicId."),
|
|
851
|
+
deploy_id: z6.string().describe("Deploy publicId."),
|
|
852
|
+
build_log_lines: z6.number().int().min(1).max(2e3).optional().describe("Trailing build log lines. Default 200, max 2000."),
|
|
853
|
+
runtime_log_lines: z6.number().int().min(0).max(1e3).optional().describe(
|
|
854
|
+
"Trailing runtime log lines since deploy start. Default 100, max 1000. 0 = skip."
|
|
855
|
+
)
|
|
856
|
+
},
|
|
857
|
+
handler: async (args2, ctx) => {
|
|
858
|
+
const teamId = await ctx.resolveTeamId();
|
|
859
|
+
const buildLimit = args2.build_log_lines ?? 200;
|
|
860
|
+
const runtimeLimit = args2.runtime_log_lines ?? 100;
|
|
861
|
+
const [deployResp, buildResp] = await Promise.all([
|
|
862
|
+
ctx.hoststack.deploys.get(teamId, args2.service_id, args2.deploy_id),
|
|
863
|
+
ctx.hoststack.deploys.getLogs(teamId, args2.service_id, args2.deploy_id, {
|
|
864
|
+
limit: buildLimit
|
|
865
|
+
})
|
|
866
|
+
]);
|
|
867
|
+
const buildEntries = buildResp.logs;
|
|
868
|
+
const buildLogs = buildEntries.map((e) => e.message).join("\n");
|
|
869
|
+
const deployShape = shapeDeploy(deployResp.deploy);
|
|
870
|
+
const deployStatus = deployShape && "status" in deployShape ? deployShape.status : "unknown";
|
|
871
|
+
const startedAtRaw = deployShape && "startedAt" in deployShape ? deployShape.startedAt : void 0;
|
|
872
|
+
const startedAt = typeof startedAtRaw === "string" ? startedAtRaw : void 0;
|
|
873
|
+
const data = {
|
|
874
|
+
deploy: deployShape,
|
|
875
|
+
build: { logs: buildLogs, lineCount: buildEntries.length }
|
|
876
|
+
};
|
|
877
|
+
const hadContainer = deployStatus !== "pending" && deployStatus !== "building";
|
|
878
|
+
if (runtimeLimit > 0 && hadContainer) {
|
|
879
|
+
const runtimeResp = await ctx.hoststack.services.getRuntimeLogs(
|
|
880
|
+
teamId,
|
|
881
|
+
args2.service_id,
|
|
882
|
+
{
|
|
883
|
+
lines: runtimeLimit,
|
|
884
|
+
...startedAt ? { since: startedAt } : {}
|
|
885
|
+
}
|
|
886
|
+
);
|
|
887
|
+
if ("count" in runtimeResp) {
|
|
888
|
+
data["runtime"] = { logs: "", lineCount: runtimeResp.count };
|
|
889
|
+
} else {
|
|
890
|
+
const runtimeEntries = Array.isArray(runtimeResp.logs) ? runtimeResp.logs : typeof runtimeResp.logs === "string" ? runtimeResp.logs.split("\n").map((line) => ({ message: line })) : [];
|
|
891
|
+
const runtimeText = runtimeEntries.map(
|
|
892
|
+
(e) => typeof e === "object" && "message" in e ? String(e.message) : String(e)
|
|
893
|
+
).join("\n");
|
|
894
|
+
data["runtime"] = {
|
|
895
|
+
logs: runtimeText,
|
|
896
|
+
lineCount: runtimeEntries.length,
|
|
897
|
+
sinceWindow: startedAt ?? null
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const failureSummary = shape({
|
|
902
|
+
status: deployStatus,
|
|
903
|
+
errorMessage: deployShape && "errorMessage" in deployShape ? deployShape.errorMessage : void 0
|
|
904
|
+
});
|
|
905
|
+
data["summary"] = failureSummary;
|
|
906
|
+
return respond({
|
|
907
|
+
summary: `Diagnostic bundle for deploy ${args2.deploy_id} (${deployStatus}): ${buildEntries.length} build log line${buildEntries.length === 1 ? "" : "s"}${runtimeLimit > 0 && hadContainer ? ", runtime log tail included" : ""}.`,
|
|
908
|
+
data
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// src/tools/dns-records.ts
|
|
914
|
+
import { z as z7 } from "zod";
|
|
915
|
+
var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "ALIAS"];
|
|
916
|
+
async function resolveZonePublicId(api, teamId, input) {
|
|
917
|
+
if (input.zone_id) {
|
|
918
|
+
const { zones: zones2 } = await api.get(`/api/dns-zones/${teamId}`);
|
|
919
|
+
const match = zones2.find((z15) => z15.publicId === input.zone_id);
|
|
920
|
+
if (!match) {
|
|
921
|
+
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
922
|
+
}
|
|
923
|
+
return { publicId: match.publicId, domainName: match.domainName };
|
|
924
|
+
}
|
|
925
|
+
if (!input.domain) {
|
|
926
|
+
throw new Error("Provide either zone_id or domain.");
|
|
927
|
+
}
|
|
928
|
+
const { zones } = await api.get(`/api/dns-zones/${teamId}`);
|
|
929
|
+
const fqdn = input.domain.toLowerCase().replace(/\.$/, "");
|
|
930
|
+
const labels = fqdn.split(".");
|
|
931
|
+
for (let i = 0; i < labels.length - 1; i++) {
|
|
932
|
+
const candidate = labels.slice(i).join(".");
|
|
933
|
+
const match = zones.find((z15) => z15.domainName.toLowerCase() === candidate);
|
|
934
|
+
if (match && match.status !== "deleting") {
|
|
935
|
+
return { publicId: match.publicId, domainName: match.domainName };
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
throw new Error(
|
|
939
|
+
`No hosted zone matches ${input.domain}. Create the zone in the dashboard (Domains \u2192 DNS) first, then retry.`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
defineTool({
|
|
943
|
+
name: "list_dns_zones",
|
|
944
|
+
category: "dns",
|
|
945
|
+
description: [
|
|
946
|
+
"List authoritative DNS zones the team owns on HostStack's PowerDNS infrastructure. Each zone is the apex domain (e.g. example.com) under which DNS records live.",
|
|
947
|
+
"",
|
|
948
|
+
"When to use: discover which apex domains support record management before calling create_dns_record / list_dns_records. The dashboard equivalent is Domains \u2192 DNS.",
|
|
949
|
+
"",
|
|
950
|
+
'Returns: { items: Zone[] } \u2014 each zone exposes publicId, domainName, status ("active" | "syncing" | "failed" | "deleting"), nsRecords (the nameservers the parent registry must delegate to), provider, createdAt.',
|
|
951
|
+
"",
|
|
952
|
+
'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
|
|
953
|
+
].join("\n"),
|
|
954
|
+
input: {},
|
|
955
|
+
handler: async (_args, ctx) => {
|
|
956
|
+
const teamId = await ctx.resolveTeamId();
|
|
957
|
+
const response = await ctx.api.get(`/api/dns-zones/${teamId}`);
|
|
958
|
+
const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
|
|
959
|
+
const summary = items.length === 0 ? "No DNS zones hosted on this team. Create one in the dashboard (Domains \u2192 DNS)." : `Found ${items.length} hosted DNS zone${items.length === 1 ? "" : "s"}.`;
|
|
960
|
+
return respond({ summary, data: { items } });
|
|
961
|
+
}
|
|
962
|
+
});
|
|
963
|
+
defineTool({
|
|
964
|
+
name: "list_dns_records",
|
|
965
|
+
category: "dns",
|
|
966
|
+
description: [
|
|
967
|
+
"List every active record in a hosted DNS zone. Records are addressed by their type + name + value tuple; PowerDNS gathers records with the same (name, type) into an RRset on the wire.",
|
|
968
|
+
"",
|
|
969
|
+
"When to use: audit what is currently published for a zone before editing, verify a recently-created record landed, or read existing TXT verification strings.",
|
|
970
|
+
"",
|
|
971
|
+
'Provide EITHER zone_id (the zone publicId, e.g. "dnz_abc") OR domain (an apex or subdomain \u2014 the longest-matching hosted apex wins; "app.example.com" matches a hosted "example.com" zone).',
|
|
972
|
+
"",
|
|
973
|
+
'Returns: { zone: { publicId, domainName }, items: Record[] } \u2014 each record includes publicId, type, name ("@" for apex, or label), value, ttl, priority?, status ("active" | "syncing" | "failed"), managedBy ("user" | "hoststack" | "poststack"), createdAt.',
|
|
974
|
+
"",
|
|
975
|
+
'Example: list_dns_records({ domain: "micci.dk" }) \u2192 { zone: { domainName: "micci.dk", \u2026 }, items: [{ type: "TXT", name: "@", value: "google-site-verification=\u2026", ttl: 300, \u2026 }] }'
|
|
976
|
+
].join("\n"),
|
|
977
|
+
input: {
|
|
978
|
+
zone_id: z7.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
|
|
979
|
+
domain: z7.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
|
|
980
|
+
},
|
|
981
|
+
handler: async (args2, ctx) => {
|
|
982
|
+
const teamId = await ctx.resolveTeamId();
|
|
983
|
+
const zoneInput = {};
|
|
984
|
+
if (args2.zone_id !== void 0) zoneInput.zone_id = args2.zone_id;
|
|
985
|
+
if (args2.domain !== void 0) zoneInput.domain = args2.domain;
|
|
986
|
+
const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
|
|
987
|
+
const response = await ctx.api.get(
|
|
988
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`
|
|
989
|
+
);
|
|
990
|
+
const items = Array.isArray(response.records) ? response.records.map(shape) : [];
|
|
991
|
+
const summary = items.length === 0 ? `Zone ${zone.domainName} has no records yet.` : `Returned ${items.length} record${items.length === 1 ? "" : "s"} for ${zone.domainName}.`;
|
|
992
|
+
return respond({
|
|
993
|
+
summary,
|
|
994
|
+
data: { zone: { publicId: zone.publicId, domainName: zone.domainName }, items }
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
});
|
|
998
|
+
defineTool({
|
|
999
|
+
name: "get_dns_record",
|
|
1000
|
+
category: "dns",
|
|
1001
|
+
description: [
|
|
1002
|
+
'Fetch a single DNS record by its publicId. Useful for confirming the record landed after create_dns_record / update_dns_record (status transitions from "syncing" to "active" once PowerDNS acknowledges).',
|
|
1003
|
+
"",
|
|
1004
|
+
"When to use: verify a record exists, inspect its current ttl/value, or check the sync status of an in-flight write.",
|
|
1005
|
+
"",
|
|
1006
|
+
"Inputs:",
|
|
1007
|
+
' - record_id: record publicId (e.g. "dnr_abc"). Returned by list_dns_records and create_dns_record.',
|
|
1008
|
+
"",
|
|
1009
|
+
"Returns: { record: Record }. Mirrors the shape returned by list_dns_records.",
|
|
1010
|
+
"",
|
|
1011
|
+
'Example: get_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { type: "TXT", name: "@", value: "\u2026", status: "active", \u2026 } }'
|
|
1012
|
+
].join("\n"),
|
|
1013
|
+
input: {
|
|
1014
|
+
record_id: z7.string().describe("Record publicId.")
|
|
1015
|
+
},
|
|
1016
|
+
handler: async (args2, ctx) => {
|
|
1017
|
+
const teamId = await ctx.resolveTeamId();
|
|
1018
|
+
const { zones } = await ctx.api.get(`/api/dns-zones/${teamId}`);
|
|
1019
|
+
for (const zone of zones) {
|
|
1020
|
+
const { records } = await ctx.api.get(
|
|
1021
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`
|
|
1022
|
+
);
|
|
1023
|
+
const match = records.find((r) => r.publicId === args2.record_id);
|
|
1024
|
+
if (match) {
|
|
1025
|
+
return respond({
|
|
1026
|
+
summary: `Record ${match.type} ${match.name} on ${zone.domainName}.`,
|
|
1027
|
+
data: {
|
|
1028
|
+
zone: { publicId: zone.publicId, domainName: zone.domainName },
|
|
1029
|
+
record: shape(match)
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return respondError(`Record ${args2.record_id} not found on any zone owned by this team.`);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
defineTool({
|
|
1038
|
+
name: "create_dns_record",
|
|
1039
|
+
category: "dns",
|
|
1040
|
+
description: [
|
|
1041
|
+
'Create a DNS record on a hosted zone. Writes to the DB first, then mirrors to PowerDNS \u2014 record returns with status="syncing" until the provider acknowledges, then flips to "active". Re-creating the same (zone, type, name, value) tuple is idempotent and returns the existing row.',
|
|
1042
|
+
"",
|
|
1043
|
+
"When to use: add TXT verification records (Google Search Console, domain ownership challenges, DKIM), A/AAAA records pointing a subdomain at an IP, CNAME aliases, MX records, etc.",
|
|
1044
|
+
"",
|
|
1045
|
+
"Provide EITHER zone_id (zone publicId) OR domain (resolves via longest-suffix match against hosted zones).",
|
|
1046
|
+
"",
|
|
1047
|
+
"Inputs:",
|
|
1048
|
+
' - type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "SRV" | "CAA" | "ALIAS".',
|
|
1049
|
+
' - name: "@" for the zone apex, or a subdomain label ("www", "api", "_acme-challenge").',
|
|
1050
|
+
" - content: the literal record value. For TXT pass the unquoted string \u2014 the PowerDNS layer handles quoting.",
|
|
1051
|
+
" - ttl (optional): 60\u201386400 seconds, default 3600.",
|
|
1052
|
+
" - priority (optional, required for MX and SRV): 0\u201365535.",
|
|
1053
|
+
"",
|
|
1054
|
+
"Returns: { record: Record } \u2014 see get_dns_record for the shape.",
|
|
1055
|
+
"",
|
|
1056
|
+
'Example: create_dns_record({ domain: "micci.dk", type: "TXT", name: "@", content: "google-site-verification=C0V0scK48g2\u2026", ttl: 300 })'
|
|
1057
|
+
].join("\n"),
|
|
1058
|
+
input: {
|
|
1059
|
+
zone_id: z7.string().optional().describe("Zone publicId."),
|
|
1060
|
+
domain: z7.string().optional().describe("Apex or subdomain to resolve a hosted zone."),
|
|
1061
|
+
type: z7.enum(DNS_RECORD_TYPES).describe("DNS record type."),
|
|
1062
|
+
name: z7.string().min(1).max(253).describe('"@" for apex, or label (no trailing dot).'),
|
|
1063
|
+
content: z7.string().min(1).max(2e3).describe("Record value; TXT values are unquoted."),
|
|
1064
|
+
ttl: z7.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
|
|
1065
|
+
priority: z7.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
|
|
1066
|
+
},
|
|
1067
|
+
handler: async (args2, ctx) => {
|
|
1068
|
+
if ((args2.type === "MX" || args2.type === "SRV") && args2.priority === void 0) {
|
|
1069
|
+
return respondError(`Priority is required for ${args2.type} records (0\u201365535).`);
|
|
1070
|
+
}
|
|
1071
|
+
const teamId = await ctx.resolveTeamId();
|
|
1072
|
+
const zoneInput = {};
|
|
1073
|
+
if (args2.zone_id !== void 0) zoneInput.zone_id = args2.zone_id;
|
|
1074
|
+
if (args2.domain !== void 0) zoneInput.domain = args2.domain;
|
|
1075
|
+
const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
|
|
1076
|
+
const body = {
|
|
1077
|
+
type: args2.type,
|
|
1078
|
+
name: args2.name,
|
|
1079
|
+
value: args2.content
|
|
1080
|
+
};
|
|
1081
|
+
if (args2.ttl !== void 0) body.ttl = args2.ttl;
|
|
1082
|
+
if (args2.priority !== void 0) body.priority = args2.priority;
|
|
1083
|
+
const response = await ctx.api.post(
|
|
1084
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`,
|
|
1085
|
+
body
|
|
1086
|
+
);
|
|
1087
|
+
return respond({
|
|
1088
|
+
summary: `Created ${args2.type} ${args2.name} on ${zone.domainName}.`,
|
|
1089
|
+
data: {
|
|
1090
|
+
zone: { publicId: zone.publicId, domainName: zone.domainName },
|
|
1091
|
+
record: shape(response.record)
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
defineTool({
|
|
1097
|
+
name: "update_dns_record",
|
|
1098
|
+
category: "dns",
|
|
1099
|
+
description: [
|
|
1100
|
+
"Update an existing DNS record by its publicId. Use this to change a TTL, swap a value, or move the record to a different (name, type) bucket. Full-record PUT semantics: every mutable field must be provided.",
|
|
1101
|
+
"",
|
|
1102
|
+
"When to use: rotate a DKIM key, repoint an A record at a new IP, bump TTL ahead of a planned change.",
|
|
1103
|
+
"",
|
|
1104
|
+
"Inputs:",
|
|
1105
|
+
" - record_id: record publicId.",
|
|
1106
|
+
" - type, name, content: the new values (the route is full-replace, not patch \u2014 fetch with get_dns_record first if you only want to tweak one field).",
|
|
1107
|
+
" - ttl (optional), priority (optional, required for MX / SRV).",
|
|
1108
|
+
"",
|
|
1109
|
+
"Returns: { record: Record } reflecting the new state.",
|
|
1110
|
+
"",
|
|
1111
|
+
'Example: update_dns_record({ record_id: "dnr_abc", type: "A", name: "api", content: "203.0.113.42", ttl: 300 })'
|
|
1112
|
+
].join("\n"),
|
|
1113
|
+
input: {
|
|
1114
|
+
record_id: z7.string().describe("Record publicId."),
|
|
1115
|
+
type: z7.enum(DNS_RECORD_TYPES).describe("DNS record type."),
|
|
1116
|
+
name: z7.string().min(1).max(253).describe('"@" for apex, or label.'),
|
|
1117
|
+
content: z7.string().min(1).max(2e3).describe("Record value; TXT unquoted."),
|
|
1118
|
+
ttl: z7.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
|
|
1119
|
+
priority: z7.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
|
|
1120
|
+
},
|
|
1121
|
+
handler: async (args2, ctx) => {
|
|
1122
|
+
if ((args2.type === "MX" || args2.type === "SRV") && args2.priority === void 0) {
|
|
1123
|
+
return respondError(`Priority is required for ${args2.type} records (0\u201365535).`);
|
|
1124
|
+
}
|
|
1125
|
+
const teamId = await ctx.resolveTeamId();
|
|
1126
|
+
const target = await findRecordZone(ctx.api, teamId, args2.record_id);
|
|
1127
|
+
if (!target) {
|
|
1128
|
+
return respondError(
|
|
1129
|
+
`Record ${args2.record_id} not found on any zone owned by this team.`
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
const body = {
|
|
1133
|
+
type: args2.type,
|
|
1134
|
+
name: args2.name,
|
|
1135
|
+
value: args2.content
|
|
1136
|
+
};
|
|
1137
|
+
if (args2.ttl !== void 0) body.ttl = args2.ttl;
|
|
1138
|
+
if (args2.priority !== void 0) body.priority = args2.priority;
|
|
1139
|
+
const response = await ctx.api.put(
|
|
1140
|
+
`/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args2.record_id}`,
|
|
1141
|
+
body
|
|
1142
|
+
);
|
|
1143
|
+
return respond({
|
|
1144
|
+
summary: `Updated ${args2.type} ${args2.name} on ${target.zone.domainName}.`,
|
|
1145
|
+
data: {
|
|
1146
|
+
zone: {
|
|
1147
|
+
publicId: target.zone.publicId,
|
|
1148
|
+
domainName: target.zone.domainName
|
|
1149
|
+
},
|
|
1150
|
+
record: shape(response.record)
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
});
|
|
1155
|
+
defineTool({
|
|
1156
|
+
name: "delete_dns_record",
|
|
1157
|
+
category: "dns",
|
|
1158
|
+
description: [
|
|
1159
|
+
"Soft-delete a DNS record and remove it from PowerDNS. DESTRUCTIVE: resolvers stop returning the value as soon as the change propagates. Same code path as the dashboard delete button \u2014 the row is marked deleted and the RRset is re-pushed without it.",
|
|
1160
|
+
"",
|
|
1161
|
+
"When to use: retire a TXT verification record once the challenge has been confirmed; drop an A record for a decommissioned subdomain.",
|
|
1162
|
+
"",
|
|
1163
|
+
"Inputs:",
|
|
1164
|
+
" - record_id: record publicId.",
|
|
1165
|
+
"",
|
|
1166
|
+
"Returns: { ok: true }.",
|
|
1167
|
+
"",
|
|
1168
|
+
'Example: delete_dns_record({ record_id: "dnr_abc" }) \u2192 { ok: true }'
|
|
1169
|
+
].join("\n"),
|
|
1170
|
+
input: {
|
|
1171
|
+
record_id: z7.string().describe("Record publicId.")
|
|
1172
|
+
},
|
|
1173
|
+
handler: async (args2, ctx) => {
|
|
1174
|
+
const teamId = await ctx.resolveTeamId();
|
|
1175
|
+
const target = await findRecordZone(ctx.api, teamId, args2.record_id);
|
|
1176
|
+
if (!target) {
|
|
1177
|
+
return respondError(
|
|
1178
|
+
`Record ${args2.record_id} not found on any zone owned by this team.`
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
await ctx.api.delete(
|
|
1182
|
+
`/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args2.record_id}`
|
|
1183
|
+
);
|
|
1184
|
+
return respond({
|
|
1185
|
+
summary: `Deleted record ${args2.record_id} from ${target.zone.domainName}.`,
|
|
1186
|
+
data: { ok: true }
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
async function findRecordZone(api, teamId, recordPublicId) {
|
|
1191
|
+
const { zones } = await api.get(`/api/dns-zones/${teamId}`);
|
|
1192
|
+
for (const zone of zones) {
|
|
1193
|
+
const { records } = await api.get(
|
|
1194
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`
|
|
1195
|
+
);
|
|
1196
|
+
const match = records.find((r) => r.publicId === recordPublicId);
|
|
1197
|
+
if (match) return { zone, record: match };
|
|
1198
|
+
}
|
|
1199
|
+
return null;
|
|
1200
|
+
}
|
|
727
1201
|
|
|
728
1202
|
// src/tools/domains.ts
|
|
729
|
-
import { z as
|
|
1203
|
+
import { z as z8 } from "zod";
|
|
730
1204
|
defineTool({
|
|
731
1205
|
name: "list_domains",
|
|
732
1206
|
category: "domains",
|
|
@@ -765,8 +1239,8 @@ defineTool({
|
|
|
765
1239
|
'Example: add_domain({ hostname: "api.example.com", service_id: "svc_abc" }) \u2192 { domain: { hostname: "api.example.com", dnsTargets: [...], verified: false, \u2026 } }'
|
|
766
1240
|
].join("\n"),
|
|
767
1241
|
input: {
|
|
768
|
-
hostname:
|
|
769
|
-
service_id:
|
|
1242
|
+
hostname: z8.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
|
|
1243
|
+
service_id: z8.string().optional().describe("Optional service publicId to bind to.")
|
|
770
1244
|
},
|
|
771
1245
|
handler: async (args2, ctx) => {
|
|
772
1246
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -796,12 +1270,15 @@ defineTool({
|
|
|
796
1270
|
'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
|
|
797
1271
|
].join("\n"),
|
|
798
1272
|
input: {
|
|
799
|
-
domain_id:
|
|
1273
|
+
domain_id: z8.string().describe("Domain publicId.")
|
|
800
1274
|
},
|
|
801
1275
|
handler: async (args2, ctx) => {
|
|
802
1276
|
const teamId = await ctx.resolveTeamId();
|
|
803
1277
|
await ctx.hoststack.domains.verify(teamId, args2.domain_id);
|
|
804
|
-
return respond({
|
|
1278
|
+
return respond({
|
|
1279
|
+
summary: `Triggered DNS verification for ${args2.domain_id}.`,
|
|
1280
|
+
data: { ok: true }
|
|
1281
|
+
});
|
|
805
1282
|
}
|
|
806
1283
|
});
|
|
807
1284
|
defineTool({
|
|
@@ -820,7 +1297,7 @@ defineTool({
|
|
|
820
1297
|
'Example: remove_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
|
|
821
1298
|
].join("\n"),
|
|
822
1299
|
input: {
|
|
823
|
-
domain_id:
|
|
1300
|
+
domain_id: z8.string().describe("Domain publicId.")
|
|
824
1301
|
},
|
|
825
1302
|
handler: async (args2, ctx) => {
|
|
826
1303
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -830,7 +1307,7 @@ defineTool({
|
|
|
830
1307
|
});
|
|
831
1308
|
|
|
832
1309
|
// src/tools/env-vars.ts
|
|
833
|
-
import { z as
|
|
1310
|
+
import { z as z9 } from "zod";
|
|
834
1311
|
defineTool({
|
|
835
1312
|
name: "list_env_vars",
|
|
836
1313
|
category: "env-vars",
|
|
@@ -847,7 +1324,7 @@ defineTool({
|
|
|
847
1324
|
'Example: list_env_vars({ service_id: "svc_abc" }) \u2192 { items: [{ key: "DATABASE_URL", value: "\u2022\u2022\u2022\u2022\u2022\u2022", isSecret: true }, { key: "PORT", value: "3000", isSecret: false }] }'
|
|
848
1325
|
].join("\n"),
|
|
849
1326
|
input: {
|
|
850
|
-
service_id:
|
|
1327
|
+
service_id: z9.string().describe("Service publicId.")
|
|
851
1328
|
},
|
|
852
1329
|
handler: async (args2, ctx) => {
|
|
853
1330
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -876,10 +1353,10 @@ defineTool({
|
|
|
876
1353
|
'Example: set_env_var({ service_id: "svc_abc", key: "DATABASE_URL", value: "postgres://\u2026", is_secret: true }) \u2192 { envVar: { key: "DATABASE_URL", value: "\u2022\u2022\u2022\u2022\u2022\u2022", \u2026 }, action: "updated" }'
|
|
877
1354
|
].join("\n"),
|
|
878
1355
|
input: {
|
|
879
|
-
service_id:
|
|
880
|
-
key:
|
|
881
|
-
value:
|
|
882
|
-
is_secret:
|
|
1356
|
+
service_id: z9.string().describe("Service publicId."),
|
|
1357
|
+
key: z9.string().min(1).max(128).describe("Env-var key."),
|
|
1358
|
+
value: z9.string().describe("New value."),
|
|
1359
|
+
is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
|
|
883
1360
|
},
|
|
884
1361
|
handler: async (args2, ctx) => {
|
|
885
1362
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -887,10 +1364,15 @@ defineTool({
|
|
|
887
1364
|
const existing = await ctx.hoststack.envVars.list(teamId, args2.service_id);
|
|
888
1365
|
const match = existing.envVars.find((v) => v.key === args2.key);
|
|
889
1366
|
if (match) {
|
|
890
|
-
const response2 = await ctx.hoststack.envVars.update(
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1367
|
+
const response2 = await ctx.hoststack.envVars.update(
|
|
1368
|
+
teamId,
|
|
1369
|
+
args2.service_id,
|
|
1370
|
+
String(match.id),
|
|
1371
|
+
{
|
|
1372
|
+
value: args2.value,
|
|
1373
|
+
isSecret
|
|
1374
|
+
}
|
|
1375
|
+
);
|
|
894
1376
|
const data2 = {
|
|
895
1377
|
envVar: shapeEnvVar(response2.envVar),
|
|
896
1378
|
action: "updated"
|
|
@@ -926,18 +1408,18 @@ defineTool({
|
|
|
926
1408
|
'Example: delete_env_var({ service_id: "svc_abc", key: "OLD_FLAG" }) \u2192 { ok: true }'
|
|
927
1409
|
].join("\n"),
|
|
928
1410
|
input: {
|
|
929
|
-
service_id:
|
|
930
|
-
key:
|
|
1411
|
+
service_id: z9.string().describe("Service publicId."),
|
|
1412
|
+
key: z9.string().min(1).max(128).describe("Env-var key to delete.")
|
|
931
1413
|
},
|
|
932
1414
|
handler: async (args2, ctx) => {
|
|
933
1415
|
const teamId = await ctx.resolveTeamId();
|
|
934
1416
|
const existing = await ctx.hoststack.envVars.list(teamId, args2.service_id);
|
|
935
1417
|
const match = existing.envVars.find((v) => v.key === args2.key);
|
|
936
1418
|
if (!match) {
|
|
937
|
-
return respondError(
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
);
|
|
1419
|
+
return respondError(`Env var "${args2.key}" not found on service ${args2.service_id}.`, {
|
|
1420
|
+
key: args2.key,
|
|
1421
|
+
service_id: args2.service_id
|
|
1422
|
+
});
|
|
941
1423
|
}
|
|
942
1424
|
await ctx.hoststack.envVars.delete(teamId, args2.service_id, String(match.id));
|
|
943
1425
|
return respond({
|
|
@@ -963,19 +1445,19 @@ defineTool({
|
|
|
963
1445
|
'Example: bulk_set_env_vars({ service_id: "svc_abc", env_vars: [{ key: "PORT", value: "3000", is_secret: false }, { key: "DATABASE_URL", value: "\u2026", is_secret: true }] }) \u2192 { ok: true }'
|
|
964
1446
|
].join("\n"),
|
|
965
1447
|
input: {
|
|
966
|
-
service_id:
|
|
967
|
-
env_vars:
|
|
968
|
-
|
|
969
|
-
key:
|
|
970
|
-
value:
|
|
971
|
-
is_secret:
|
|
1448
|
+
service_id: z9.string().describe("Service publicId."),
|
|
1449
|
+
env_vars: z9.array(
|
|
1450
|
+
z9.object({
|
|
1451
|
+
key: z9.string().min(1).max(128),
|
|
1452
|
+
value: z9.string(),
|
|
1453
|
+
is_secret: z9.boolean().optional()
|
|
972
1454
|
})
|
|
973
1455
|
).max(500).describe("Array of env-var rows. Hard cap 500.")
|
|
974
1456
|
},
|
|
975
1457
|
handler: async (args2, ctx) => {
|
|
976
1458
|
const teamId = await ctx.resolveTeamId();
|
|
977
1459
|
const payload = {
|
|
978
|
-
|
|
1460
|
+
vars: args2.env_vars.map((v) => {
|
|
979
1461
|
const row = {
|
|
980
1462
|
key: v.key,
|
|
981
1463
|
value: v.value
|
|
@@ -993,7 +1475,7 @@ defineTool({
|
|
|
993
1475
|
});
|
|
994
1476
|
|
|
995
1477
|
// src/tools/environments.ts
|
|
996
|
-
import { z as
|
|
1478
|
+
import { z as z10 } from "zod";
|
|
997
1479
|
defineTool({
|
|
998
1480
|
name: "list_environments",
|
|
999
1481
|
category: "environments",
|
|
@@ -1010,7 +1492,7 @@ defineTool({
|
|
|
1010
1492
|
'Example: list_environments({ project_id: "prj_abc" }) \u2192 { items: [{ name: "Production", type: "production", isDefault: true }, { name: "Staging", type: "staging" }] }'
|
|
1011
1493
|
].join("\n"),
|
|
1012
1494
|
input: {
|
|
1013
|
-
project_id:
|
|
1495
|
+
project_id: z10.string().describe("Project publicId.")
|
|
1014
1496
|
},
|
|
1015
1497
|
handler: async (args2, ctx) => {
|
|
1016
1498
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1039,10 +1521,10 @@ defineTool({
|
|
|
1039
1521
|
'Example: create_environment({ project_id: "prj_abc", name: "Staging", type: "staging" }) \u2192 { environment: { id: 7, publicId: "environment_\u2026", name: "Staging", type: "staging" } }'
|
|
1040
1522
|
].join("\n"),
|
|
1041
1523
|
input: {
|
|
1042
|
-
project_id:
|
|
1043
|
-
name:
|
|
1044
|
-
type:
|
|
1045
|
-
is_protected:
|
|
1524
|
+
project_id: z10.string().describe("Project publicId."),
|
|
1525
|
+
name: z10.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
|
|
1526
|
+
type: z10.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
|
|
1527
|
+
is_protected: z10.boolean().optional().describe("Require admin role for destructive actions. Default false.")
|
|
1046
1528
|
},
|
|
1047
1529
|
handler: async (args2, ctx) => {
|
|
1048
1530
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1076,8 +1558,8 @@ defineTool({
|
|
|
1076
1558
|
'Example: delete_environment({ project_id: "prj_abc", environment_id: "environment_xyz" }) \u2192 { success: true }'
|
|
1077
1559
|
].join("\n"),
|
|
1078
1560
|
input: {
|
|
1079
|
-
project_id:
|
|
1080
|
-
environment_id:
|
|
1561
|
+
project_id: z10.string().describe("Project publicId."),
|
|
1562
|
+
environment_id: z10.union([z10.string(), z10.number()]).describe("Environment publicId or numeric id.")
|
|
1081
1563
|
},
|
|
1082
1564
|
handler: async (args2, ctx) => {
|
|
1083
1565
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1108,9 +1590,9 @@ defineTool({
|
|
|
1108
1590
|
'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" } }'
|
|
1109
1591
|
].join("\n"),
|
|
1110
1592
|
input: {
|
|
1111
|
-
service_id:
|
|
1112
|
-
deploy_id:
|
|
1113
|
-
target_environment_id:
|
|
1593
|
+
service_id: z10.string().describe("Source service publicId."),
|
|
1594
|
+
deploy_id: z10.string().describe("Source deploy publicId (must have a built image)."),
|
|
1595
|
+
target_environment_id: z10.union([z10.string(), z10.number()]).describe("Target environment publicId or numeric id.")
|
|
1114
1596
|
},
|
|
1115
1597
|
handler: async (args2, ctx) => {
|
|
1116
1598
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1158,8 +1640,190 @@ defineTool({
|
|
|
1158
1640
|
}
|
|
1159
1641
|
});
|
|
1160
1642
|
|
|
1643
|
+
// src/tools/notifications.ts
|
|
1644
|
+
import { z as z11 } from "zod";
|
|
1645
|
+
var NOTIFICATION_EVENTS = [
|
|
1646
|
+
"deploy.started",
|
|
1647
|
+
"deploy.succeeded",
|
|
1648
|
+
"deploy.failed",
|
|
1649
|
+
"deploy.failed_consecutive",
|
|
1650
|
+
"service.created",
|
|
1651
|
+
"service.deleted",
|
|
1652
|
+
"service.suspended",
|
|
1653
|
+
"service.resumed",
|
|
1654
|
+
"service.restart_failed",
|
|
1655
|
+
"service.auto_suspended",
|
|
1656
|
+
"service.acme_cert_failed",
|
|
1657
|
+
"git.auth_failed"
|
|
1658
|
+
];
|
|
1659
|
+
defineTool({
|
|
1660
|
+
name: "list_notification_channels",
|
|
1661
|
+
category: "alerts",
|
|
1662
|
+
description: [
|
|
1663
|
+
"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.",
|
|
1664
|
+
"",
|
|
1665
|
+
"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.",
|
|
1666
|
+
"",
|
|
1667
|
+
"Returns: { items: Channel[] } \u2014 each channel includes id, type, name, webhookUrl (masked), active, events, createdAt.",
|
|
1668
|
+
"",
|
|
1669
|
+
"Example: list_notification_channels() \u2192 { items: [{ id: 3, type: 'slack', name: 'eng-alerts', events: ['deploy.failed', 'git.auth_failed'], \u2026 }] }"
|
|
1670
|
+
].join("\n"),
|
|
1671
|
+
input: {},
|
|
1672
|
+
handler: async (_args, ctx) => {
|
|
1673
|
+
const teamId = await ctx.resolveTeamId();
|
|
1674
|
+
const response = await ctx.hoststack.notifications.listChannels(teamId);
|
|
1675
|
+
const data = shapeList(response, "channels", shape);
|
|
1676
|
+
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"}.`;
|
|
1677
|
+
return respond({ summary, data });
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
defineTool({
|
|
1681
|
+
name: "create_notification_channel",
|
|
1682
|
+
category: "alerts",
|
|
1683
|
+
description: [
|
|
1684
|
+
"Create a Slack, Discord, or email notification channel and subscribe it to a list of events.",
|
|
1685
|
+
"",
|
|
1686
|
+
"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.",
|
|
1687
|
+
"",
|
|
1688
|
+
"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.",
|
|
1689
|
+
"",
|
|
1690
|
+
"Inputs:",
|
|
1691
|
+
' - type: "slack" | "discord" | "email".',
|
|
1692
|
+
" - name: human-readable label (1\u2013128 chars).",
|
|
1693
|
+
" - webhook_url: Slack/Discord webhook URL OR email address.",
|
|
1694
|
+
" - 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).",
|
|
1695
|
+
"",
|
|
1696
|
+
"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.",
|
|
1697
|
+
"",
|
|
1698
|
+
"Returns: { channel: Channel }.",
|
|
1699
|
+
"",
|
|
1700
|
+
"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'] })"
|
|
1701
|
+
].join("\n"),
|
|
1702
|
+
input: {
|
|
1703
|
+
type: z11.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
1704
|
+
name: z11.string().min(1).max(128).describe("Human-readable label."),
|
|
1705
|
+
webhook_url: z11.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
1706
|
+
events: z11.array(z11.enum(NOTIFICATION_EVENTS)).describe("List of events the channel subscribes to. Empty list = subscribe to nothing.")
|
|
1707
|
+
},
|
|
1708
|
+
handler: async (args2, ctx) => {
|
|
1709
|
+
const teamId = await ctx.resolveTeamId();
|
|
1710
|
+
const response = await ctx.hoststack.notifications.createChannel(teamId, {
|
|
1711
|
+
type: args2.type,
|
|
1712
|
+
name: args2.name,
|
|
1713
|
+
webhookUrl: args2.webhook_url,
|
|
1714
|
+
events: args2.events
|
|
1715
|
+
});
|
|
1716
|
+
const data = { channel: shape(response.channel) };
|
|
1717
|
+
return respond({
|
|
1718
|
+
summary: `Created ${args2.type} channel "${args2.name}" subscribed to ${args2.events.length} event${args2.events.length === 1 ? "" : "s"}.`,
|
|
1719
|
+
data
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1723
|
+
defineTool({
|
|
1724
|
+
name: "update_notification_channel",
|
|
1725
|
+
category: "alerts",
|
|
1726
|
+
description: [
|
|
1727
|
+
"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.",
|
|
1728
|
+
"",
|
|
1729
|
+
"When to use: silencing a noisy channel (active=false), adding a new event to an existing list, or relabeling.",
|
|
1730
|
+
"",
|
|
1731
|
+
"Inputs:",
|
|
1732
|
+
" - channel_id: numeric id of the channel.",
|
|
1733
|
+
" - name (optional): new label.",
|
|
1734
|
+
" - active (optional): false to silence, true to re-enable.",
|
|
1735
|
+
" - events (optional): replace the full subscription list. Passing [] silences all events without disabling the channel.",
|
|
1736
|
+
"",
|
|
1737
|
+
"Returns: { channel: Channel }.",
|
|
1738
|
+
"",
|
|
1739
|
+
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
1740
|
+
].join("\n"),
|
|
1741
|
+
input: {
|
|
1742
|
+
channel_id: z11.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
1743
|
+
name: z11.string().min(1).max(128).optional().describe("New label."),
|
|
1744
|
+
active: z11.boolean().optional().describe("false silences without deleting."),
|
|
1745
|
+
events: z11.array(z11.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
1746
|
+
},
|
|
1747
|
+
handler: async (args2, ctx) => {
|
|
1748
|
+
const teamId = await ctx.resolveTeamId();
|
|
1749
|
+
const update = {};
|
|
1750
|
+
if (args2.name !== void 0) update.name = args2.name;
|
|
1751
|
+
if (args2.active !== void 0) update.active = args2.active;
|
|
1752
|
+
if (args2.events !== void 0) update.events = args2.events;
|
|
1753
|
+
if (Object.keys(update).length === 0) {
|
|
1754
|
+
return respond({ summary: "No fields to update.", data: {} });
|
|
1755
|
+
}
|
|
1756
|
+
const response = await ctx.hoststack.notifications.updateChannel(
|
|
1757
|
+
teamId,
|
|
1758
|
+
args2.channel_id,
|
|
1759
|
+
update
|
|
1760
|
+
);
|
|
1761
|
+
const data = { channel: shape(response.channel) };
|
|
1762
|
+
return respond({
|
|
1763
|
+
summary: `Updated notification channel #${args2.channel_id}.`,
|
|
1764
|
+
data
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
});
|
|
1768
|
+
defineTool({
|
|
1769
|
+
name: "delete_notification_channel",
|
|
1770
|
+
category: "alerts",
|
|
1771
|
+
description: [
|
|
1772
|
+
"Permanently delete a notification channel. Use update_notification_channel({ active: false }) to silence without deleting if you might want it back later.",
|
|
1773
|
+
"",
|
|
1774
|
+
"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.",
|
|
1775
|
+
"",
|
|
1776
|
+
"Inputs:",
|
|
1777
|
+
" - channel_id: numeric id of the channel.",
|
|
1778
|
+
"",
|
|
1779
|
+
"Returns: { ok: true }.",
|
|
1780
|
+
"",
|
|
1781
|
+
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
1782
|
+
].join("\n"),
|
|
1783
|
+
input: {
|
|
1784
|
+
channel_id: z11.number().int().positive().describe("Numeric channel id.")
|
|
1785
|
+
},
|
|
1786
|
+
handler: async (args2, ctx) => {
|
|
1787
|
+
const teamId = await ctx.resolveTeamId();
|
|
1788
|
+
await ctx.hoststack.notifications.deleteChannel(teamId, args2.channel_id);
|
|
1789
|
+
return respond({
|
|
1790
|
+
summary: `Deleted notification channel #${args2.channel_id}.`,
|
|
1791
|
+
data: { ok: true }
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
defineTool({
|
|
1796
|
+
name: "test_notification_channel",
|
|
1797
|
+
category: "alerts",
|
|
1798
|
+
description: [
|
|
1799
|
+
'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.',
|
|
1800
|
+
"",
|
|
1801
|
+
"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.",
|
|
1802
|
+
"",
|
|
1803
|
+
"Inputs:",
|
|
1804
|
+
" - channel_id: numeric id of the channel.",
|
|
1805
|
+
"",
|
|
1806
|
+
"Returns: { success: boolean, error?: string } \u2014 false + error string when the webhook returned non-2xx.",
|
|
1807
|
+
"",
|
|
1808
|
+
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
1809
|
+
].join("\n"),
|
|
1810
|
+
input: {
|
|
1811
|
+
channel_id: z11.number().int().positive().describe("Numeric channel id.")
|
|
1812
|
+
},
|
|
1813
|
+
handler: async (args2, ctx) => {
|
|
1814
|
+
const teamId = await ctx.resolveTeamId();
|
|
1815
|
+
const response = await ctx.hoststack.notifications.testChannel(teamId, args2.channel_id);
|
|
1816
|
+
const data = shape(response);
|
|
1817
|
+
const okFlag = data && typeof data === "object" && "success" in data ? data.success : false;
|
|
1818
|
+
return respond({
|
|
1819
|
+
summary: okFlag ? `Test event delivered to channel #${args2.channel_id}.` : `Test event FAILED for channel #${args2.channel_id}: ${data.error ?? "unknown error"}.`,
|
|
1820
|
+
data
|
|
1821
|
+
});
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1161
1825
|
// src/tools/projects.ts
|
|
1162
|
-
import { z as
|
|
1826
|
+
import { z as z12 } from "zod";
|
|
1163
1827
|
defineTool({
|
|
1164
1828
|
name: "list_projects",
|
|
1165
1829
|
category: "projects",
|
|
@@ -1199,9 +1863,9 @@ defineTool({
|
|
|
1199
1863
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "fsn1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
1200
1864
|
].join("\n"),
|
|
1201
1865
|
input: {
|
|
1202
|
-
name:
|
|
1203
|
-
description:
|
|
1204
|
-
region:
|
|
1866
|
+
name: z12.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
1867
|
+
description: z12.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
1868
|
+
region: z12.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
|
|
1205
1869
|
},
|
|
1206
1870
|
handler: async (args2, ctx) => {
|
|
1207
1871
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1232,9 +1896,9 @@ defineTool({
|
|
|
1232
1896
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
1233
1897
|
].join("\n"),
|
|
1234
1898
|
input: {
|
|
1235
|
-
project_id:
|
|
1236
|
-
name:
|
|
1237
|
-
description:
|
|
1899
|
+
project_id: z12.string().describe("Project publicId."),
|
|
1900
|
+
name: z12.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
1901
|
+
description: z12.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
1238
1902
|
},
|
|
1239
1903
|
handler: async (args2, ctx) => {
|
|
1240
1904
|
if (args2.name === void 0 && args2.description === void 0) {
|
|
@@ -1268,7 +1932,7 @@ defineTool({
|
|
|
1268
1932
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
1269
1933
|
].join("\n"),
|
|
1270
1934
|
input: {
|
|
1271
|
-
project_id:
|
|
1935
|
+
project_id: z12.string().describe("Project publicId (e.g. prj_abc123).")
|
|
1272
1936
|
},
|
|
1273
1937
|
handler: async (args2, ctx) => {
|
|
1274
1938
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1280,25 +1944,59 @@ defineTool({
|
|
|
1280
1944
|
});
|
|
1281
1945
|
|
|
1282
1946
|
// src/tools/services.ts
|
|
1283
|
-
import { z as
|
|
1947
|
+
import { z as z13 } from "zod";
|
|
1284
1948
|
defineTool({
|
|
1285
1949
|
name: "list_services",
|
|
1286
1950
|
category: "services",
|
|
1287
1951
|
description: [
|
|
1288
|
-
"List
|
|
1952
|
+
"List services (web, worker, cron, private, static) in the active HostStack team.",
|
|
1289
1953
|
"",
|
|
1290
1954
|
"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.",
|
|
1291
1955
|
"",
|
|
1956
|
+
"Inputs (all optional):",
|
|
1957
|
+
' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
|
|
1958
|
+
' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
|
|
1959
|
+
' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
|
|
1960
|
+
' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
|
|
1961
|
+
"",
|
|
1292
1962
|
"Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
|
|
1293
1963
|
"",
|
|
1294
|
-
'Example: list_services(
|
|
1964
|
+
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
1295
1965
|
].join("\n"),
|
|
1296
|
-
input: {
|
|
1297
|
-
|
|
1966
|
+
input: {
|
|
1967
|
+
project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
1968
|
+
environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
1969
|
+
status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
1970
|
+
type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
|
|
1971
|
+
},
|
|
1972
|
+
handler: async (args2, ctx) => {
|
|
1298
1973
|
const teamId = await ctx.resolveTeamId();
|
|
1299
|
-
const
|
|
1974
|
+
const filters = {};
|
|
1975
|
+
if (args2.project_id !== void 0) {
|
|
1976
|
+
const resolved = await ctx.hoststack.resolveId(args2.project_id, {
|
|
1977
|
+
kind: "project",
|
|
1978
|
+
teamId
|
|
1979
|
+
});
|
|
1980
|
+
filters.projectId = resolved;
|
|
1981
|
+
}
|
|
1982
|
+
if (args2.environment_id !== void 0) {
|
|
1983
|
+
const resolved = await ctx.hoststack.resolveId(args2.environment_id, {
|
|
1984
|
+
kind: "environment",
|
|
1985
|
+
teamId
|
|
1986
|
+
});
|
|
1987
|
+
filters.environmentId = resolved;
|
|
1988
|
+
}
|
|
1989
|
+
if (args2.status) filters.status = args2.status;
|
|
1990
|
+
if (args2.type) filters.type = args2.type;
|
|
1991
|
+
const response = await ctx.hoststack.services.list(teamId, filters);
|
|
1300
1992
|
const data = shapeList(response, "services", shapeService);
|
|
1301
|
-
const
|
|
1993
|
+
const filterDesc = [
|
|
1994
|
+
args2.project_id !== void 0 ? `project=${args2.project_id}` : null,
|
|
1995
|
+
args2.environment_id !== void 0 ? `env=${args2.environment_id}` : null,
|
|
1996
|
+
args2.status ? `status=${args2.status}` : null,
|
|
1997
|
+
args2.type ? `type=${args2.type}` : null
|
|
1998
|
+
].filter(Boolean).join(", ");
|
|
1999
|
+
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}` : ""}.`;
|
|
1302
2000
|
return respond({ summary, data });
|
|
1303
2001
|
}
|
|
1304
2002
|
});
|
|
@@ -1318,7 +2016,7 @@ defineTool({
|
|
|
1318
2016
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 } }'
|
|
1319
2017
|
].join("\n"),
|
|
1320
2018
|
input: {
|
|
1321
|
-
service_id:
|
|
2019
|
+
service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1322
2020
|
},
|
|
1323
2021
|
handler: async (args2, ctx) => {
|
|
1324
2022
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1334,7 +2032,7 @@ defineTool({
|
|
|
1334
2032
|
description: [
|
|
1335
2033
|
"Fetch the latest CPU, memory, network, and request-rate metrics for a service.",
|
|
1336
2034
|
"",
|
|
1337
|
-
|
|
2035
|
+
'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.',
|
|
1338
2036
|
"",
|
|
1339
2037
|
"Inputs:",
|
|
1340
2038
|
" - service_id: publicId of the service.",
|
|
@@ -1344,7 +2042,7 @@ defineTool({
|
|
|
1344
2042
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
1345
2043
|
].join("\n"),
|
|
1346
2044
|
input: {
|
|
1347
|
-
service_id:
|
|
2045
|
+
service_id: z13.string().describe("Service publicId.")
|
|
1348
2046
|
},
|
|
1349
2047
|
handler: async (args2, ctx) => {
|
|
1350
2048
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1353,6 +2051,55 @@ defineTool({
|
|
|
1353
2051
|
return respond({ summary: `Metrics snapshot for service ${args2.service_id}.`, data });
|
|
1354
2052
|
}
|
|
1355
2053
|
});
|
|
2054
|
+
defineTool({
|
|
2055
|
+
name: "get_service_metrics_history",
|
|
2056
|
+
category: "services",
|
|
2057
|
+
description: [
|
|
2058
|
+
"Fetch a metrics time series (CPU, memory, network, disk) for a service.",
|
|
2059
|
+
"",
|
|
2060
|
+
'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.',
|
|
2061
|
+
"",
|
|
2062
|
+
"Inputs (all optional \u2014 omit both for the trailing hour):",
|
|
2063
|
+
" - service_id: publicId of the service.",
|
|
2064
|
+
' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
|
|
2065
|
+
" - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
|
|
2066
|
+
"",
|
|
2067
|
+
"Resolution: \u22647d \u2192 raw samples (~minute granularity), \u226430d \u2192 hourly pre-aggregates, >30d \u2192 daily. Up to ~500 points returned.",
|
|
2068
|
+
"",
|
|
2069
|
+
"Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
|
|
2070
|
+
"",
|
|
2071
|
+
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
|
|
2072
|
+
].join("\n"),
|
|
2073
|
+
input: {
|
|
2074
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2075
|
+
from: z13.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
2076
|
+
to: z13.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
2077
|
+
},
|
|
2078
|
+
handler: async (args2, ctx) => {
|
|
2079
|
+
const teamId = await ctx.resolveTeamId();
|
|
2080
|
+
const opts = {};
|
|
2081
|
+
const resolveRel = (raw) => {
|
|
2082
|
+
const rel = /^-(\d+)(s|m|h|d)$/.exec(raw.trim());
|
|
2083
|
+
if (!rel) return raw;
|
|
2084
|
+
const amount = Number.parseInt(rel[1], 10);
|
|
2085
|
+
const unit = rel[2];
|
|
2086
|
+
const ms = unit === "s" ? amount * 1e3 : unit === "m" ? amount * 6e4 : unit === "h" ? amount * 36e5 : amount * 864e5;
|
|
2087
|
+
return new Date(Date.now() - ms).toISOString();
|
|
2088
|
+
};
|
|
2089
|
+
if (args2.from) opts.from = resolveRel(args2.from);
|
|
2090
|
+
if (args2.to) opts.to = resolveRel(args2.to);
|
|
2091
|
+
const response = await ctx.hoststack.services.getMetricsHistory(
|
|
2092
|
+
teamId,
|
|
2093
|
+
args2.service_id,
|
|
2094
|
+
opts
|
|
2095
|
+
);
|
|
2096
|
+
const points = Array.isArray(response.history) ? response.history.length : 0;
|
|
2097
|
+
return respond({
|
|
2098
|
+
summary: `Returned ${points} metric point${points === 1 ? "" : "s"} for service ${args2.service_id}.`,
|
|
2099
|
+
data: { history: response.history }
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
1356
2103
|
defineTool({
|
|
1357
2104
|
name: "update_service",
|
|
1358
2105
|
category: "services",
|
|
@@ -1370,8 +2117,8 @@ defineTool({
|
|
|
1370
2117
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
1371
2118
|
].join("\n"),
|
|
1372
2119
|
input: {
|
|
1373
|
-
service_id:
|
|
1374
|
-
name:
|
|
2120
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2121
|
+
name: z13.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
1375
2122
|
},
|
|
1376
2123
|
handler: async (args2, ctx) => {
|
|
1377
2124
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1398,21 +2145,30 @@ defineTool({
|
|
|
1398
2145
|
" - dockerfile_path (optional): path to Dockerfile relative to root_directory. Pass null to clear.",
|
|
1399
2146
|
" - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
|
|
1400
2147
|
" - instance_count (optional): integer \u22651 \u2014 pin both min and max instances to this value.",
|
|
2148
|
+
' - 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.',
|
|
1401
2149
|
"",
|
|
1402
2150
|
"Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
|
|
1403
2151
|
"",
|
|
1404
2152
|
'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 } }'
|
|
1405
2153
|
].join("\n"),
|
|
1406
2154
|
input: {
|
|
1407
|
-
service_id:
|
|
1408
|
-
install_command:
|
|
1409
|
-
build_command:
|
|
1410
|
-
start_command:
|
|
1411
|
-
branch:
|
|
1412
|
-
root_directory:
|
|
1413
|
-
dockerfile_path:
|
|
1414
|
-
auto_deploy:
|
|
1415
|
-
instance_count:
|
|
2155
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2156
|
+
install_command: z13.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
2157
|
+
build_command: z13.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
2158
|
+
start_command: z13.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
2159
|
+
branch: z13.string().optional().describe("Git branch to track."),
|
|
2160
|
+
root_directory: z13.string().optional().describe("Build context root."),
|
|
2161
|
+
dockerfile_path: z13.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
2162
|
+
auto_deploy: z13.boolean().optional().describe("Auto-deploy on push."),
|
|
2163
|
+
instance_count: z13.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
2164
|
+
log_filter_rules: z13.array(
|
|
2165
|
+
z13.object({
|
|
2166
|
+
pattern: z13.string().min(1).max(200),
|
|
2167
|
+
action: z13.enum(["drop", "downgrade"])
|
|
2168
|
+
})
|
|
2169
|
+
).max(50).optional().describe(
|
|
2170
|
+
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
2171
|
+
)
|
|
1416
2172
|
},
|
|
1417
2173
|
handler: async (args2, ctx) => {
|
|
1418
2174
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1424,14 +2180,16 @@ defineTool({
|
|
|
1424
2180
|
if (args2.dockerfile_path !== void 0)
|
|
1425
2181
|
serviceUpdate["dockerfilePath"] = args2.dockerfile_path;
|
|
1426
2182
|
if (args2.branch !== void 0) serviceUpdate["branch"] = args2.branch;
|
|
1427
|
-
if (args2.root_directory !== void 0)
|
|
1428
|
-
serviceUpdate["rootDirectory"] = args2.root_directory;
|
|
2183
|
+
if (args2.root_directory !== void 0) serviceUpdate["rootDirectory"] = args2.root_directory;
|
|
1429
2184
|
if (args2.auto_deploy !== void 0) serviceUpdate["autoDeploy"] = args2.auto_deploy;
|
|
1430
2185
|
const configUpdate = {};
|
|
1431
2186
|
if (args2.instance_count !== void 0) {
|
|
1432
2187
|
configUpdate["minInstances"] = args2.instance_count;
|
|
1433
2188
|
configUpdate["maxInstances"] = args2.instance_count;
|
|
1434
2189
|
}
|
|
2190
|
+
if (args2.log_filter_rules !== void 0) {
|
|
2191
|
+
configUpdate["logFilterRules"] = args2.log_filter_rules;
|
|
2192
|
+
}
|
|
1435
2193
|
if (Object.keys(serviceUpdate).length === 0 && Object.keys(configUpdate).length === 0) {
|
|
1436
2194
|
return respond({ summary: "No fields to update.", data: {} });
|
|
1437
2195
|
}
|
|
@@ -1477,7 +2235,7 @@ defineTool({
|
|
|
1477
2235
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
1478
2236
|
].join("\n"),
|
|
1479
2237
|
input: {
|
|
1480
|
-
service_id:
|
|
2238
|
+
service_id: z13.string().describe("Service publicId.")
|
|
1481
2239
|
},
|
|
1482
2240
|
handler: async (args2, ctx) => {
|
|
1483
2241
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1501,7 +2259,7 @@ defineTool({
|
|
|
1501
2259
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
1502
2260
|
].join("\n"),
|
|
1503
2261
|
input: {
|
|
1504
|
-
service_id:
|
|
2262
|
+
service_id: z13.string().describe("Service publicId.")
|
|
1505
2263
|
},
|
|
1506
2264
|
handler: async (args2, ctx) => {
|
|
1507
2265
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1522,11 +2280,11 @@ defineTool({
|
|
|
1522
2280
|
" - lines (optional): tail size (default 200, max 1000).",
|
|
1523
2281
|
' - since/until (optional): ISO-8601 timestamp OR a relative offset like "-5m", "-1h", "-2d".',
|
|
1524
2282
|
' - stream (optional): "stdout" | "stderr". Omit to combine.',
|
|
1525
|
-
" - level (optional):
|
|
2283
|
+
" - 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).",
|
|
1526
2284
|
" - search (optional): case-insensitive substring grep, \u2264100 chars.",
|
|
1527
2285
|
' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
|
|
1528
2286
|
"",
|
|
1529
|
-
"Returns: { logs: LogEntry[] | string } when count_only is false
|
|
2287
|
+
"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.",
|
|
1530
2288
|
"",
|
|
1531
2289
|
'Example: get_service_logs({ service_id: "svc_abc", search: "OOM", since: "-15m" }) \u2192 { logs: [...] }.',
|
|
1532
2290
|
"",
|
|
@@ -1535,14 +2293,16 @@ defineTool({
|
|
|
1535
2293
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
1536
2294
|
].join("\n"),
|
|
1537
2295
|
input: {
|
|
1538
|
-
service_id:
|
|
1539
|
-
lines:
|
|
1540
|
-
since:
|
|
1541
|
-
until:
|
|
1542
|
-
stream:
|
|
1543
|
-
level:
|
|
1544
|
-
|
|
1545
|
-
|
|
2296
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2297
|
+
lines: z13.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
2298
|
+
since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
2299
|
+
until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
2300
|
+
stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
2301
|
+
level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
2302
|
+
"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)."
|
|
2303
|
+
),
|
|
2304
|
+
search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
2305
|
+
count_only: z13.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
1546
2306
|
},
|
|
1547
2307
|
handler: async (args2, ctx) => {
|
|
1548
2308
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1569,9 +2329,82 @@ defineTool({
|
|
|
1569
2329
|
});
|
|
1570
2330
|
}
|
|
1571
2331
|
});
|
|
2332
|
+
defineTool({
|
|
2333
|
+
name: "get_service_logs_bulk",
|
|
2334
|
+
category: "logs",
|
|
2335
|
+
description: [
|
|
2336
|
+
"Fetch runtime logs for multiple services in parallel and return one response keyed by service_id. Single round-trip from the MCP client perspective.",
|
|
2337
|
+
"",
|
|
2338
|
+
'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.',
|
|
2339
|
+
"",
|
|
2340
|
+
"Inputs:",
|
|
2341
|
+
" - service_ids: list of service publicIds (svc_\u2026). Hard cap 10 to bound work \u2014 for larger fleets, partition the call.",
|
|
2342
|
+
" - lines_per_service (optional): tail size per service (default 100, max 500). Smaller default than get_service_logs since you are fanning out.",
|
|
2343
|
+
' - since/until (optional): same shape as get_service_logs (ISO-8601 or "-5m", "-1h").',
|
|
2344
|
+
' - stream (optional): "stdout" | "stderr".',
|
|
2345
|
+
" - level (optional): structured-log level filter (trace/debug/info/warn/error/fatal).",
|
|
2346
|
+
" - search (optional): case-insensitive substring filter applied to every service.",
|
|
2347
|
+
' - 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.',
|
|
2348
|
+
"",
|
|
2349
|
+
"Returns: { results: { [service_id]: { logs?, count?, error? } } } \u2014 per-service result, with `error` populated when one service failed (the rest still succeed).",
|
|
2350
|
+
"",
|
|
2351
|
+
'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 } } }.'
|
|
2352
|
+
].join("\n"),
|
|
2353
|
+
input: {
|
|
2354
|
+
service_ids: z13.array(z13.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
2355
|
+
lines_per_service: z13.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
2356
|
+
since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
2357
|
+
until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
2358
|
+
stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
2359
|
+
level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
2360
|
+
search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
2361
|
+
count_only: z13.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
2362
|
+
},
|
|
2363
|
+
handler: async (args2, ctx) => {
|
|
2364
|
+
const teamId = await ctx.resolveTeamId();
|
|
2365
|
+
const linesPerService = args2.lines_per_service ?? 100;
|
|
2366
|
+
const baseOpts = { lines: linesPerService };
|
|
2367
|
+
if (args2.since) baseOpts.since = args2.since;
|
|
2368
|
+
if (args2.until) baseOpts.until = args2.until;
|
|
2369
|
+
if (args2.stream) baseOpts.stream = args2.stream;
|
|
2370
|
+
if (args2.level) baseOpts.level = args2.level;
|
|
2371
|
+
if (args2.search) baseOpts.search = args2.search;
|
|
2372
|
+
if (args2.count_only) baseOpts.countOnly = args2.count_only;
|
|
2373
|
+
const settled = await Promise.allSettled(
|
|
2374
|
+
args2.service_ids.map(async (sid) => {
|
|
2375
|
+
const response = await ctx.hoststack.services.getRuntimeLogs(teamId, sid, baseOpts);
|
|
2376
|
+
return { sid, response };
|
|
2377
|
+
})
|
|
2378
|
+
);
|
|
2379
|
+
const results = {};
|
|
2380
|
+
let okCount = 0;
|
|
2381
|
+
let errorCount = 0;
|
|
2382
|
+
for (let i = 0; i < settled.length; i += 1) {
|
|
2383
|
+
const outcome = settled[i];
|
|
2384
|
+
const sid = args2.service_ids[i];
|
|
2385
|
+
if (outcome.status === "fulfilled") {
|
|
2386
|
+
const { response } = outcome.value;
|
|
2387
|
+
if ("count" in response) {
|
|
2388
|
+
results[sid] = { count: response.count };
|
|
2389
|
+
} else {
|
|
2390
|
+
results[sid] = { logs: response.logs };
|
|
2391
|
+
}
|
|
2392
|
+
okCount += 1;
|
|
2393
|
+
} else {
|
|
2394
|
+
const reason = outcome.reason;
|
|
2395
|
+
results[sid] = {
|
|
2396
|
+
error: typeof reason === "string" ? reason : reason?.message ?? "Unknown error"
|
|
2397
|
+
};
|
|
2398
|
+
errorCount += 1;
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
const summary = errorCount === 0 ? `Fetched logs from ${okCount} service${okCount === 1 ? "" : "s"}.` : `Fetched logs from ${okCount} of ${args2.service_ids.length} service${args2.service_ids.length === 1 ? "" : "s"} (${errorCount} failed \u2014 see per-service .error field).`;
|
|
2402
|
+
return respond({ summary, data: { results } });
|
|
2403
|
+
}
|
|
2404
|
+
});
|
|
1572
2405
|
|
|
1573
2406
|
// src/tools/volumes.ts
|
|
1574
|
-
import { z as
|
|
2407
|
+
import { z as z14 } from "zod";
|
|
1575
2408
|
defineTool({
|
|
1576
2409
|
name: "list_volumes",
|
|
1577
2410
|
category: "volumes",
|
|
@@ -1588,7 +2421,7 @@ defineTool({
|
|
|
1588
2421
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
1589
2422
|
].join("\n"),
|
|
1590
2423
|
input: {
|
|
1591
|
-
service_id:
|
|
2424
|
+
service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1592
2425
|
},
|
|
1593
2426
|
handler: async (args2, ctx) => {
|
|
1594
2427
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1617,10 +2450,10 @@ defineTool({
|
|
|
1617
2450
|
'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" } }'
|
|
1618
2451
|
].join("\n"),
|
|
1619
2452
|
input: {
|
|
1620
|
-
service_id:
|
|
1621
|
-
name:
|
|
1622
|
-
mount_path:
|
|
1623
|
-
size_gb:
|
|
2453
|
+
service_id: z14.string().describe("Service publicId."),
|
|
2454
|
+
name: z14.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
2455
|
+
mount_path: z14.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
2456
|
+
size_gb: z14.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
|
|
1624
2457
|
},
|
|
1625
2458
|
handler: async (args2, ctx) => {
|
|
1626
2459
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1656,10 +2489,10 @@ defineTool({
|
|
|
1656
2489
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
1657
2490
|
].join("\n"),
|
|
1658
2491
|
input: {
|
|
1659
|
-
service_id:
|
|
1660
|
-
volume_id:
|
|
1661
|
-
mount_path:
|
|
1662
|
-
size_gb:
|
|
2492
|
+
service_id: z14.string().describe("Service publicId."),
|
|
2493
|
+
volume_id: z14.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
2494
|
+
mount_path: z14.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
2495
|
+
size_gb: z14.number().int().min(1).max(100).optional().describe("New size in GB.")
|
|
1663
2496
|
},
|
|
1664
2497
|
handler: async (args2, ctx) => {
|
|
1665
2498
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1697,8 +2530,8 @@ defineTool({
|
|
|
1697
2530
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
1698
2531
|
].join("\n"),
|
|
1699
2532
|
input: {
|
|
1700
|
-
service_id:
|
|
1701
|
-
volume_id:
|
|
2533
|
+
service_id: z14.string().describe("Service publicId."),
|
|
2534
|
+
volume_id: z14.string().describe("Volume publicId.")
|
|
1702
2535
|
},
|
|
1703
2536
|
handler: async (args2, ctx) => {
|
|
1704
2537
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1712,7 +2545,7 @@ defineTool({
|
|
|
1712
2545
|
|
|
1713
2546
|
// src/server-factory.ts
|
|
1714
2547
|
var PACKAGE_NAME = "hoststack";
|
|
1715
|
-
var PACKAGE_VERSION = "0.
|
|
2548
|
+
var PACKAGE_VERSION = "0.6.0";
|
|
1716
2549
|
function createMcpServer(options) {
|
|
1717
2550
|
const baseUrl2 = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
|
|
1718
2551
|
const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl: baseUrl2 });
|