@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +165 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "description": "MCP server for moor - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects via the moor HTTP API.",
5
5
  "license": "MIT",
6
6
  "repository": {
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}`);