@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +137 -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.16.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,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}`);