@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/index.js
CHANGED
|
@@ -41,6 +41,14 @@ var ApiClient = class {
|
|
|
41
41
|
});
|
|
42
42
|
return this.handle(res);
|
|
43
43
|
}
|
|
44
|
+
async put(path, body) {
|
|
45
|
+
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
46
|
+
method: "PUT",
|
|
47
|
+
headers: this.headers,
|
|
48
|
+
body: JSON.stringify(body)
|
|
49
|
+
});
|
|
50
|
+
return this.handle(res);
|
|
51
|
+
}
|
|
44
52
|
async delete(path) {
|
|
45
53
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
46
54
|
method: "DELETE",
|
|
@@ -419,8 +427,9 @@ defineTool({
|
|
|
419
427
|
' - action: filter to a specific action (e.g. "service.created", "deploy.triggered").',
|
|
420
428
|
' - resource_type: filter by resource type (e.g. "service", "deploy", "domain").',
|
|
421
429
|
" - user_id: filter by acting user numeric ID.",
|
|
430
|
+
' - 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
431
|
"",
|
|
423
|
-
"Returns: { items: ActivityLogEntry[], meta
|
|
432
|
+
"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
433
|
"",
|
|
425
434
|
'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
435
|
].join("\n"),
|
|
@@ -429,7 +438,9 @@ defineTool({
|
|
|
429
438
|
per_page: z2.number().int().positive().max(100).optional().describe("Items per page, hard cap 100."),
|
|
430
439
|
action: z2.string().optional().describe('Action filter, e.g. "service.created".'),
|
|
431
440
|
resource_type: z2.string().optional().describe("Resource type filter."),
|
|
432
|
-
user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter.")
|
|
441
|
+
user_id: z2.number().int().positive().optional().describe("Numeric acting-user filter."),
|
|
442
|
+
since: z2.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-15m", "-2h", "-7d").'),
|
|
443
|
+
until: z2.string().optional().describe("ISO-8601 timestamp or relative offset upper bound.")
|
|
433
444
|
},
|
|
434
445
|
handler: async (args, ctx) => {
|
|
435
446
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -439,13 +450,24 @@ defineTool({
|
|
|
439
450
|
if (args.action !== void 0) params["action"] = args.action;
|
|
440
451
|
if (args.resource_type !== void 0) params["resourceType"] = args.resource_type;
|
|
441
452
|
if (args.user_id !== void 0) params["userId"] = String(args.user_id);
|
|
453
|
+
if (args.since !== void 0) params["since"] = args.since;
|
|
454
|
+
if (args.until !== void 0) params["until"] = args.until;
|
|
442
455
|
const response = await ctx.api.get(
|
|
443
456
|
`/api/activity-log/${teamId}`,
|
|
444
457
|
params
|
|
445
458
|
);
|
|
446
459
|
const items = Array.isArray(response.data) ? response.data.map(shapeActivity) : [];
|
|
447
460
|
const data = { items };
|
|
448
|
-
if (response.meta !== void 0)
|
|
461
|
+
if (response.meta !== void 0) {
|
|
462
|
+
data.meta = shape(response.meta);
|
|
463
|
+
} else if (response.page !== void 0 || response.perPage !== void 0 || response.total !== void 0 || response.totalPages !== void 0) {
|
|
464
|
+
data.meta = shape({
|
|
465
|
+
page: response.page,
|
|
466
|
+
perPage: response.perPage,
|
|
467
|
+
total: response.total,
|
|
468
|
+
totalPages: response.totalPages
|
|
469
|
+
});
|
|
470
|
+
}
|
|
449
471
|
return respond({
|
|
450
472
|
summary: items.length === 0 ? "No activity log entries match the given filters." : `Returned ${items.length} activity log entr${items.length === 1 ? "y" : "ies"}.`,
|
|
451
473
|
data
|
|
@@ -453,8 +475,51 @@ defineTool({
|
|
|
453
475
|
}
|
|
454
476
|
});
|
|
455
477
|
|
|
456
|
-
// src/tools/
|
|
478
|
+
// src/tools/alerts.ts
|
|
457
479
|
import { z as z3 } from "zod";
|
|
480
|
+
defineTool({
|
|
481
|
+
name: "list_alerts",
|
|
482
|
+
category: "alerts",
|
|
483
|
+
description: [
|
|
484
|
+
"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.",
|
|
485
|
+
"",
|
|
486
|
+
"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.",
|
|
487
|
+
"",
|
|
488
|
+
'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.',
|
|
489
|
+
"",
|
|
490
|
+
"Inputs (all optional):",
|
|
491
|
+
' - since: ISO-8601 timestamp OR relative offset like "-1h" / "-2d". Default: -24h.',
|
|
492
|
+
" - until: ISO-8601 upper bound (ignored when aggregating \u2014 aggregated view always extends to now).",
|
|
493
|
+
" - limit: max rows (default 100, hard cap 500).",
|
|
494
|
+
" - aggregate: true (default) collapses by (action, resourceId); false returns raw rows.",
|
|
495
|
+
"",
|
|
496
|
+
"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.",
|
|
497
|
+
"",
|
|
498
|
+
"Example: list_alerts({ since: '-1h' }) \u2192 { alerts: [{ action: 'deploy.failed', resourceId: 31, severity: 'error', count: 3, lastFiredAt: '\u2026', \u2026 }] }."
|
|
499
|
+
].join("\n"),
|
|
500
|
+
input: {
|
|
501
|
+
since: z3.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-1h", "-2d"). Default: -24h.'),
|
|
502
|
+
until: z3.string().optional().describe("ISO-8601 upper bound. Only honored when aggregate=false."),
|
|
503
|
+
limit: z3.number().int().positive().max(500).optional().describe("Max rows (default 100, hard cap 500)."),
|
|
504
|
+
aggregate: z3.boolean().optional().describe("Collapse by (action, resourceId). Default true.")
|
|
505
|
+
},
|
|
506
|
+
handler: async (args, ctx) => {
|
|
507
|
+
const teamId = await ctx.resolveTeamId();
|
|
508
|
+
const params = {};
|
|
509
|
+
if (args.since !== void 0) params["since"] = args.since;
|
|
510
|
+
if (args.until !== void 0) params["until"] = args.until;
|
|
511
|
+
if (args.limit !== void 0) params["limit"] = String(args.limit);
|
|
512
|
+
if (args.aggregate === false) params["aggregate"] = "0";
|
|
513
|
+
const response = await ctx.api.get(`/api/alerts/${teamId}`, params);
|
|
514
|
+
const items = Array.isArray(response.alerts) ? response.alerts.map(shape) : [];
|
|
515
|
+
const aggregated = Boolean(response.aggregated);
|
|
516
|
+
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"}.`;
|
|
517
|
+
return respond({ summary, data: { alerts: items, aggregated } });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// src/tools/cron.ts
|
|
522
|
+
import { z as z4 } from "zod";
|
|
458
523
|
defineTool({
|
|
459
524
|
name: "list_cron_executions",
|
|
460
525
|
category: "cron",
|
|
@@ -472,8 +537,8 @@ defineTool({
|
|
|
472
537
|
'Example: list_cron_executions({ service_id: "svc_cron" }) \u2192 { items: [{ status: "succeeded", exitCode: 0, \u2026 }, \u2026] }'
|
|
473
538
|
].join("\n"),
|
|
474
539
|
input: {
|
|
475
|
-
service_id:
|
|
476
|
-
limit:
|
|
540
|
+
service_id: z4.string().describe("Cron service publicId."),
|
|
541
|
+
limit: z4.number().int().positive().max(200).optional().describe("Max executions to return (default 50).")
|
|
477
542
|
},
|
|
478
543
|
handler: async (args, ctx) => {
|
|
479
544
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -501,8 +566,8 @@ defineTool({
|
|
|
501
566
|
'Example: get_cron_execution({ service_id: "svc_cron", execution_id: "exe_xyz" }) \u2192 { execution: { status: "failed", exitCode: 1, \u2026 } }'
|
|
502
567
|
].join("\n"),
|
|
503
568
|
input: {
|
|
504
|
-
service_id:
|
|
505
|
-
execution_id:
|
|
569
|
+
service_id: z4.string().describe("Cron service publicId."),
|
|
570
|
+
execution_id: z4.string().describe("Execution publicId.")
|
|
506
571
|
},
|
|
507
572
|
handler: async (args, ctx) => {
|
|
508
573
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -514,7 +579,7 @@ defineTool({
|
|
|
514
579
|
});
|
|
515
580
|
|
|
516
581
|
// src/tools/databases.ts
|
|
517
|
-
import { z as
|
|
582
|
+
import { z as z5 } from "zod";
|
|
518
583
|
defineTool({
|
|
519
584
|
name: "list_databases",
|
|
520
585
|
category: "databases",
|
|
@@ -531,7 +596,7 @@ defineTool({
|
|
|
531
596
|
'Example: list_databases({ project_id: 12 }) \u2192 { items: [{ publicId: "db_\u2026", type: "postgres", status: "running", \u2026 }] }'
|
|
532
597
|
].join("\n"),
|
|
533
598
|
input: {
|
|
534
|
-
project_id:
|
|
599
|
+
project_id: z5.number().int().positive().describe("Numeric project ID (from list_projects).")
|
|
535
600
|
},
|
|
536
601
|
handler: async (args, ctx) => {
|
|
537
602
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -541,6 +606,45 @@ defineTool({
|
|
|
541
606
|
return respond({ summary, data });
|
|
542
607
|
}
|
|
543
608
|
});
|
|
609
|
+
defineTool({
|
|
610
|
+
name: "update_database",
|
|
611
|
+
category: "databases",
|
|
612
|
+
description: [
|
|
613
|
+
"Update a managed database \u2014 rename, change plan tier (memory/CPU), or grow disk size.",
|
|
614
|
+
"",
|
|
615
|
+
"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.",
|
|
616
|
+
"",
|
|
617
|
+
"Inputs:",
|
|
618
|
+
' - database_id: publicId of the database (e.g. "db_\u2026").',
|
|
619
|
+
" - name (optional): new database name.",
|
|
620
|
+
' - plan (optional): "free" | "starter" | "standard" | "pro" \u2014 changes memory tier.',
|
|
621
|
+
" - disk_size_gb (optional): new disk size in GB (must be \u2265 current).",
|
|
622
|
+
"",
|
|
623
|
+
"Returns: { database: Database } \u2014 the updated record.",
|
|
624
|
+
"",
|
|
625
|
+
'Example: update_database({ database_id: "db_xyz", disk_size_gb: 50 }) \u2192 { database: { diskSizeGb: 50, \u2026 } }'
|
|
626
|
+
].join("\n"),
|
|
627
|
+
input: {
|
|
628
|
+
database_id: z5.string().describe("Database publicId (e.g. db_xyz)."),
|
|
629
|
+
name: z5.string().min(1).max(100).optional().describe("New database name."),
|
|
630
|
+
plan: z5.enum(["free", "starter", "standard", "pro"]).optional().describe("Plan tier (memory/CPU)."),
|
|
631
|
+
disk_size_gb: z5.number().int().min(1).max(1024).optional().describe("New disk size in GB. Must be \u2265 current.")
|
|
632
|
+
},
|
|
633
|
+
handler: async (args, ctx) => {
|
|
634
|
+
const teamId = await ctx.resolveTeamId();
|
|
635
|
+
const input = {};
|
|
636
|
+
if (args.name !== void 0) input.name = args.name;
|
|
637
|
+
if (args.plan !== void 0) input.plan = args.plan;
|
|
638
|
+
if (args.disk_size_gb !== void 0) input.diskSizeGb = args.disk_size_gb;
|
|
639
|
+
if (Object.keys(input).length === 0) {
|
|
640
|
+
return respond({ summary: "No fields to update.", data: {} });
|
|
641
|
+
}
|
|
642
|
+
const response = await ctx.hoststack.databases.update(teamId, args.database_id, input);
|
|
643
|
+
const data = { database: shapeDatabase(response.database) };
|
|
644
|
+
const fields = Object.keys(input).join(", ");
|
|
645
|
+
return respond({ summary: `Updated ${fields} on database ${args.database_id}.`, data });
|
|
646
|
+
}
|
|
647
|
+
});
|
|
544
648
|
defineTool({
|
|
545
649
|
name: "get_database",
|
|
546
650
|
category: "databases",
|
|
@@ -557,7 +661,7 @@ defineTool({
|
|
|
557
661
|
'Example: get_database({ database_id: "db_xyz" }) \u2192 { database: { type: "postgres", version: "16", status: "running", \u2026 } }'
|
|
558
662
|
].join("\n"),
|
|
559
663
|
input: {
|
|
560
|
-
database_id:
|
|
664
|
+
database_id: z5.string().describe("Database publicId (e.g. db_xyz).")
|
|
561
665
|
},
|
|
562
666
|
handler: async (args, ctx) => {
|
|
563
667
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -570,7 +674,7 @@ defineTool({
|
|
|
570
674
|
});
|
|
571
675
|
|
|
572
676
|
// src/tools/deploys.ts
|
|
573
|
-
import { z as
|
|
677
|
+
import { z as z6 } from "zod";
|
|
574
678
|
defineTool({
|
|
575
679
|
name: "list_deploys",
|
|
576
680
|
category: "deploys",
|
|
@@ -582,12 +686,12 @@ defineTool({
|
|
|
582
686
|
"Inputs:",
|
|
583
687
|
' - service_id: publicId of the service (e.g. "svc_abc123"). Use list_services to find it.',
|
|
584
688
|
"",
|
|
585
|
-
"Returns: { items: Deploy[] } \u2014 each deploy includes id, publicId, status (pending|building|deploying|live|failed|cancelled), commitSha, commitMessage, branch, triggeredBy, startedAt, finishedAt,
|
|
689
|
+
"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
690
|
"",
|
|
587
691
|
'Example: list_deploys({ service_id: "svc_abc" }) \u2192 { items: [{ publicId: "dpl_\u2026", status: "live", commitMessage: "Fix login", \u2026 }, \u2026] }'
|
|
588
692
|
].join("\n"),
|
|
589
693
|
input: {
|
|
590
|
-
service_id:
|
|
694
|
+
service_id: z6.string().describe("Service publicId (e.g. svc_abc123).")
|
|
591
695
|
},
|
|
592
696
|
handler: async (args, ctx) => {
|
|
593
697
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -609,13 +713,13 @@ defineTool({
|
|
|
609
713
|
" - service_id: publicId of the service.",
|
|
610
714
|
' - deploy_id: publicId of the deploy (e.g. "dpl_\u2026").',
|
|
611
715
|
"",
|
|
612
|
-
"Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch,
|
|
716
|
+
"Returns: { deploy: Deploy } \u2014 full deploy record (status, commitSha, commitMessage, branch, buildDurationMs, totalDurationMs, finishedAt, etc).",
|
|
613
717
|
"",
|
|
614
718
|
'Example: get_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { deploy: { status: "live", \u2026 } }'
|
|
615
719
|
].join("\n"),
|
|
616
720
|
input: {
|
|
617
|
-
service_id:
|
|
618
|
-
deploy_id:
|
|
721
|
+
service_id: z6.string().describe("Service publicId."),
|
|
722
|
+
deploy_id: z6.string().describe("Deploy publicId.")
|
|
619
723
|
},
|
|
620
724
|
handler: async (args, ctx) => {
|
|
621
725
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -642,8 +746,8 @@ defineTool({
|
|
|
642
746
|
'Example: trigger_deploy({ service_id: "svc_abc" }) \u2192 { deploy: { publicId: "dpl_\u2026", status: "pending", \u2026 } }'
|
|
643
747
|
].join("\n"),
|
|
644
748
|
input: {
|
|
645
|
-
service_id:
|
|
646
|
-
clear_cache:
|
|
749
|
+
service_id: z6.string().describe("Service publicId."),
|
|
750
|
+
clear_cache: z6.boolean().optional().describe("Clear the build cache (default false).")
|
|
647
751
|
},
|
|
648
752
|
handler: async (args, ctx) => {
|
|
649
753
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -675,8 +779,8 @@ defineTool({
|
|
|
675
779
|
'Example: cancel_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 { ok: true }'
|
|
676
780
|
].join("\n"),
|
|
677
781
|
input: {
|
|
678
|
-
service_id:
|
|
679
|
-
deploy_id:
|
|
782
|
+
service_id: z6.string().describe("Service publicId."),
|
|
783
|
+
deploy_id: z6.string().describe("Deploy publicId.")
|
|
680
784
|
},
|
|
681
785
|
handler: async (args, ctx) => {
|
|
682
786
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -704,10 +808,10 @@ defineTool({
|
|
|
704
808
|
'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
809
|
].join("\n"),
|
|
706
810
|
input: {
|
|
707
|
-
service_id:
|
|
708
|
-
deploy_id:
|
|
709
|
-
after_id:
|
|
710
|
-
limit:
|
|
811
|
+
service_id: z6.string().describe("Service publicId."),
|
|
812
|
+
deploy_id: z6.string().describe("Deploy publicId."),
|
|
813
|
+
after_id: z6.number().int().optional().describe("Pagination cursor from a previous response\u2019s nextAfterId."),
|
|
814
|
+
limit: z6.number().int().min(1).max(5e3).optional().describe("Max log entries to fetch (default 500, hard cap 5000).")
|
|
711
815
|
},
|
|
712
816
|
handler: async (args, ctx) => {
|
|
713
817
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -728,9 +832,379 @@ defineTool({
|
|
|
728
832
|
});
|
|
729
833
|
}
|
|
730
834
|
});
|
|
835
|
+
defineTool({
|
|
836
|
+
name: "diagnose_deploy",
|
|
837
|
+
category: "deploys",
|
|
838
|
+
description: [
|
|
839
|
+
"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.",
|
|
840
|
+
"",
|
|
841
|
+
"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.",
|
|
842
|
+
"",
|
|
843
|
+
"Inputs:",
|
|
844
|
+
" - service_id: publicId of the service.",
|
|
845
|
+
" - deploy_id: publicId of the deploy to diagnose.",
|
|
846
|
+
" - build_log_lines (optional): how many trailing build log lines to include. Default 200, max 2000.",
|
|
847
|
+
" - 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).",
|
|
848
|
+
"",
|
|
849
|
+
"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.",
|
|
850
|
+
"",
|
|
851
|
+
'Example: diagnose_deploy({ service_id: "svc_abc", deploy_id: "dpl_xyz" }) \u2192 all three sections in one call.'
|
|
852
|
+
].join("\n"),
|
|
853
|
+
input: {
|
|
854
|
+
service_id: z6.string().describe("Service publicId."),
|
|
855
|
+
deploy_id: z6.string().describe("Deploy publicId."),
|
|
856
|
+
build_log_lines: z6.number().int().min(1).max(2e3).optional().describe("Trailing build log lines. Default 200, max 2000."),
|
|
857
|
+
runtime_log_lines: z6.number().int().min(0).max(1e3).optional().describe(
|
|
858
|
+
"Trailing runtime log lines since deploy start. Default 100, max 1000. 0 = skip."
|
|
859
|
+
)
|
|
860
|
+
},
|
|
861
|
+
handler: async (args, ctx) => {
|
|
862
|
+
const teamId = await ctx.resolveTeamId();
|
|
863
|
+
const buildLimit = args.build_log_lines ?? 200;
|
|
864
|
+
const runtimeLimit = args.runtime_log_lines ?? 100;
|
|
865
|
+
const [deployResp, buildResp] = await Promise.all([
|
|
866
|
+
ctx.hoststack.deploys.get(teamId, args.service_id, args.deploy_id),
|
|
867
|
+
ctx.hoststack.deploys.getLogs(teamId, args.service_id, args.deploy_id, {
|
|
868
|
+
limit: buildLimit
|
|
869
|
+
})
|
|
870
|
+
]);
|
|
871
|
+
const buildEntries = buildResp.logs;
|
|
872
|
+
const buildLogs = buildEntries.map((e) => e.message).join("\n");
|
|
873
|
+
const deployShape = shapeDeploy(deployResp.deploy);
|
|
874
|
+
const deployStatus = deployShape && "status" in deployShape ? deployShape.status : "unknown";
|
|
875
|
+
const startedAtRaw = deployShape && "startedAt" in deployShape ? deployShape.startedAt : void 0;
|
|
876
|
+
const startedAt = typeof startedAtRaw === "string" ? startedAtRaw : void 0;
|
|
877
|
+
const data = {
|
|
878
|
+
deploy: deployShape,
|
|
879
|
+
build: { logs: buildLogs, lineCount: buildEntries.length }
|
|
880
|
+
};
|
|
881
|
+
const hadContainer = deployStatus !== "pending" && deployStatus !== "building";
|
|
882
|
+
if (runtimeLimit > 0 && hadContainer) {
|
|
883
|
+
const runtimeResp = await ctx.hoststack.services.getRuntimeLogs(
|
|
884
|
+
teamId,
|
|
885
|
+
args.service_id,
|
|
886
|
+
{
|
|
887
|
+
lines: runtimeLimit,
|
|
888
|
+
...startedAt ? { since: startedAt } : {}
|
|
889
|
+
}
|
|
890
|
+
);
|
|
891
|
+
if ("count" in runtimeResp) {
|
|
892
|
+
data["runtime"] = { logs: "", lineCount: runtimeResp.count };
|
|
893
|
+
} else {
|
|
894
|
+
const runtimeEntries = Array.isArray(runtimeResp.logs) ? runtimeResp.logs : typeof runtimeResp.logs === "string" ? runtimeResp.logs.split("\n").map((line) => ({ message: line })) : [];
|
|
895
|
+
const runtimeText = runtimeEntries.map(
|
|
896
|
+
(e) => typeof e === "object" && "message" in e ? String(e.message) : String(e)
|
|
897
|
+
).join("\n");
|
|
898
|
+
data["runtime"] = {
|
|
899
|
+
logs: runtimeText,
|
|
900
|
+
lineCount: runtimeEntries.length,
|
|
901
|
+
sinceWindow: startedAt ?? null
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const failureSummary = shape({
|
|
906
|
+
status: deployStatus,
|
|
907
|
+
errorMessage: deployShape && "errorMessage" in deployShape ? deployShape.errorMessage : void 0
|
|
908
|
+
});
|
|
909
|
+
data["summary"] = failureSummary;
|
|
910
|
+
return respond({
|
|
911
|
+
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" : ""}.`,
|
|
912
|
+
data
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// src/tools/dns-records.ts
|
|
918
|
+
import { z as z7 } from "zod";
|
|
919
|
+
var DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SRV", "CAA", "ALIAS"];
|
|
920
|
+
async function resolveZonePublicId(api, teamId, input) {
|
|
921
|
+
if (input.zone_id) {
|
|
922
|
+
const { zones: zones2 } = await api.get(`/api/dns-zones/${teamId}`);
|
|
923
|
+
const match = zones2.find((z15) => z15.publicId === input.zone_id);
|
|
924
|
+
if (!match) {
|
|
925
|
+
throw new Error(`Zone ${input.zone_id} not found on this team.`);
|
|
926
|
+
}
|
|
927
|
+
return { publicId: match.publicId, domainName: match.domainName };
|
|
928
|
+
}
|
|
929
|
+
if (!input.domain) {
|
|
930
|
+
throw new Error("Provide either zone_id or domain.");
|
|
931
|
+
}
|
|
932
|
+
const { zones } = await api.get(`/api/dns-zones/${teamId}`);
|
|
933
|
+
const fqdn = input.domain.toLowerCase().replace(/\.$/, "");
|
|
934
|
+
const labels = fqdn.split(".");
|
|
935
|
+
for (let i = 0; i < labels.length - 1; i++) {
|
|
936
|
+
const candidate = labels.slice(i).join(".");
|
|
937
|
+
const match = zones.find((z15) => z15.domainName.toLowerCase() === candidate);
|
|
938
|
+
if (match && match.status !== "deleting") {
|
|
939
|
+
return { publicId: match.publicId, domainName: match.domainName };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
throw new Error(
|
|
943
|
+
`No hosted zone matches ${input.domain}. Create the zone in the dashboard (Domains \u2192 DNS) first, then retry.`
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
defineTool({
|
|
947
|
+
name: "list_dns_zones",
|
|
948
|
+
category: "dns",
|
|
949
|
+
description: [
|
|
950
|
+
"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.",
|
|
951
|
+
"",
|
|
952
|
+
"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.",
|
|
953
|
+
"",
|
|
954
|
+
'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.',
|
|
955
|
+
"",
|
|
956
|
+
'Example: list_dns_zones() \u2192 { items: [{ publicId: "dnz_abc", domainName: "micci.dk", status: "active", nsRecords: ["ns1.hoststack.dev","ns2.hoststack.dev"] }] }'
|
|
957
|
+
].join("\n"),
|
|
958
|
+
input: {},
|
|
959
|
+
handler: async (_args, ctx) => {
|
|
960
|
+
const teamId = await ctx.resolveTeamId();
|
|
961
|
+
const response = await ctx.api.get(`/api/dns-zones/${teamId}`);
|
|
962
|
+
const items = Array.isArray(response.zones) ? response.zones.map(shape) : [];
|
|
963
|
+
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"}.`;
|
|
964
|
+
return respond({ summary, data: { items } });
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
defineTool({
|
|
968
|
+
name: "list_dns_records",
|
|
969
|
+
category: "dns",
|
|
970
|
+
description: [
|
|
971
|
+
"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.",
|
|
972
|
+
"",
|
|
973
|
+
"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.",
|
|
974
|
+
"",
|
|
975
|
+
'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).',
|
|
976
|
+
"",
|
|
977
|
+
'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.',
|
|
978
|
+
"",
|
|
979
|
+
'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 }] }'
|
|
980
|
+
].join("\n"),
|
|
981
|
+
input: {
|
|
982
|
+
zone_id: z7.string().optional().describe('Zone publicId (e.g. "dnz_abc").'),
|
|
983
|
+
domain: z7.string().optional().describe("Apex or subdomain \u2014 resolves to the longest-matching hosted zone.")
|
|
984
|
+
},
|
|
985
|
+
handler: async (args, ctx) => {
|
|
986
|
+
const teamId = await ctx.resolveTeamId();
|
|
987
|
+
const zoneInput = {};
|
|
988
|
+
if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
|
|
989
|
+
if (args.domain !== void 0) zoneInput.domain = args.domain;
|
|
990
|
+
const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
|
|
991
|
+
const response = await ctx.api.get(
|
|
992
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`
|
|
993
|
+
);
|
|
994
|
+
const items = Array.isArray(response.records) ? response.records.map(shape) : [];
|
|
995
|
+
const summary = items.length === 0 ? `Zone ${zone.domainName} has no records yet.` : `Returned ${items.length} record${items.length === 1 ? "" : "s"} for ${zone.domainName}.`;
|
|
996
|
+
return respond({
|
|
997
|
+
summary,
|
|
998
|
+
data: { zone: { publicId: zone.publicId, domainName: zone.domainName }, items }
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
});
|
|
1002
|
+
defineTool({
|
|
1003
|
+
name: "get_dns_record",
|
|
1004
|
+
category: "dns",
|
|
1005
|
+
description: [
|
|
1006
|
+
'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).',
|
|
1007
|
+
"",
|
|
1008
|
+
"When to use: verify a record exists, inspect its current ttl/value, or check the sync status of an in-flight write.",
|
|
1009
|
+
"",
|
|
1010
|
+
"Inputs:",
|
|
1011
|
+
' - record_id: record publicId (e.g. "dnr_abc"). Returned by list_dns_records and create_dns_record.',
|
|
1012
|
+
"",
|
|
1013
|
+
"Returns: { record: Record }. Mirrors the shape returned by list_dns_records.",
|
|
1014
|
+
"",
|
|
1015
|
+
'Example: get_dns_record({ record_id: "dnr_abc" }) \u2192 { record: { type: "TXT", name: "@", value: "\u2026", status: "active", \u2026 } }'
|
|
1016
|
+
].join("\n"),
|
|
1017
|
+
input: {
|
|
1018
|
+
record_id: z7.string().describe("Record publicId.")
|
|
1019
|
+
},
|
|
1020
|
+
handler: async (args, ctx) => {
|
|
1021
|
+
const teamId = await ctx.resolveTeamId();
|
|
1022
|
+
const { zones } = await ctx.api.get(`/api/dns-zones/${teamId}`);
|
|
1023
|
+
for (const zone of zones) {
|
|
1024
|
+
const { records } = await ctx.api.get(
|
|
1025
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`
|
|
1026
|
+
);
|
|
1027
|
+
const match = records.find((r) => r.publicId === args.record_id);
|
|
1028
|
+
if (match) {
|
|
1029
|
+
return respond({
|
|
1030
|
+
summary: `Record ${match.type} ${match.name} on ${zone.domainName}.`,
|
|
1031
|
+
data: {
|
|
1032
|
+
zone: { publicId: zone.publicId, domainName: zone.domainName },
|
|
1033
|
+
record: shape(match)
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return respondError(`Record ${args.record_id} not found on any zone owned by this team.`);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
defineTool({
|
|
1042
|
+
name: "create_dns_record",
|
|
1043
|
+
category: "dns",
|
|
1044
|
+
description: [
|
|
1045
|
+
'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.',
|
|
1046
|
+
"",
|
|
1047
|
+
"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.",
|
|
1048
|
+
"",
|
|
1049
|
+
"Provide EITHER zone_id (zone publicId) OR domain (resolves via longest-suffix match against hosted zones).",
|
|
1050
|
+
"",
|
|
1051
|
+
"Inputs:",
|
|
1052
|
+
' - type: "A" | "AAAA" | "CNAME" | "MX" | "TXT" | "NS" | "SRV" | "CAA" | "ALIAS".',
|
|
1053
|
+
' - name: "@" for the zone apex, or a subdomain label ("www", "api", "_acme-challenge").',
|
|
1054
|
+
" - content: the literal record value. For TXT pass the unquoted string \u2014 the PowerDNS layer handles quoting.",
|
|
1055
|
+
" - ttl (optional): 60\u201386400 seconds, default 3600.",
|
|
1056
|
+
" - priority (optional, required for MX and SRV): 0\u201365535.",
|
|
1057
|
+
"",
|
|
1058
|
+
"Returns: { record: Record } \u2014 see get_dns_record for the shape.",
|
|
1059
|
+
"",
|
|
1060
|
+
'Example: create_dns_record({ domain: "micci.dk", type: "TXT", name: "@", content: "google-site-verification=C0V0scK48g2\u2026", ttl: 300 })'
|
|
1061
|
+
].join("\n"),
|
|
1062
|
+
input: {
|
|
1063
|
+
zone_id: z7.string().optional().describe("Zone publicId."),
|
|
1064
|
+
domain: z7.string().optional().describe("Apex or subdomain to resolve a hosted zone."),
|
|
1065
|
+
type: z7.enum(DNS_RECORD_TYPES).describe("DNS record type."),
|
|
1066
|
+
name: z7.string().min(1).max(253).describe('"@" for apex, or label (no trailing dot).'),
|
|
1067
|
+
content: z7.string().min(1).max(2e3).describe("Record value; TXT values are unquoted."),
|
|
1068
|
+
ttl: z7.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
|
|
1069
|
+
priority: z7.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
|
|
1070
|
+
},
|
|
1071
|
+
handler: async (args, ctx) => {
|
|
1072
|
+
if ((args.type === "MX" || args.type === "SRV") && args.priority === void 0) {
|
|
1073
|
+
return respondError(`Priority is required for ${args.type} records (0\u201365535).`);
|
|
1074
|
+
}
|
|
1075
|
+
const teamId = await ctx.resolveTeamId();
|
|
1076
|
+
const zoneInput = {};
|
|
1077
|
+
if (args.zone_id !== void 0) zoneInput.zone_id = args.zone_id;
|
|
1078
|
+
if (args.domain !== void 0) zoneInput.domain = args.domain;
|
|
1079
|
+
const zone = await resolveZonePublicId(ctx.api, teamId, zoneInput);
|
|
1080
|
+
const body = {
|
|
1081
|
+
type: args.type,
|
|
1082
|
+
name: args.name,
|
|
1083
|
+
value: args.content
|
|
1084
|
+
};
|
|
1085
|
+
if (args.ttl !== void 0) body.ttl = args.ttl;
|
|
1086
|
+
if (args.priority !== void 0) body.priority = args.priority;
|
|
1087
|
+
const response = await ctx.api.post(
|
|
1088
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`,
|
|
1089
|
+
body
|
|
1090
|
+
);
|
|
1091
|
+
return respond({
|
|
1092
|
+
summary: `Created ${args.type} ${args.name} on ${zone.domainName}.`,
|
|
1093
|
+
data: {
|
|
1094
|
+
zone: { publicId: zone.publicId, domainName: zone.domainName },
|
|
1095
|
+
record: shape(response.record)
|
|
1096
|
+
}
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
defineTool({
|
|
1101
|
+
name: "update_dns_record",
|
|
1102
|
+
category: "dns",
|
|
1103
|
+
description: [
|
|
1104
|
+
"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.",
|
|
1105
|
+
"",
|
|
1106
|
+
"When to use: rotate a DKIM key, repoint an A record at a new IP, bump TTL ahead of a planned change.",
|
|
1107
|
+
"",
|
|
1108
|
+
"Inputs:",
|
|
1109
|
+
" - record_id: record publicId.",
|
|
1110
|
+
" - 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).",
|
|
1111
|
+
" - ttl (optional), priority (optional, required for MX / SRV).",
|
|
1112
|
+
"",
|
|
1113
|
+
"Returns: { record: Record } reflecting the new state.",
|
|
1114
|
+
"",
|
|
1115
|
+
'Example: update_dns_record({ record_id: "dnr_abc", type: "A", name: "api", content: "203.0.113.42", ttl: 300 })'
|
|
1116
|
+
].join("\n"),
|
|
1117
|
+
input: {
|
|
1118
|
+
record_id: z7.string().describe("Record publicId."),
|
|
1119
|
+
type: z7.enum(DNS_RECORD_TYPES).describe("DNS record type."),
|
|
1120
|
+
name: z7.string().min(1).max(253).describe('"@" for apex, or label.'),
|
|
1121
|
+
content: z7.string().min(1).max(2e3).describe("Record value; TXT unquoted."),
|
|
1122
|
+
ttl: z7.number().int().min(60).max(86400).optional().describe("TTL in seconds (default 3600)."),
|
|
1123
|
+
priority: z7.number().int().min(0).max(65535).optional().describe("Required for MX / SRV.")
|
|
1124
|
+
},
|
|
1125
|
+
handler: async (args, ctx) => {
|
|
1126
|
+
if ((args.type === "MX" || args.type === "SRV") && args.priority === void 0) {
|
|
1127
|
+
return respondError(`Priority is required for ${args.type} records (0\u201365535).`);
|
|
1128
|
+
}
|
|
1129
|
+
const teamId = await ctx.resolveTeamId();
|
|
1130
|
+
const target = await findRecordZone(ctx.api, teamId, args.record_id);
|
|
1131
|
+
if (!target) {
|
|
1132
|
+
return respondError(
|
|
1133
|
+
`Record ${args.record_id} not found on any zone owned by this team.`
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const body = {
|
|
1137
|
+
type: args.type,
|
|
1138
|
+
name: args.name,
|
|
1139
|
+
value: args.content
|
|
1140
|
+
};
|
|
1141
|
+
if (args.ttl !== void 0) body.ttl = args.ttl;
|
|
1142
|
+
if (args.priority !== void 0) body.priority = args.priority;
|
|
1143
|
+
const response = await ctx.api.put(
|
|
1144
|
+
`/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`,
|
|
1145
|
+
body
|
|
1146
|
+
);
|
|
1147
|
+
return respond({
|
|
1148
|
+
summary: `Updated ${args.type} ${args.name} on ${target.zone.domainName}.`,
|
|
1149
|
+
data: {
|
|
1150
|
+
zone: {
|
|
1151
|
+
publicId: target.zone.publicId,
|
|
1152
|
+
domainName: target.zone.domainName
|
|
1153
|
+
},
|
|
1154
|
+
record: shape(response.record)
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
defineTool({
|
|
1160
|
+
name: "delete_dns_record",
|
|
1161
|
+
category: "dns",
|
|
1162
|
+
description: [
|
|
1163
|
+
"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.",
|
|
1164
|
+
"",
|
|
1165
|
+
"When to use: retire a TXT verification record once the challenge has been confirmed; drop an A record for a decommissioned subdomain.",
|
|
1166
|
+
"",
|
|
1167
|
+
"Inputs:",
|
|
1168
|
+
" - record_id: record publicId.",
|
|
1169
|
+
"",
|
|
1170
|
+
"Returns: { ok: true }.",
|
|
1171
|
+
"",
|
|
1172
|
+
'Example: delete_dns_record({ record_id: "dnr_abc" }) \u2192 { ok: true }'
|
|
1173
|
+
].join("\n"),
|
|
1174
|
+
input: {
|
|
1175
|
+
record_id: z7.string().describe("Record publicId.")
|
|
1176
|
+
},
|
|
1177
|
+
handler: async (args, ctx) => {
|
|
1178
|
+
const teamId = await ctx.resolveTeamId();
|
|
1179
|
+
const target = await findRecordZone(ctx.api, teamId, args.record_id);
|
|
1180
|
+
if (!target) {
|
|
1181
|
+
return respondError(
|
|
1182
|
+
`Record ${args.record_id} not found on any zone owned by this team.`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
await ctx.api.delete(
|
|
1186
|
+
`/api/dns-zones/${teamId}/${target.zone.publicId}/records/${args.record_id}`
|
|
1187
|
+
);
|
|
1188
|
+
return respond({
|
|
1189
|
+
summary: `Deleted record ${args.record_id} from ${target.zone.domainName}.`,
|
|
1190
|
+
data: { ok: true }
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
async function findRecordZone(api, teamId, recordPublicId) {
|
|
1195
|
+
const { zones } = await api.get(`/api/dns-zones/${teamId}`);
|
|
1196
|
+
for (const zone of zones) {
|
|
1197
|
+
const { records } = await api.get(
|
|
1198
|
+
`/api/dns-zones/${teamId}/${zone.publicId}/records`
|
|
1199
|
+
);
|
|
1200
|
+
const match = records.find((r) => r.publicId === recordPublicId);
|
|
1201
|
+
if (match) return { zone, record: match };
|
|
1202
|
+
}
|
|
1203
|
+
return null;
|
|
1204
|
+
}
|
|
731
1205
|
|
|
732
1206
|
// src/tools/domains.ts
|
|
733
|
-
import { z as
|
|
1207
|
+
import { z as z8 } from "zod";
|
|
734
1208
|
defineTool({
|
|
735
1209
|
name: "list_domains",
|
|
736
1210
|
category: "domains",
|
|
@@ -769,8 +1243,8 @@ defineTool({
|
|
|
769
1243
|
'Example: add_domain({ hostname: "api.example.com", service_id: "svc_abc" }) \u2192 { domain: { hostname: "api.example.com", dnsTargets: [...], verified: false, \u2026 } }'
|
|
770
1244
|
].join("\n"),
|
|
771
1245
|
input: {
|
|
772
|
-
hostname:
|
|
773
|
-
service_id:
|
|
1246
|
+
hostname: z8.string().min(3).max(253).describe("Fully-qualified hostname (e.g. api.example.com)."),
|
|
1247
|
+
service_id: z8.string().optional().describe("Optional service publicId to bind to.")
|
|
774
1248
|
},
|
|
775
1249
|
handler: async (args, ctx) => {
|
|
776
1250
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -800,12 +1274,15 @@ defineTool({
|
|
|
800
1274
|
'Example: verify_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
|
|
801
1275
|
].join("\n"),
|
|
802
1276
|
input: {
|
|
803
|
-
domain_id:
|
|
1277
|
+
domain_id: z8.string().describe("Domain publicId.")
|
|
804
1278
|
},
|
|
805
1279
|
handler: async (args, ctx) => {
|
|
806
1280
|
const teamId = await ctx.resolveTeamId();
|
|
807
1281
|
await ctx.hoststack.domains.verify(teamId, args.domain_id);
|
|
808
|
-
return respond({
|
|
1282
|
+
return respond({
|
|
1283
|
+
summary: `Triggered DNS verification for ${args.domain_id}.`,
|
|
1284
|
+
data: { ok: true }
|
|
1285
|
+
});
|
|
809
1286
|
}
|
|
810
1287
|
});
|
|
811
1288
|
defineTool({
|
|
@@ -824,7 +1301,7 @@ defineTool({
|
|
|
824
1301
|
'Example: remove_domain({ domain_id: "dom_xyz" }) \u2192 { ok: true }'
|
|
825
1302
|
].join("\n"),
|
|
826
1303
|
input: {
|
|
827
|
-
domain_id:
|
|
1304
|
+
domain_id: z8.string().describe("Domain publicId.")
|
|
828
1305
|
},
|
|
829
1306
|
handler: async (args, ctx) => {
|
|
830
1307
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -834,7 +1311,7 @@ defineTool({
|
|
|
834
1311
|
});
|
|
835
1312
|
|
|
836
1313
|
// src/tools/env-vars.ts
|
|
837
|
-
import { z as
|
|
1314
|
+
import { z as z9 } from "zod";
|
|
838
1315
|
defineTool({
|
|
839
1316
|
name: "list_env_vars",
|
|
840
1317
|
category: "env-vars",
|
|
@@ -851,7 +1328,7 @@ defineTool({
|
|
|
851
1328
|
'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
1329
|
].join("\n"),
|
|
853
1330
|
input: {
|
|
854
|
-
service_id:
|
|
1331
|
+
service_id: z9.string().describe("Service publicId.")
|
|
855
1332
|
},
|
|
856
1333
|
handler: async (args, ctx) => {
|
|
857
1334
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -880,10 +1357,10 @@ defineTool({
|
|
|
880
1357
|
'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
1358
|
].join("\n"),
|
|
882
1359
|
input: {
|
|
883
|
-
service_id:
|
|
884
|
-
key:
|
|
885
|
-
value:
|
|
886
|
-
is_secret:
|
|
1360
|
+
service_id: z9.string().describe("Service publicId."),
|
|
1361
|
+
key: z9.string().min(1).max(128).describe("Env-var key."),
|
|
1362
|
+
value: z9.string().describe("New value."),
|
|
1363
|
+
is_secret: z9.boolean().optional().describe("Mark as secret (encrypted, masked on read). Default true.")
|
|
887
1364
|
},
|
|
888
1365
|
handler: async (args, ctx) => {
|
|
889
1366
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -891,10 +1368,15 @@ defineTool({
|
|
|
891
1368
|
const existing = await ctx.hoststack.envVars.list(teamId, args.service_id);
|
|
892
1369
|
const match = existing.envVars.find((v) => v.key === args.key);
|
|
893
1370
|
if (match) {
|
|
894
|
-
const response2 = await ctx.hoststack.envVars.update(
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
1371
|
+
const response2 = await ctx.hoststack.envVars.update(
|
|
1372
|
+
teamId,
|
|
1373
|
+
args.service_id,
|
|
1374
|
+
String(match.id),
|
|
1375
|
+
{
|
|
1376
|
+
value: args.value,
|
|
1377
|
+
isSecret
|
|
1378
|
+
}
|
|
1379
|
+
);
|
|
898
1380
|
const data2 = {
|
|
899
1381
|
envVar: shapeEnvVar(response2.envVar),
|
|
900
1382
|
action: "updated"
|
|
@@ -930,18 +1412,18 @@ defineTool({
|
|
|
930
1412
|
'Example: delete_env_var({ service_id: "svc_abc", key: "OLD_FLAG" }) \u2192 { ok: true }'
|
|
931
1413
|
].join("\n"),
|
|
932
1414
|
input: {
|
|
933
|
-
service_id:
|
|
934
|
-
key:
|
|
1415
|
+
service_id: z9.string().describe("Service publicId."),
|
|
1416
|
+
key: z9.string().min(1).max(128).describe("Env-var key to delete.")
|
|
935
1417
|
},
|
|
936
1418
|
handler: async (args, ctx) => {
|
|
937
1419
|
const teamId = await ctx.resolveTeamId();
|
|
938
1420
|
const existing = await ctx.hoststack.envVars.list(teamId, args.service_id);
|
|
939
1421
|
const match = existing.envVars.find((v) => v.key === args.key);
|
|
940
1422
|
if (!match) {
|
|
941
|
-
return respondError(
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
);
|
|
1423
|
+
return respondError(`Env var "${args.key}" not found on service ${args.service_id}.`, {
|
|
1424
|
+
key: args.key,
|
|
1425
|
+
service_id: args.service_id
|
|
1426
|
+
});
|
|
945
1427
|
}
|
|
946
1428
|
await ctx.hoststack.envVars.delete(teamId, args.service_id, String(match.id));
|
|
947
1429
|
return respond({
|
|
@@ -967,19 +1449,19 @@ defineTool({
|
|
|
967
1449
|
'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
1450
|
].join("\n"),
|
|
969
1451
|
input: {
|
|
970
|
-
service_id:
|
|
971
|
-
env_vars:
|
|
972
|
-
|
|
973
|
-
key:
|
|
974
|
-
value:
|
|
975
|
-
is_secret:
|
|
1452
|
+
service_id: z9.string().describe("Service publicId."),
|
|
1453
|
+
env_vars: z9.array(
|
|
1454
|
+
z9.object({
|
|
1455
|
+
key: z9.string().min(1).max(128),
|
|
1456
|
+
value: z9.string(),
|
|
1457
|
+
is_secret: z9.boolean().optional()
|
|
976
1458
|
})
|
|
977
1459
|
).max(500).describe("Array of env-var rows. Hard cap 500.")
|
|
978
1460
|
},
|
|
979
1461
|
handler: async (args, ctx) => {
|
|
980
1462
|
const teamId = await ctx.resolveTeamId();
|
|
981
1463
|
const payload = {
|
|
982
|
-
|
|
1464
|
+
vars: args.env_vars.map((v) => {
|
|
983
1465
|
const row = {
|
|
984
1466
|
key: v.key,
|
|
985
1467
|
value: v.value
|
|
@@ -997,7 +1479,7 @@ defineTool({
|
|
|
997
1479
|
});
|
|
998
1480
|
|
|
999
1481
|
// src/tools/environments.ts
|
|
1000
|
-
import { z as
|
|
1482
|
+
import { z as z10 } from "zod";
|
|
1001
1483
|
defineTool({
|
|
1002
1484
|
name: "list_environments",
|
|
1003
1485
|
category: "environments",
|
|
@@ -1014,7 +1496,7 @@ defineTool({
|
|
|
1014
1496
|
'Example: list_environments({ project_id: "prj_abc" }) \u2192 { items: [{ name: "Production", type: "production", isDefault: true }, { name: "Staging", type: "staging" }] }'
|
|
1015
1497
|
].join("\n"),
|
|
1016
1498
|
input: {
|
|
1017
|
-
project_id:
|
|
1499
|
+
project_id: z10.string().describe("Project publicId.")
|
|
1018
1500
|
},
|
|
1019
1501
|
handler: async (args, ctx) => {
|
|
1020
1502
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1043,10 +1525,10 @@ defineTool({
|
|
|
1043
1525
|
'Example: create_environment({ project_id: "prj_abc", name: "Staging", type: "staging" }) \u2192 { environment: { id: 7, publicId: "environment_\u2026", name: "Staging", type: "staging" } }'
|
|
1044
1526
|
].join("\n"),
|
|
1045
1527
|
input: {
|
|
1046
|
-
project_id:
|
|
1047
|
-
name:
|
|
1048
|
-
type:
|
|
1049
|
-
is_protected:
|
|
1528
|
+
project_id: z10.string().describe("Project publicId."),
|
|
1529
|
+
name: z10.string().min(1).max(64).describe("Environment name (1\u201364 chars)."),
|
|
1530
|
+
type: z10.enum(["production", "staging", "development", "preview"]).describe("Environment type \u2014 drives the hostname suffix."),
|
|
1531
|
+
is_protected: z10.boolean().optional().describe("Require admin role for destructive actions. Default false.")
|
|
1050
1532
|
},
|
|
1051
1533
|
handler: async (args, ctx) => {
|
|
1052
1534
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1080,8 +1562,8 @@ defineTool({
|
|
|
1080
1562
|
'Example: delete_environment({ project_id: "prj_abc", environment_id: "environment_xyz" }) \u2192 { success: true }'
|
|
1081
1563
|
].join("\n"),
|
|
1082
1564
|
input: {
|
|
1083
|
-
project_id:
|
|
1084
|
-
environment_id:
|
|
1565
|
+
project_id: z10.string().describe("Project publicId."),
|
|
1566
|
+
environment_id: z10.union([z10.string(), z10.number()]).describe("Environment publicId or numeric id.")
|
|
1085
1567
|
},
|
|
1086
1568
|
handler: async (args, ctx) => {
|
|
1087
1569
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1112,9 +1594,9 @@ defineTool({
|
|
|
1112
1594
|
'Example: promote_deploy({ service_id: "svc_staging", deploy_id: "dpl_built", target_environment_id: "environment_prod" }) \u2192 { deploy: { id: 99, status: "pending", trigger: "rollback", dockerImageId: "sha256:\u2026" } }'
|
|
1113
1595
|
].join("\n"),
|
|
1114
1596
|
input: {
|
|
1115
|
-
service_id:
|
|
1116
|
-
deploy_id:
|
|
1117
|
-
target_environment_id:
|
|
1597
|
+
service_id: z10.string().describe("Source service publicId."),
|
|
1598
|
+
deploy_id: z10.string().describe("Source deploy publicId (must have a built image)."),
|
|
1599
|
+
target_environment_id: z10.union([z10.string(), z10.number()]).describe("Target environment publicId or numeric id.")
|
|
1118
1600
|
},
|
|
1119
1601
|
handler: async (args, ctx) => {
|
|
1120
1602
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1162,8 +1644,190 @@ defineTool({
|
|
|
1162
1644
|
}
|
|
1163
1645
|
});
|
|
1164
1646
|
|
|
1647
|
+
// src/tools/notifications.ts
|
|
1648
|
+
import { z as z11 } from "zod";
|
|
1649
|
+
var NOTIFICATION_EVENTS = [
|
|
1650
|
+
"deploy.started",
|
|
1651
|
+
"deploy.succeeded",
|
|
1652
|
+
"deploy.failed",
|
|
1653
|
+
"deploy.failed_consecutive",
|
|
1654
|
+
"service.created",
|
|
1655
|
+
"service.deleted",
|
|
1656
|
+
"service.suspended",
|
|
1657
|
+
"service.resumed",
|
|
1658
|
+
"service.restart_failed",
|
|
1659
|
+
"service.auto_suspended",
|
|
1660
|
+
"service.acme_cert_failed",
|
|
1661
|
+
"git.auth_failed"
|
|
1662
|
+
];
|
|
1663
|
+
defineTool({
|
|
1664
|
+
name: "list_notification_channels",
|
|
1665
|
+
category: "alerts",
|
|
1666
|
+
description: [
|
|
1667
|
+
"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.",
|
|
1668
|
+
"",
|
|
1669
|
+
"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.",
|
|
1670
|
+
"",
|
|
1671
|
+
"Returns: { items: Channel[] } \u2014 each channel includes id, type, name, webhookUrl (masked), active, events, createdAt.",
|
|
1672
|
+
"",
|
|
1673
|
+
"Example: list_notification_channels() \u2192 { items: [{ id: 3, type: 'slack', name: 'eng-alerts', events: ['deploy.failed', 'git.auth_failed'], \u2026 }] }"
|
|
1674
|
+
].join("\n"),
|
|
1675
|
+
input: {},
|
|
1676
|
+
handler: async (_args, ctx) => {
|
|
1677
|
+
const teamId = await ctx.resolveTeamId();
|
|
1678
|
+
const response = await ctx.hoststack.notifications.listChannels(teamId);
|
|
1679
|
+
const data = shapeList(response, "channels", shape);
|
|
1680
|
+
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"}.`;
|
|
1681
|
+
return respond({ summary, data });
|
|
1682
|
+
}
|
|
1683
|
+
});
|
|
1684
|
+
defineTool({
|
|
1685
|
+
name: "create_notification_channel",
|
|
1686
|
+
category: "alerts",
|
|
1687
|
+
description: [
|
|
1688
|
+
"Create a Slack, Discord, or email notification channel and subscribe it to a list of events.",
|
|
1689
|
+
"",
|
|
1690
|
+
"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.",
|
|
1691
|
+
"",
|
|
1692
|
+
"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.",
|
|
1693
|
+
"",
|
|
1694
|
+
"Inputs:",
|
|
1695
|
+
' - type: "slack" | "discord" | "email".',
|
|
1696
|
+
" - name: human-readable label (1\u2013128 chars).",
|
|
1697
|
+
" - webhook_url: Slack/Discord webhook URL OR email address.",
|
|
1698
|
+
" - 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).",
|
|
1699
|
+
"",
|
|
1700
|
+
"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.",
|
|
1701
|
+
"",
|
|
1702
|
+
"Returns: { channel: Channel }.",
|
|
1703
|
+
"",
|
|
1704
|
+
"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'] })"
|
|
1705
|
+
].join("\n"),
|
|
1706
|
+
input: {
|
|
1707
|
+
type: z11.enum(["slack", "discord", "email"]).describe("Channel type."),
|
|
1708
|
+
name: z11.string().min(1).max(128).describe("Human-readable label."),
|
|
1709
|
+
webhook_url: z11.string().max(500).describe("Slack/Discord webhook URL or email address (when type=email)."),
|
|
1710
|
+
events: z11.array(z11.enum(NOTIFICATION_EVENTS)).describe("List of events the channel subscribes to. Empty list = subscribe to nothing.")
|
|
1711
|
+
},
|
|
1712
|
+
handler: async (args, ctx) => {
|
|
1713
|
+
const teamId = await ctx.resolveTeamId();
|
|
1714
|
+
const response = await ctx.hoststack.notifications.createChannel(teamId, {
|
|
1715
|
+
type: args.type,
|
|
1716
|
+
name: args.name,
|
|
1717
|
+
webhookUrl: args.webhook_url,
|
|
1718
|
+
events: args.events
|
|
1719
|
+
});
|
|
1720
|
+
const data = { channel: shape(response.channel) };
|
|
1721
|
+
return respond({
|
|
1722
|
+
summary: `Created ${args.type} channel "${args.name}" subscribed to ${args.events.length} event${args.events.length === 1 ? "" : "s"}.`,
|
|
1723
|
+
data
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
defineTool({
|
|
1728
|
+
name: "update_notification_channel",
|
|
1729
|
+
category: "alerts",
|
|
1730
|
+
description: [
|
|
1731
|
+
"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.",
|
|
1732
|
+
"",
|
|
1733
|
+
"When to use: silencing a noisy channel (active=false), adding a new event to an existing list, or relabeling.",
|
|
1734
|
+
"",
|
|
1735
|
+
"Inputs:",
|
|
1736
|
+
" - channel_id: numeric id of the channel.",
|
|
1737
|
+
" - name (optional): new label.",
|
|
1738
|
+
" - active (optional): false to silence, true to re-enable.",
|
|
1739
|
+
" - events (optional): replace the full subscription list. Passing [] silences all events without disabling the channel.",
|
|
1740
|
+
"",
|
|
1741
|
+
"Returns: { channel: Channel }.",
|
|
1742
|
+
"",
|
|
1743
|
+
"Example: update_notification_channel({ channel_id: 3, events: ['deploy.failed', 'service.restart_failed', 'git.auth_failed'] })"
|
|
1744
|
+
].join("\n"),
|
|
1745
|
+
input: {
|
|
1746
|
+
channel_id: z11.number().int().positive().describe("Numeric channel id from list_notification_channels."),
|
|
1747
|
+
name: z11.string().min(1).max(128).optional().describe("New label."),
|
|
1748
|
+
active: z11.boolean().optional().describe("false silences without deleting."),
|
|
1749
|
+
events: z11.array(z11.enum(NOTIFICATION_EVENTS)).optional().describe("Replaces the full subscription list.")
|
|
1750
|
+
},
|
|
1751
|
+
handler: async (args, ctx) => {
|
|
1752
|
+
const teamId = await ctx.resolveTeamId();
|
|
1753
|
+
const update = {};
|
|
1754
|
+
if (args.name !== void 0) update.name = args.name;
|
|
1755
|
+
if (args.active !== void 0) update.active = args.active;
|
|
1756
|
+
if (args.events !== void 0) update.events = args.events;
|
|
1757
|
+
if (Object.keys(update).length === 0) {
|
|
1758
|
+
return respond({ summary: "No fields to update.", data: {} });
|
|
1759
|
+
}
|
|
1760
|
+
const response = await ctx.hoststack.notifications.updateChannel(
|
|
1761
|
+
teamId,
|
|
1762
|
+
args.channel_id,
|
|
1763
|
+
update
|
|
1764
|
+
);
|
|
1765
|
+
const data = { channel: shape(response.channel) };
|
|
1766
|
+
return respond({
|
|
1767
|
+
summary: `Updated notification channel #${args.channel_id}.`,
|
|
1768
|
+
data
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
});
|
|
1772
|
+
defineTool({
|
|
1773
|
+
name: "delete_notification_channel",
|
|
1774
|
+
category: "alerts",
|
|
1775
|
+
description: [
|
|
1776
|
+
"Permanently delete a notification channel. Use update_notification_channel({ active: false }) to silence without deleting if you might want it back later.",
|
|
1777
|
+
"",
|
|
1778
|
+
"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.",
|
|
1779
|
+
"",
|
|
1780
|
+
"Inputs:",
|
|
1781
|
+
" - channel_id: numeric id of the channel.",
|
|
1782
|
+
"",
|
|
1783
|
+
"Returns: { ok: true }.",
|
|
1784
|
+
"",
|
|
1785
|
+
"Example: delete_notification_channel({ channel_id: 3 }) \u2192 { ok: true }"
|
|
1786
|
+
].join("\n"),
|
|
1787
|
+
input: {
|
|
1788
|
+
channel_id: z11.number().int().positive().describe("Numeric channel id.")
|
|
1789
|
+
},
|
|
1790
|
+
handler: async (args, ctx) => {
|
|
1791
|
+
const teamId = await ctx.resolveTeamId();
|
|
1792
|
+
await ctx.hoststack.notifications.deleteChannel(teamId, args.channel_id);
|
|
1793
|
+
return respond({
|
|
1794
|
+
summary: `Deleted notification channel #${args.channel_id}.`,
|
|
1795
|
+
data: { ok: true }
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
});
|
|
1799
|
+
defineTool({
|
|
1800
|
+
name: "test_notification_channel",
|
|
1801
|
+
category: "alerts",
|
|
1802
|
+
description: [
|
|
1803
|
+
'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.',
|
|
1804
|
+
"",
|
|
1805
|
+
"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.",
|
|
1806
|
+
"",
|
|
1807
|
+
"Inputs:",
|
|
1808
|
+
" - channel_id: numeric id of the channel.",
|
|
1809
|
+
"",
|
|
1810
|
+
"Returns: { success: boolean, error?: string } \u2014 false + error string when the webhook returned non-2xx.",
|
|
1811
|
+
"",
|
|
1812
|
+
"Example: test_notification_channel({ channel_id: 3 }) \u2192 { success: true }"
|
|
1813
|
+
].join("\n"),
|
|
1814
|
+
input: {
|
|
1815
|
+
channel_id: z11.number().int().positive().describe("Numeric channel id.")
|
|
1816
|
+
},
|
|
1817
|
+
handler: async (args, ctx) => {
|
|
1818
|
+
const teamId = await ctx.resolveTeamId();
|
|
1819
|
+
const response = await ctx.hoststack.notifications.testChannel(teamId, args.channel_id);
|
|
1820
|
+
const data = shape(response);
|
|
1821
|
+
const okFlag = data && typeof data === "object" && "success" in data ? data.success : false;
|
|
1822
|
+
return respond({
|
|
1823
|
+
summary: okFlag ? `Test event delivered to channel #${args.channel_id}.` : `Test event FAILED for channel #${args.channel_id}: ${data.error ?? "unknown error"}.`,
|
|
1824
|
+
data
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1165
1829
|
// src/tools/projects.ts
|
|
1166
|
-
import { z as
|
|
1830
|
+
import { z as z12 } from "zod";
|
|
1167
1831
|
defineTool({
|
|
1168
1832
|
name: "list_projects",
|
|
1169
1833
|
category: "projects",
|
|
@@ -1203,9 +1867,9 @@ defineTool({
|
|
|
1203
1867
|
'Example: create_project({ name: "billing-api", description: "Stripe webhooks", region: "fsn1" }) \u2192 { project: { id: 12, publicId: "prj_\u2026", \u2026 } }'
|
|
1204
1868
|
].join("\n"),
|
|
1205
1869
|
input: {
|
|
1206
|
-
name:
|
|
1207
|
-
description:
|
|
1208
|
-
region:
|
|
1870
|
+
name: z12.string().min(1).max(60).describe("Project name (1\u201360 chars)."),
|
|
1871
|
+
description: z12.string().max(500).optional().describe("Short description (\u2264500 chars)."),
|
|
1872
|
+
region: z12.enum(["fsn1", "nbg1", "hel1"]).optional().describe("Hetzner region: fsn1 | nbg1 | hel1.")
|
|
1209
1873
|
},
|
|
1210
1874
|
handler: async (args, ctx) => {
|
|
1211
1875
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1236,9 +1900,9 @@ defineTool({
|
|
|
1236
1900
|
'Example: update_project({ project_id: "prj_abc", name: "billing-prod" }) \u2192 { project: { name: "billing-prod", \u2026 } }'
|
|
1237
1901
|
].join("\n"),
|
|
1238
1902
|
input: {
|
|
1239
|
-
project_id:
|
|
1240
|
-
name:
|
|
1241
|
-
description:
|
|
1903
|
+
project_id: z12.string().describe("Project publicId."),
|
|
1904
|
+
name: z12.string().min(1).max(60).optional().describe("New name (1\u201360 chars)."),
|
|
1905
|
+
description: z12.string().max(500).optional().describe("New description (\u2264500 chars).")
|
|
1242
1906
|
},
|
|
1243
1907
|
handler: async (args, ctx) => {
|
|
1244
1908
|
if (args.name === void 0 && args.description === void 0) {
|
|
@@ -1272,7 +1936,7 @@ defineTool({
|
|
|
1272
1936
|
'Example: get_project({ project_id: "prj_abc" }) \u2192 { project: { id: 12, name: "billing", \u2026 } }'
|
|
1273
1937
|
].join("\n"),
|
|
1274
1938
|
input: {
|
|
1275
|
-
project_id:
|
|
1939
|
+
project_id: z12.string().describe("Project publicId (e.g. prj_abc123).")
|
|
1276
1940
|
},
|
|
1277
1941
|
handler: async (args, ctx) => {
|
|
1278
1942
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1284,25 +1948,59 @@ defineTool({
|
|
|
1284
1948
|
});
|
|
1285
1949
|
|
|
1286
1950
|
// src/tools/services.ts
|
|
1287
|
-
import { z as
|
|
1951
|
+
import { z as z13 } from "zod";
|
|
1288
1952
|
defineTool({
|
|
1289
1953
|
name: "list_services",
|
|
1290
1954
|
category: "services",
|
|
1291
1955
|
description: [
|
|
1292
|
-
"List
|
|
1956
|
+
"List services (web, worker, cron, private, static) in the active HostStack team.",
|
|
1293
1957
|
"",
|
|
1294
1958
|
"When to use: the agent needs to find a service by name, check what is deployed, or pick a target for a follow-up tool (logs, deploys, env vars). This is the canonical way to resolve a publicId from a human-friendly name.",
|
|
1295
1959
|
"",
|
|
1960
|
+
"Inputs (all optional):",
|
|
1961
|
+
' - project_id: narrow to one project (numeric id or publicId "prj_\u2026").',
|
|
1962
|
+
' - environment_id: narrow to one environment (numeric id or publicId "env_\u2026").',
|
|
1963
|
+
' - status: "active" | "deploying" | "suspended" | "failed" | "not_deployed".',
|
|
1964
|
+
' - type: "web_service" | "private_service" | "worker" | "cron_job" | "static_site".',
|
|
1965
|
+
"",
|
|
1296
1966
|
"Returns: { items: Service[] } \u2014 each service includes id, publicId, name, type, status, projectId, repoUrl, branch, runtime, createdAt.",
|
|
1297
1967
|
"",
|
|
1298
|
-
'Example: list_services(
|
|
1968
|
+
'Example: list_services({ status: "failed" }) \u2192 only services that need attention.'
|
|
1299
1969
|
].join("\n"),
|
|
1300
|
-
input: {
|
|
1301
|
-
|
|
1970
|
+
input: {
|
|
1971
|
+
project_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Project filter \u2014 numeric id or publicId."),
|
|
1972
|
+
environment_id: z13.union([z13.number().int().positive(), z13.string()]).optional().describe("Environment filter \u2014 numeric id or publicId."),
|
|
1973
|
+
status: z13.enum(["active", "deploying", "suspended", "failed", "not_deployed"]).optional().describe("Filter by current runtime status."),
|
|
1974
|
+
type: z13.enum(["web_service", "private_service", "worker", "cron_job", "static_site"]).optional().describe("Filter by service type.")
|
|
1975
|
+
},
|
|
1976
|
+
handler: async (args, ctx) => {
|
|
1302
1977
|
const teamId = await ctx.resolveTeamId();
|
|
1303
|
-
const
|
|
1978
|
+
const filters = {};
|
|
1979
|
+
if (args.project_id !== void 0) {
|
|
1980
|
+
const resolved = await ctx.hoststack.resolveId(args.project_id, {
|
|
1981
|
+
kind: "project",
|
|
1982
|
+
teamId
|
|
1983
|
+
});
|
|
1984
|
+
filters.projectId = resolved;
|
|
1985
|
+
}
|
|
1986
|
+
if (args.environment_id !== void 0) {
|
|
1987
|
+
const resolved = await ctx.hoststack.resolveId(args.environment_id, {
|
|
1988
|
+
kind: "environment",
|
|
1989
|
+
teamId
|
|
1990
|
+
});
|
|
1991
|
+
filters.environmentId = resolved;
|
|
1992
|
+
}
|
|
1993
|
+
if (args.status) filters.status = args.status;
|
|
1994
|
+
if (args.type) filters.type = args.type;
|
|
1995
|
+
const response = await ctx.hoststack.services.list(teamId, filters);
|
|
1304
1996
|
const data = shapeList(response, "services", shapeService);
|
|
1305
|
-
const
|
|
1997
|
+
const filterDesc = [
|
|
1998
|
+
args.project_id !== void 0 ? `project=${args.project_id}` : null,
|
|
1999
|
+
args.environment_id !== void 0 ? `env=${args.environment_id}` : null,
|
|
2000
|
+
args.status ? `status=${args.status}` : null,
|
|
2001
|
+
args.type ? `type=${args.type}` : null
|
|
2002
|
+
].filter(Boolean).join(", ");
|
|
2003
|
+
const summary = data.items.length === 0 ? filterDesc ? `No services match the given filters (${filterDesc}).` : "No services yet \u2014 create one with create_service or via the dashboard." : `Found ${data.items.length} service${data.items.length === 1 ? "" : "s"}${filterDesc ? ` matching ${filterDesc}` : ""}.`;
|
|
1306
2004
|
return respond({ summary, data });
|
|
1307
2005
|
}
|
|
1308
2006
|
});
|
|
@@ -1322,7 +2020,7 @@ defineTool({
|
|
|
1322
2020
|
'Example: get_service({ service_id: "svc_abc" }) \u2192 { service: { type: "web", status: "running", \u2026 } }'
|
|
1323
2021
|
].join("\n"),
|
|
1324
2022
|
input: {
|
|
1325
|
-
service_id:
|
|
2023
|
+
service_id: z13.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1326
2024
|
},
|
|
1327
2025
|
handler: async (args, ctx) => {
|
|
1328
2026
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1338,7 +2036,7 @@ defineTool({
|
|
|
1338
2036
|
description: [
|
|
1339
2037
|
"Fetch the latest CPU, memory, network, and request-rate metrics for a service.",
|
|
1340
2038
|
"",
|
|
1341
|
-
|
|
2039
|
+
'When to use: a service is reportedly slow, you suspect resource pressure, or you want a quick health snapshot before scaling. Returns a single point-in-time sample. For a time series \u2014 "is memory growing?", "did CPU spike during that deploy?" \u2014 use get_service_metrics_history instead.',
|
|
1342
2040
|
"",
|
|
1343
2041
|
"Inputs:",
|
|
1344
2042
|
" - service_id: publicId of the service.",
|
|
@@ -1348,7 +2046,7 @@ defineTool({
|
|
|
1348
2046
|
'Example: get_service_metrics({ service_id: "svc_abc" }) \u2192 { metrics: { cpu: 0.42, memory: 0.71, \u2026 } }'
|
|
1349
2047
|
].join("\n"),
|
|
1350
2048
|
input: {
|
|
1351
|
-
service_id:
|
|
2049
|
+
service_id: z13.string().describe("Service publicId.")
|
|
1352
2050
|
},
|
|
1353
2051
|
handler: async (args, ctx) => {
|
|
1354
2052
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1357,6 +2055,55 @@ defineTool({
|
|
|
1357
2055
|
return respond({ summary: `Metrics snapshot for service ${args.service_id}.`, data });
|
|
1358
2056
|
}
|
|
1359
2057
|
});
|
|
2058
|
+
defineTool({
|
|
2059
|
+
name: "get_service_metrics_history",
|
|
2060
|
+
category: "services",
|
|
2061
|
+
description: [
|
|
2062
|
+
"Fetch a metrics time series (CPU, memory, network, disk) for a service.",
|
|
2063
|
+
"",
|
|
2064
|
+
'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.',
|
|
2065
|
+
"",
|
|
2066
|
+
"Inputs (all optional \u2014 omit both for the trailing hour):",
|
|
2067
|
+
" - service_id: publicId of the service.",
|
|
2068
|
+
' - from: ISO-8601 lower bound OR relative offset like "-15m" / "-2h" / "-7d".',
|
|
2069
|
+
" - to: ISO-8601 upper bound (or relative offset). Defaults to now.",
|
|
2070
|
+
"",
|
|
2071
|
+
"Resolution: \u22647d \u2192 raw samples (~minute granularity), \u226430d \u2192 hourly pre-aggregates, >30d \u2192 daily. Up to ~500 points returned.",
|
|
2072
|
+
"",
|
|
2073
|
+
"Returns: { history: Array<{ timestamp, cpuPercent, memoryUsedMb, memoryLimitMb, networkRxBytes, networkTxBytes, diskUsedMb }> }.",
|
|
2074
|
+
"",
|
|
2075
|
+
'Example: get_service_metrics_history({ service_id: "svc_abc", from: "-1h" }) \u2192 60-ish points for the last hour.'
|
|
2076
|
+
].join("\n"),
|
|
2077
|
+
input: {
|
|
2078
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2079
|
+
from: z13.string().optional().describe('ISO-8601 lower bound or relative offset (e.g. "-1h", "-2d").'),
|
|
2080
|
+
to: z13.string().optional().describe("ISO-8601 upper bound; defaults to now.")
|
|
2081
|
+
},
|
|
2082
|
+
handler: async (args, ctx) => {
|
|
2083
|
+
const teamId = await ctx.resolveTeamId();
|
|
2084
|
+
const opts = {};
|
|
2085
|
+
const resolveRel = (raw) => {
|
|
2086
|
+
const rel = /^-(\d+)(s|m|h|d)$/.exec(raw.trim());
|
|
2087
|
+
if (!rel) return raw;
|
|
2088
|
+
const amount = Number.parseInt(rel[1], 10);
|
|
2089
|
+
const unit = rel[2];
|
|
2090
|
+
const ms = unit === "s" ? amount * 1e3 : unit === "m" ? amount * 6e4 : unit === "h" ? amount * 36e5 : amount * 864e5;
|
|
2091
|
+
return new Date(Date.now() - ms).toISOString();
|
|
2092
|
+
};
|
|
2093
|
+
if (args.from) opts.from = resolveRel(args.from);
|
|
2094
|
+
if (args.to) opts.to = resolveRel(args.to);
|
|
2095
|
+
const response = await ctx.hoststack.services.getMetricsHistory(
|
|
2096
|
+
teamId,
|
|
2097
|
+
args.service_id,
|
|
2098
|
+
opts
|
|
2099
|
+
);
|
|
2100
|
+
const points = Array.isArray(response.history) ? response.history.length : 0;
|
|
2101
|
+
return respond({
|
|
2102
|
+
summary: `Returned ${points} metric point${points === 1 ? "" : "s"} for service ${args.service_id}.`,
|
|
2103
|
+
data: { history: response.history }
|
|
2104
|
+
});
|
|
2105
|
+
}
|
|
2106
|
+
});
|
|
1360
2107
|
defineTool({
|
|
1361
2108
|
name: "update_service",
|
|
1362
2109
|
category: "services",
|
|
@@ -1374,8 +2121,8 @@ defineTool({
|
|
|
1374
2121
|
'Example: update_service({ service_id: "svc_abc", name: "api-prod" }) \u2192 { service: { name: "api-prod", \u2026 } }'
|
|
1375
2122
|
].join("\n"),
|
|
1376
2123
|
input: {
|
|
1377
|
-
service_id:
|
|
1378
|
-
name:
|
|
2124
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2125
|
+
name: z13.string().min(1).max(60).describe("New service name (1\u201360 chars).")
|
|
1379
2126
|
},
|
|
1380
2127
|
handler: async (args, ctx) => {
|
|
1381
2128
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1402,21 +2149,30 @@ defineTool({
|
|
|
1402
2149
|
" - dockerfile_path (optional): path to Dockerfile relative to root_directory. Pass null to clear.",
|
|
1403
2150
|
" - auto_deploy (optional): boolean \u2014 auto-deploy on git push.",
|
|
1404
2151
|
" - instance_count (optional): integer \u22651 \u2014 pin both min and max instances to this value.",
|
|
2152
|
+
' - log_filter_rules (optional): list of { pattern, action } rules applied to runtime logs at query time. Pattern matches the message by case-insensitive substring; action is "drop" (filter out) or "downgrade" (flip stderr \u2192 stdout so it stops looking like an error). Pass [] to clear all rules. Capped at 50 rules.',
|
|
1405
2153
|
"",
|
|
1406
2154
|
"Returns: { service?: Service, config?: ServiceConfig } \u2014 whichever rows were touched.",
|
|
1407
2155
|
"",
|
|
1408
2156
|
'Example: update_service_config({ service_id: "svc_abc", start_command: "bun apps/api/src/index.ts" }) \u2192 { service: { startCommand: "bun apps/api/src/index.ts", \u2026 } }'
|
|
1409
2157
|
].join("\n"),
|
|
1410
2158
|
input: {
|
|
1411
|
-
service_id:
|
|
1412
|
-
install_command:
|
|
1413
|
-
build_command:
|
|
1414
|
-
start_command:
|
|
1415
|
-
branch:
|
|
1416
|
-
root_directory:
|
|
1417
|
-
dockerfile_path:
|
|
1418
|
-
auto_deploy:
|
|
1419
|
-
instance_count:
|
|
2159
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2160
|
+
install_command: z13.string().nullable().optional().describe("Install shell command. Null clears."),
|
|
2161
|
+
build_command: z13.string().nullable().optional().describe("Build shell command. Null clears."),
|
|
2162
|
+
start_command: z13.string().nullable().optional().describe("Start shell command. Null clears."),
|
|
2163
|
+
branch: z13.string().optional().describe("Git branch to track."),
|
|
2164
|
+
root_directory: z13.string().optional().describe("Build context root."),
|
|
2165
|
+
dockerfile_path: z13.string().nullable().optional().describe("Path to Dockerfile relative to root. Null clears."),
|
|
2166
|
+
auto_deploy: z13.boolean().optional().describe("Auto-deploy on push."),
|
|
2167
|
+
instance_count: z13.number().int().positive().max(50).optional().describe("Pin min and max instances to this value (1\u201350)."),
|
|
2168
|
+
log_filter_rules: z13.array(
|
|
2169
|
+
z13.object({
|
|
2170
|
+
pattern: z13.string().min(1).max(200),
|
|
2171
|
+
action: z13.enum(["drop", "downgrade"])
|
|
2172
|
+
})
|
|
2173
|
+
).max(50).optional().describe(
|
|
2174
|
+
"Runtime-log filter rules. Empty array [] clears all rules. Each pattern is case-insensitive substring match against the message."
|
|
2175
|
+
)
|
|
1420
2176
|
},
|
|
1421
2177
|
handler: async (args, ctx) => {
|
|
1422
2178
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1428,14 +2184,16 @@ defineTool({
|
|
|
1428
2184
|
if (args.dockerfile_path !== void 0)
|
|
1429
2185
|
serviceUpdate["dockerfilePath"] = args.dockerfile_path;
|
|
1430
2186
|
if (args.branch !== void 0) serviceUpdate["branch"] = args.branch;
|
|
1431
|
-
if (args.root_directory !== void 0)
|
|
1432
|
-
serviceUpdate["rootDirectory"] = args.root_directory;
|
|
2187
|
+
if (args.root_directory !== void 0) serviceUpdate["rootDirectory"] = args.root_directory;
|
|
1433
2188
|
if (args.auto_deploy !== void 0) serviceUpdate["autoDeploy"] = args.auto_deploy;
|
|
1434
2189
|
const configUpdate = {};
|
|
1435
2190
|
if (args.instance_count !== void 0) {
|
|
1436
2191
|
configUpdate["minInstances"] = args.instance_count;
|
|
1437
2192
|
configUpdate["maxInstances"] = args.instance_count;
|
|
1438
2193
|
}
|
|
2194
|
+
if (args.log_filter_rules !== void 0) {
|
|
2195
|
+
configUpdate["logFilterRules"] = args.log_filter_rules;
|
|
2196
|
+
}
|
|
1439
2197
|
if (Object.keys(serviceUpdate).length === 0 && Object.keys(configUpdate).length === 0) {
|
|
1440
2198
|
return respond({ summary: "No fields to update.", data: {} });
|
|
1441
2199
|
}
|
|
@@ -1481,7 +2239,7 @@ defineTool({
|
|
|
1481
2239
|
'Example: suspend_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
1482
2240
|
].join("\n"),
|
|
1483
2241
|
input: {
|
|
1484
|
-
service_id:
|
|
2242
|
+
service_id: z13.string().describe("Service publicId.")
|
|
1485
2243
|
},
|
|
1486
2244
|
handler: async (args, ctx) => {
|
|
1487
2245
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1505,7 +2263,7 @@ defineTool({
|
|
|
1505
2263
|
'Example: resume_service({ service_id: "svc_dev" }) \u2192 { ok: true }'
|
|
1506
2264
|
].join("\n"),
|
|
1507
2265
|
input: {
|
|
1508
|
-
service_id:
|
|
2266
|
+
service_id: z13.string().describe("Service publicId.")
|
|
1509
2267
|
},
|
|
1510
2268
|
handler: async (args, ctx) => {
|
|
1511
2269
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1526,11 +2284,11 @@ defineTool({
|
|
|
1526
2284
|
" - lines (optional): tail size (default 200, max 1000).",
|
|
1527
2285
|
' - since/until (optional): ISO-8601 timestamp OR a relative offset like "-5m", "-1h", "-2d".',
|
|
1528
2286
|
' - stream (optional): "stdout" | "stderr". Omit to combine.',
|
|
1529
|
-
" - level (optional):
|
|
2287
|
+
" - level (optional): real log level \u2014 trace/debug/info/warn/error/fatal. For structured JSON logs (pino, bunyan, OpenTelemetry severity) this filters on the parsed inner level field. For plain text logs it falls back to a stream-alias hint (info/debug \u2192 stdout, warn/error/fatal \u2192 stderr).",
|
|
1530
2288
|
" - search (optional): case-insensitive substring grep, \u2264100 chars.",
|
|
1531
2289
|
' - count_only (optional): when true, returns { count } only \u2014 much cheaper for "how many error lines in last 5m" polling.',
|
|
1532
2290
|
"",
|
|
1533
|
-
"Returns: { logs: LogEntry[] | string } when count_only is false
|
|
2291
|
+
"Returns: { logs: LogEntry[] | string } when count_only is false. Each entry has { timestamp, level?, stream, message }. `level` is the parsed inner level when the message is a structured JSON envelope (pino numeric or string), and undefined for plain-text logs. `stream` is always one of stdout/stderr. Or { count: number } when count_only is true.",
|
|
1534
2292
|
"",
|
|
1535
2293
|
'Example: get_service_logs({ service_id: "svc_abc", search: "OOM", since: "-15m" }) \u2192 { logs: [...] }.',
|
|
1536
2294
|
"",
|
|
@@ -1539,14 +2297,16 @@ defineTool({
|
|
|
1539
2297
|
' - Just count error lines without fetching them: get_service_logs({ service_id: "svc_abc", level: "error", since: "-5m", count_only: true }) \u2192 { count: 47 }'
|
|
1540
2298
|
].join("\n"),
|
|
1541
2299
|
input: {
|
|
1542
|
-
service_id:
|
|
1543
|
-
lines:
|
|
1544
|
-
since:
|
|
1545
|
-
until:
|
|
1546
|
-
stream:
|
|
1547
|
-
level:
|
|
1548
|
-
|
|
1549
|
-
|
|
2300
|
+
service_id: z13.string().describe("Service publicId."),
|
|
2301
|
+
lines: z13.number().int().positive().max(1e3).optional().describe("Tail size; default 200, hard cap 1000."),
|
|
2302
|
+
since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
2303
|
+
until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
2304
|
+
stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
2305
|
+
level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe(
|
|
2306
|
+
"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)."
|
|
2307
|
+
),
|
|
2308
|
+
search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
2309
|
+
count_only: z13.boolean().optional().describe("When true, return only { count } \u2014 skips the log payload.")
|
|
1550
2310
|
},
|
|
1551
2311
|
handler: async (args, ctx) => {
|
|
1552
2312
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1573,9 +2333,82 @@ defineTool({
|
|
|
1573
2333
|
});
|
|
1574
2334
|
}
|
|
1575
2335
|
});
|
|
2336
|
+
defineTool({
|
|
2337
|
+
name: "get_service_logs_bulk",
|
|
2338
|
+
category: "logs",
|
|
2339
|
+
description: [
|
|
2340
|
+
"Fetch runtime logs for multiple services in parallel and return one response keyed by service_id. Single round-trip from the MCP client perspective.",
|
|
2341
|
+
"",
|
|
2342
|
+
'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.',
|
|
2343
|
+
"",
|
|
2344
|
+
"Inputs:",
|
|
2345
|
+
" - service_ids: list of service publicIds (svc_\u2026). Hard cap 10 to bound work \u2014 for larger fleets, partition the call.",
|
|
2346
|
+
" - lines_per_service (optional): tail size per service (default 100, max 500). Smaller default than get_service_logs since you are fanning out.",
|
|
2347
|
+
' - since/until (optional): same shape as get_service_logs (ISO-8601 or "-5m", "-1h").',
|
|
2348
|
+
' - stream (optional): "stdout" | "stderr".',
|
|
2349
|
+
" - level (optional): structured-log level filter (trace/debug/info/warn/error/fatal).",
|
|
2350
|
+
" - search (optional): case-insensitive substring filter applied to every service.",
|
|
2351
|
+
' - 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.',
|
|
2352
|
+
"",
|
|
2353
|
+
"Returns: { results: { [service_id]: { logs?, count?, error? } } } \u2014 per-service result, with `error` populated when one service failed (the rest still succeed).",
|
|
2354
|
+
"",
|
|
2355
|
+
'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 } } }.'
|
|
2356
|
+
].join("\n"),
|
|
2357
|
+
input: {
|
|
2358
|
+
service_ids: z13.array(z13.string()).min(1).max(10).describe("Service publicIds (1\u201310). Hard cap 10 to bound parallel work."),
|
|
2359
|
+
lines_per_service: z13.number().int().positive().max(500).optional().describe("Tail size per service; default 100, hard cap 500."),
|
|
2360
|
+
since: z13.string().optional().describe('ISO-8601 timestamp or relative offset (e.g. "-5m", "-1h").'),
|
|
2361
|
+
until: z13.string().optional().describe("ISO-8601 timestamp or relative offset upper bound."),
|
|
2362
|
+
stream: z13.enum(["stdout", "stderr"]).optional().describe("Restrict to one stream."),
|
|
2363
|
+
level: z13.enum(["stdout", "stderr", "trace", "debug", "info", "warn", "error", "fatal"]).optional().describe("Structured log level filter (same as get_service_logs)."),
|
|
2364
|
+
search: z13.string().max(100).optional().describe("Case-insensitive substring filter."),
|
|
2365
|
+
count_only: z13.boolean().optional().describe("When true, return only counts per service \u2014 skips the log payload.")
|
|
2366
|
+
},
|
|
2367
|
+
handler: async (args, ctx) => {
|
|
2368
|
+
const teamId = await ctx.resolveTeamId();
|
|
2369
|
+
const linesPerService = args.lines_per_service ?? 100;
|
|
2370
|
+
const baseOpts = { lines: linesPerService };
|
|
2371
|
+
if (args.since) baseOpts.since = args.since;
|
|
2372
|
+
if (args.until) baseOpts.until = args.until;
|
|
2373
|
+
if (args.stream) baseOpts.stream = args.stream;
|
|
2374
|
+
if (args.level) baseOpts.level = args.level;
|
|
2375
|
+
if (args.search) baseOpts.search = args.search;
|
|
2376
|
+
if (args.count_only) baseOpts.countOnly = args.count_only;
|
|
2377
|
+
const settled = await Promise.allSettled(
|
|
2378
|
+
args.service_ids.map(async (sid) => {
|
|
2379
|
+
const response = await ctx.hoststack.services.getRuntimeLogs(teamId, sid, baseOpts);
|
|
2380
|
+
return { sid, response };
|
|
2381
|
+
})
|
|
2382
|
+
);
|
|
2383
|
+
const results = {};
|
|
2384
|
+
let okCount = 0;
|
|
2385
|
+
let errorCount = 0;
|
|
2386
|
+
for (let i = 0; i < settled.length; i += 1) {
|
|
2387
|
+
const outcome = settled[i];
|
|
2388
|
+
const sid = args.service_ids[i];
|
|
2389
|
+
if (outcome.status === "fulfilled") {
|
|
2390
|
+
const { response } = outcome.value;
|
|
2391
|
+
if ("count" in response) {
|
|
2392
|
+
results[sid] = { count: response.count };
|
|
2393
|
+
} else {
|
|
2394
|
+
results[sid] = { logs: response.logs };
|
|
2395
|
+
}
|
|
2396
|
+
okCount += 1;
|
|
2397
|
+
} else {
|
|
2398
|
+
const reason = outcome.reason;
|
|
2399
|
+
results[sid] = {
|
|
2400
|
+
error: typeof reason === "string" ? reason : reason?.message ?? "Unknown error"
|
|
2401
|
+
};
|
|
2402
|
+
errorCount += 1;
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
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).`;
|
|
2406
|
+
return respond({ summary, data: { results } });
|
|
2407
|
+
}
|
|
2408
|
+
});
|
|
1576
2409
|
|
|
1577
2410
|
// src/tools/volumes.ts
|
|
1578
|
-
import { z as
|
|
2411
|
+
import { z as z14 } from "zod";
|
|
1579
2412
|
defineTool({
|
|
1580
2413
|
name: "list_volumes",
|
|
1581
2414
|
category: "volumes",
|
|
@@ -1592,7 +2425,7 @@ defineTool({
|
|
|
1592
2425
|
'Example: list_volumes({ service_id: "svc_abc" }) \u2192 { items: [{ name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" }] }'
|
|
1593
2426
|
].join("\n"),
|
|
1594
2427
|
input: {
|
|
1595
|
-
service_id:
|
|
2428
|
+
service_id: z14.string().describe("Service publicId (e.g. svc_abc123).")
|
|
1596
2429
|
},
|
|
1597
2430
|
handler: async (args, ctx) => {
|
|
1598
2431
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1621,10 +2454,10 @@ defineTool({
|
|
|
1621
2454
|
'Example: create_volume({ service_id: "svc_abc", name: "data", mount_path: "/var/data", size_gb: 10 }) \u2192 { volume: { name: "data", mountPath: "/var/data", sizeGb: 10, status: "active" } }'
|
|
1622
2455
|
].join("\n"),
|
|
1623
2456
|
input: {
|
|
1624
|
-
service_id:
|
|
1625
|
-
name:
|
|
1626
|
-
mount_path:
|
|
1627
|
-
size_gb:
|
|
2457
|
+
service_id: z14.string().describe("Service publicId."),
|
|
2458
|
+
name: z14.string().min(1).max(64).regex(/^[a-z0-9-]+$/).describe("Volume name (lowercase alphanumeric + hyphens)."),
|
|
2459
|
+
mount_path: z14.string().startsWith("/").max(500).describe("In-container mount path (absolute)."),
|
|
2460
|
+
size_gb: z14.number().int().min(1).max(100).optional().describe("Disk size in GB (default 1, max 100).")
|
|
1628
2461
|
},
|
|
1629
2462
|
handler: async (args, ctx) => {
|
|
1630
2463
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1660,10 +2493,10 @@ defineTool({
|
|
|
1660
2493
|
'Example: update_volume({ service_id: "svc_abc", volume_id: "vol_xyz", size_gb: 20 }) \u2192 { volume: { sizeGb: 20, \u2026 } }'
|
|
1661
2494
|
].join("\n"),
|
|
1662
2495
|
input: {
|
|
1663
|
-
service_id:
|
|
1664
|
-
volume_id:
|
|
1665
|
-
mount_path:
|
|
1666
|
-
size_gb:
|
|
2496
|
+
service_id: z14.string().describe("Service publicId."),
|
|
2497
|
+
volume_id: z14.string().describe("Volume publicId (e.g. vol_\u2026)."),
|
|
2498
|
+
mount_path: z14.string().startsWith("/").max(500).optional().describe("New mount path."),
|
|
2499
|
+
size_gb: z14.number().int().min(1).max(100).optional().describe("New size in GB.")
|
|
1667
2500
|
},
|
|
1668
2501
|
handler: async (args, ctx) => {
|
|
1669
2502
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1701,8 +2534,8 @@ defineTool({
|
|
|
1701
2534
|
'Example: delete_volume({ service_id: "svc_abc", volume_id: "vol_xyz" }) \u2192 { ok: true }'
|
|
1702
2535
|
].join("\n"),
|
|
1703
2536
|
input: {
|
|
1704
|
-
service_id:
|
|
1705
|
-
volume_id:
|
|
2537
|
+
service_id: z14.string().describe("Service publicId."),
|
|
2538
|
+
volume_id: z14.string().describe("Volume publicId.")
|
|
1706
2539
|
},
|
|
1707
2540
|
handler: async (args, ctx) => {
|
|
1708
2541
|
const teamId = await ctx.resolveTeamId();
|
|
@@ -1716,7 +2549,7 @@ defineTool({
|
|
|
1716
2549
|
|
|
1717
2550
|
// src/server-factory.ts
|
|
1718
2551
|
var PACKAGE_NAME = "hoststack";
|
|
1719
|
-
var PACKAGE_VERSION = "0.
|
|
2552
|
+
var PACKAGE_VERSION = "0.6.0";
|
|
1720
2553
|
function createMcpServer(options) {
|
|
1721
2554
|
const baseUrl = (options.baseUrl ?? "https://hoststack.dev").replace(/\/$/, "");
|
|
1722
2555
|
const hoststack = new HostStack({ apiKey: options.apiKey, baseUrl });
|