@moor-sh/mcp 0.15.0 → 0.17.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 +165 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -671,6 +671,145 @@ 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
|
+
|
|
785
|
+
// #90: operator-initiated DB snapshot. Uses VACUUM INTO on the server so
|
|
786
|
+
// hot WAL state is captured safely (cp would copy a corrupt-looking file).
|
|
787
|
+
// Backups land next to moor.db; retention prunes to the N most recent.
|
|
788
|
+
// Pair with MOOR_DB_BACKUP_INTERVAL_HOURS for scheduled snapshots — this
|
|
789
|
+
// tool is for taking one right before a manual update.
|
|
790
|
+
server.registerTool(
|
|
791
|
+
"moor_db_backup",
|
|
792
|
+
{
|
|
793
|
+
title: "DB Backup (snapshot)",
|
|
794
|
+
description:
|
|
795
|
+
"Take a SQLite snapshot of moor.db via VACUUM INTO. The file lands next to the main DB as moor.db.backup-<epoch-ms>. Retention is enforced after each snapshot (keeps the 7 most recent by default; older ones are pruned). After this returns, moor_update_status' db_backup.age_seconds will read close to 0. Use before a manual `docker compose pull moor && up -d` if you don't have MOOR_DB_BACKUP_INTERVAL_HOURS scheduled.",
|
|
796
|
+
},
|
|
797
|
+
async () => {
|
|
798
|
+
const res = await apiPost("/api/server/backup", {});
|
|
799
|
+
if (!res.ok) throw new Error(`db backup failed: ${res.status} ${await res.text()}`);
|
|
800
|
+
const r = (await res.json()) as { path: string; sizeBytes: number; durationMs: number };
|
|
801
|
+
const mb = (r.sizeBytes / (1024 * 1024)).toFixed(2);
|
|
802
|
+
return {
|
|
803
|
+
content: [
|
|
804
|
+
{
|
|
805
|
+
type: "text",
|
|
806
|
+
text: `Snapshot written: ${r.path}\nsize: ${r.sizeBytes}B (${mb} MB)\nduration: ${r.durationMs}ms`,
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
};
|
|
810
|
+
},
|
|
811
|
+
);
|
|
812
|
+
|
|
674
813
|
server.registerTool(
|
|
675
814
|
"moor_cleanup_plan",
|
|
676
815
|
{
|
|
@@ -1641,6 +1780,32 @@ server.registerTool(
|
|
|
1641
1780
|
throw new Error("Cannot set both github_url and docker_image");
|
|
1642
1781
|
}
|
|
1643
1782
|
|
|
1783
|
+
// #79: drain-mode preflight. moor_deploy is a composition: by the
|
|
1784
|
+
// time the run step (Step 3) hits the drain 503 from /api/projects/
|
|
1785
|
+
// :id/run, the create/update/volume/env side effects have already
|
|
1786
|
+
// landed. Check drain server-side BEFORE any writes so a drained
|
|
1787
|
+
// deploy fails cleanly without leaving partial state.
|
|
1788
|
+
//
|
|
1789
|
+
// Skipped when run: false because the no-run mode is metadata-only —
|
|
1790
|
+
// no container work, so drain doesn't apply.
|
|
1791
|
+
if (input.run !== false) {
|
|
1792
|
+
const drainRes = await apiGet("/api/server/drain");
|
|
1793
|
+
if (drainRes.ok) {
|
|
1794
|
+
const { state } = (await drainRes.json()) as {
|
|
1795
|
+
state: { enabled: boolean; reason: string | null; expires_at: string | null };
|
|
1796
|
+
};
|
|
1797
|
+
if (state.enabled) {
|
|
1798
|
+
throw new Error(
|
|
1799
|
+
`[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.`,
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
// If the drain endpoint is unreachable (older moor or transient
|
|
1804
|
+
// failure), don't block the deploy — the per-route gate inside
|
|
1805
|
+
// /api/projects/:id/run will still catch it before container work
|
|
1806
|
+
// starts. Preflight is an optimization, not the guarantee.
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1644
1809
|
// Resolve existence and check domain conflicts from a single project list.
|
|
1645
1810
|
const listRes = await apiGet("/api/projects");
|
|
1646
1811
|
if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`);
|