@moor-sh/mcp 0.14.0 → 0.16.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/package.json +1 -1
- package/src/index.ts +210 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -598,6 +598,190 @@ server.registerTool(
|
|
|
598
598
|
},
|
|
599
599
|
);
|
|
600
600
|
|
|
601
|
+
server.registerTool(
|
|
602
|
+
"moor_update_status",
|
|
603
|
+
{
|
|
604
|
+
title: "Update status / preflight",
|
|
605
|
+
description:
|
|
606
|
+
"Report moor's current version + image digest, the latest available digest on GHCR, active in-flight work counts, DB backup recency, and a safe_to_update boolean. update_available is null (not false) when either the local repo_digest or the registry digest is unknown — never lies by comparing across identifier spaces. unsafe_reasons is a human-readable array; render inline rather than re-deriving from booleans. Read-only diagnostic — does NOT perform any update.",
|
|
607
|
+
},
|
|
608
|
+
async () => {
|
|
609
|
+
const res = await apiGet("/api/server/update-status");
|
|
610
|
+
if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
|
|
611
|
+
const s = (await res.json()) as {
|
|
612
|
+
current: {
|
|
613
|
+
version: string;
|
|
614
|
+
image_id: string | null;
|
|
615
|
+
repo_digest: string | null;
|
|
616
|
+
started_at: string;
|
|
617
|
+
};
|
|
618
|
+
available: {
|
|
619
|
+
latest_tag: string;
|
|
620
|
+
latest_digest: string | null;
|
|
621
|
+
update_available: boolean | null;
|
|
622
|
+
registry_error: string | null;
|
|
623
|
+
};
|
|
624
|
+
active_work: {
|
|
625
|
+
builds_in_flight: number;
|
|
626
|
+
execs_in_flight: number;
|
|
627
|
+
crons_in_flight: number;
|
|
628
|
+
terminals_open: number;
|
|
629
|
+
};
|
|
630
|
+
db_backup: {
|
|
631
|
+
last_backup_at: string | null;
|
|
632
|
+
age_seconds: number | null;
|
|
633
|
+
location: string | null;
|
|
634
|
+
};
|
|
635
|
+
safe_to_update: boolean;
|
|
636
|
+
unsafe_reasons: string[];
|
|
637
|
+
recommended_command: string;
|
|
638
|
+
};
|
|
639
|
+
const lines: string[] = [];
|
|
640
|
+
lines.push(`moor ${s.current.version} (image_id: ${s.current.image_id ?? "unknown"})`);
|
|
641
|
+
lines.push(
|
|
642
|
+
`repo_digest: ${s.current.repo_digest ?? "(none — locally built or stale inspect)"}`,
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
if (s.available.update_available === true) {
|
|
646
|
+
lines.push(`update AVAILABLE → latest: ${s.available.latest_digest}`);
|
|
647
|
+
} else if (s.available.update_available === false) {
|
|
648
|
+
lines.push(`up to date (latest: ${s.available.latest_digest})`);
|
|
649
|
+
} else {
|
|
650
|
+
// null — explain WHICH side is unknown.
|
|
651
|
+
const why = s.available.registry_error
|
|
652
|
+
? `registry unreachable: ${s.available.registry_error}`
|
|
653
|
+
: s.current.repo_digest === null
|
|
654
|
+
? "no local repo_digest (built locally?)"
|
|
655
|
+
: "comparison unavailable";
|
|
656
|
+
lines.push(`update availability unknown — ${why}`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
lines.push(
|
|
660
|
+
`active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`,
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
if (s.safe_to_update) {
|
|
664
|
+
lines.push("safe_to_update: YES");
|
|
665
|
+
} else {
|
|
666
|
+
lines.push("safe_to_update: NO");
|
|
667
|
+
for (const r of s.unsafe_reasons) lines.push(` - ${r}`);
|
|
668
|
+
}
|
|
669
|
+
lines.push(`recommended: ${s.recommended_command}`);
|
|
670
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
// #79: drain mode. Operator-facing primitive that gates new work-against-
|
|
675
|
+
// container actions (deploys, builds, async/sync execs, manual cron
|
|
676
|
+
// triggers, terminal upgrades) so an upgrade can wait for in-flight work
|
|
677
|
+
// to complete cleanly. Drain refuses NEW work; it never kills in-flight
|
|
678
|
+
// work. The TTL is load-bearing — every refusal carries expires_at and
|
|
679
|
+
// the row auto-clears at expiry so a forgotten drain doesn't lock moor
|
|
680
|
+
// forever.
|
|
681
|
+
|
|
682
|
+
type DrainStateResponse = {
|
|
683
|
+
state: {
|
|
684
|
+
enabled: boolean;
|
|
685
|
+
reason: string | null;
|
|
686
|
+
started_at: string | null;
|
|
687
|
+
expires_at: string | null;
|
|
688
|
+
clear_after_version: string | null;
|
|
689
|
+
};
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
type DrainStatusResponse = DrainStateResponse & {
|
|
693
|
+
active_work: {
|
|
694
|
+
builds_in_flight: number;
|
|
695
|
+
execs_in_flight: number;
|
|
696
|
+
crons_in_flight: number;
|
|
697
|
+
terminals_open: number;
|
|
698
|
+
};
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
function renderDrainState(s: DrainStateResponse["state"]): string[] {
|
|
702
|
+
if (!s.enabled) return ["drain: OFF"];
|
|
703
|
+
const lines = [`drain: ON (reason: ${s.reason ?? "(none)"})`];
|
|
704
|
+
if (s.started_at) lines.push(` started_at: ${s.started_at}`);
|
|
705
|
+
if (s.expires_at) lines.push(` expires_at: ${s.expires_at} (auto-clear)`);
|
|
706
|
+
if (s.clear_after_version) {
|
|
707
|
+
lines.push(
|
|
708
|
+
` clear_after_version: ${s.clear_after_version} (auto-clear on matching boot version)`,
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
return lines;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
server.registerTool(
|
|
715
|
+
"moor_drain_status",
|
|
716
|
+
{
|
|
717
|
+
title: "Drain Status",
|
|
718
|
+
description:
|
|
719
|
+
"Read-only: current drain state (enabled, reason, expires_at, clear_after_version) plus counts of active work the operator should wait on before an update. active_work uses the same counter as moor_update_status so the two never disagree.",
|
|
720
|
+
},
|
|
721
|
+
async () => {
|
|
722
|
+
const res = await apiGet("/api/server/drain");
|
|
723
|
+
if (!res.ok) throw new Error(`drain status failed: ${res.status} ${await res.text()}`);
|
|
724
|
+
const s = (await res.json()) as DrainStatusResponse;
|
|
725
|
+
const lines = renderDrainState(s.state);
|
|
726
|
+
lines.push(
|
|
727
|
+
`active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`,
|
|
728
|
+
);
|
|
729
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
server.registerTool(
|
|
734
|
+
"moor_drain_enable",
|
|
735
|
+
{
|
|
736
|
+
title: "Enable Drain Mode",
|
|
737
|
+
description:
|
|
738
|
+
"Refuse new builds, deploys, execs, manual cron runs, and terminal upgrades with a 503 carrying { reason, expires_at, hint }. Existing in-flight work runs to completion — drain does NOT kill anything. Scheduled cron ticks during drain write a synthetic 'skipped due to drain' run row instead of executing. Read-only routes (status, logs, runs) keep working. Default TTL is 30 minutes; set ttl_minutes to override. clear_after_version is the updater's hook — when set, the drain auto-clears on boot if the running moor version matches.",
|
|
739
|
+
inputSchema: z.object({
|
|
740
|
+
reason: z
|
|
741
|
+
.string()
|
|
742
|
+
.optional()
|
|
743
|
+
.describe(
|
|
744
|
+
"Freeform reason shown in every refusal response (e.g. 'preparing for 0.34 upgrade').",
|
|
745
|
+
),
|
|
746
|
+
ttl_minutes: z
|
|
747
|
+
.number()
|
|
748
|
+
.optional()
|
|
749
|
+
.describe("Auto-clear after this many minutes. Default 30. Clamped to [0.05 min, 7 days]."),
|
|
750
|
+
clear_after_version: z
|
|
751
|
+
.string()
|
|
752
|
+
.optional()
|
|
753
|
+
.describe(
|
|
754
|
+
"Optional: on next boot, if the running moor version equals this value, auto-clear the drain. Typically set by the updater path; safe for manual use too.",
|
|
755
|
+
),
|
|
756
|
+
}),
|
|
757
|
+
},
|
|
758
|
+
async ({ reason, ttl_minutes, clear_after_version }) => {
|
|
759
|
+
const res = await apiPost("/api/server/drain/enable", {
|
|
760
|
+
reason,
|
|
761
|
+
ttl_minutes,
|
|
762
|
+
clear_after_version,
|
|
763
|
+
});
|
|
764
|
+
if (!res.ok) throw new Error(`drain enable failed: ${res.status} ${await res.text()}`);
|
|
765
|
+
const s = (await res.json()) as DrainStateResponse;
|
|
766
|
+
return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] };
|
|
767
|
+
},
|
|
768
|
+
);
|
|
769
|
+
|
|
770
|
+
server.registerTool(
|
|
771
|
+
"moor_drain_disable",
|
|
772
|
+
{
|
|
773
|
+
title: "Disable Drain Mode",
|
|
774
|
+
description:
|
|
775
|
+
"Explicit operator action to clear drain immediately. Does not kill or restart anything — just removes the gate so new builds/deploys/execs/cron triggers/terminal upgrades succeed again.",
|
|
776
|
+
},
|
|
777
|
+
async () => {
|
|
778
|
+
const res = await apiPost("/api/server/drain/disable", {});
|
|
779
|
+
if (!res.ok) throw new Error(`drain disable failed: ${res.status} ${await res.text()}`);
|
|
780
|
+
const s = (await res.json()) as DrainStateResponse;
|
|
781
|
+
return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] };
|
|
782
|
+
},
|
|
783
|
+
);
|
|
784
|
+
|
|
601
785
|
server.registerTool(
|
|
602
786
|
"moor_cleanup_plan",
|
|
603
787
|
{
|
|
@@ -1568,6 +1752,32 @@ server.registerTool(
|
|
|
1568
1752
|
throw new Error("Cannot set both github_url and docker_image");
|
|
1569
1753
|
}
|
|
1570
1754
|
|
|
1755
|
+
// #79: drain-mode preflight. moor_deploy is a composition: by the
|
|
1756
|
+
// time the run step (Step 3) hits the drain 503 from /api/projects/
|
|
1757
|
+
// :id/run, the create/update/volume/env side effects have already
|
|
1758
|
+
// landed. Check drain server-side BEFORE any writes so a drained
|
|
1759
|
+
// deploy fails cleanly without leaving partial state.
|
|
1760
|
+
//
|
|
1761
|
+
// Skipped when run: false because the no-run mode is metadata-only —
|
|
1762
|
+
// no container work, so drain doesn't apply.
|
|
1763
|
+
if (input.run !== false) {
|
|
1764
|
+
const drainRes = await apiGet("/api/server/drain");
|
|
1765
|
+
if (drainRes.ok) {
|
|
1766
|
+
const { state } = (await drainRes.json()) as {
|
|
1767
|
+
state: { enabled: boolean; reason: string | null; expires_at: string | null };
|
|
1768
|
+
};
|
|
1769
|
+
if (state.enabled) {
|
|
1770
|
+
throw new Error(
|
|
1771
|
+
`[drain] moor is draining (reason: ${state.reason ?? "(none)"}; expires_at: ${state.expires_at}). Refusing deploy before any project create/update side effects. Use moor_drain_disable to re-enable, or retry after expiry. Pass run: false if you only need metadata changes.`,
|
|
1772
|
+
);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
// If the drain endpoint is unreachable (older moor or transient
|
|
1776
|
+
// failure), don't block the deploy — the per-route gate inside
|
|
1777
|
+
// /api/projects/:id/run will still catch it before container work
|
|
1778
|
+
// starts. Preflight is an optimization, not the guarantee.
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1571
1781
|
// Resolve existence and check domain conflicts from a single project list.
|
|
1572
1782
|
const listRes = await apiGet("/api/projects");
|
|
1573
1783
|
if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`);
|