@moor-sh/mcp 0.15.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 +137 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -671,6 +671,117 @@ server.registerTool(
|
|
|
671
671
|
},
|
|
672
672
|
);
|
|
673
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
|
+
|
|
674
785
|
server.registerTool(
|
|
675
786
|
"moor_cleanup_plan",
|
|
676
787
|
{
|
|
@@ -1641,6 +1752,32 @@ server.registerTool(
|
|
|
1641
1752
|
throw new Error("Cannot set both github_url and docker_image");
|
|
1642
1753
|
}
|
|
1643
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
|
+
|
|
1644
1781
|
// Resolve existence and check domain conflicts from a single project list.
|
|
1645
1782
|
const listRes = await apiGet("/api/projects");
|
|
1646
1783
|
if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`);
|