@moor-sh/mcp 0.18.0 → 0.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.18.0",
3
+ "version": "0.19.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
@@ -810,22 +810,27 @@ server.registerTool(
810
810
  },
811
811
  );
812
812
 
813
- // #80 PR #4: moor_update_apply — kick off a transient-respawner update of
813
+ // #80: moor_update_apply — kick off a transient-respawner update of
814
814
  // moor itself. The respawner runs async; this tool returns the audit_id
815
- // immediately. Poll moor_update_status (or, eventually, moor_update_audit)
816
- // for the outcome.
815
+ // immediately. Poll moor_update_audit (history of attempts) or
816
+ // moor_update_status (version change) for the outcome.
817
817
  //
818
- // IMPORTANT: PR #4 has no rollback. A failed compose-up / --wait timeout /
819
- // health check writes a `failed` marker and exits; compose state is left
820
- // as Compose left it. Recovery: manual `docker compose up`, OR wait for the
821
- // 30-min stale-in_progress sweep to reclaim the audit row. Automatic
822
- // rollback (retag + `--pull never`) lands in PR #5.
818
+ // Outcomes (encoded in the audit row's `state` field, surfaced by
819
+ // moor_update_audit):
820
+ // - success : new image is healthy.
821
+ // - failed : pull failed before moor was replaced (no rollback).
822
+ // - rolled_back : up/health failed; rollback to prev image succeeded.
823
+ // - rollback_failed : up/health failed AND rollback also failed —
824
+ // operator must investigate (manual recovery).
825
+ // - in_progress : respawner still running.
826
+ // - crashed : 30-min grace elapsed without a marker
827
+ // (respawner died or moor never read the marker).
823
828
  server.registerTool(
824
829
  "moor_update_apply",
825
830
  {
826
831
  title: "Apply moor update (transient respawner)",
827
832
  description:
828
- "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll. PR #4 happy path ONLY no automatic rollback. A failed up/health writes `failed` and leaves the compose state; recovery may require manual `docker compose up`. Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.",
833
+ "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll via moor_update_audit. Outcomes: success | failed (pull failed pre-replacement) | rolled_back (up/health failed, automatic rollback succeeded) | rollback_failed (rollback also failed manual recovery needed) | crashed (no marker after 30-min grace). Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.",
829
834
  inputSchema: z.object({
830
835
  target_digest: z
831
836
  .string()
@@ -850,7 +855,13 @@ server.registerTool(
850
855
  content: [
851
856
  {
852
857
  type: "text",
853
- text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_status to watch the version change. Expected transitions: in_progress → success (clean apply) or failed (compose/health failure; PR #4 has no rollback so the compose state is left where Compose left it). If the new moor never becomes healthy enough to ingest the marker, the audit row may stay in_progress until the 30-min stale sweep marks it crashed — at which point manual recovery (docker compose up) may be required.`,
858
+ text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_audit to watch the outcome, or moor_update_status to watch the version. Possible terminal states:
859
+ - success (new image healthy)
860
+ - failed (pull failed before moor was replaced)
861
+ - rolled_back (up/health failed; automatic rollback succeeded; drain stays on)
862
+ - rollback_failed (up/health failed AND rollback failed; manual recovery)
863
+ - crashed (no marker after 30-min grace; respawner died)
864
+ Recovery: rolled_back means moor is on the previous image again; the failed update is captured in error_log. rollback_failed or crashed mean an operator should investigate (likely manual docker compose up).`,
854
865
  },
855
866
  ],
856
867
  };
@@ -868,6 +879,57 @@ server.registerTool(
868
879
  },
869
880
  );
870
881
 
882
+ // #80 PR #6: moor_update_audit — recent history of moor_update_apply
883
+ // attempts. Read-only diagnostic; the orchestration lives entirely on
884
+ // the moor side. Tail-truncates error_log / rollback_error per-field
885
+ // so a crashed update with a long error doesn't blow up the token
886
+ // budget; opt in to a larger tail when actively debugging.
887
+ //
888
+ // Rendering helpers (shortDigest / fmtDuration / tailLog / renderAuditRow
889
+ // / renderAuditList) live in ./update-audit-render and are unit-tested
890
+ // directly — this tool is a thin shell around them.
891
+ import { MAX_LOG_TAIL_BYTES, renderAuditList, type UpdateAuditApiRow } from "./update-audit-render";
892
+
893
+ server.registerTool(
894
+ "moor_update_audit",
895
+ {
896
+ title: "Update history (audit log)",
897
+ description:
898
+ "Read-only: recent moor_update_apply attempts and their outcomes. Each row shows audit_id, state (success | failed | rolled_back | rollback_failed | in_progress | crashed), duration, digest deltas, backup path, and any error logs. error_log preserves the ORIGINAL apply failure (never overwritten by rollback step details); rollback_error is set only on rollback_failed. Default tail is 4 KiB per log field; pass tail_bytes=0 to omit log bodies entirely (keeps the metadata line and replaces the body with a sized marker), or up to 16384 to read more.",
899
+ inputSchema: z.object({
900
+ limit: z
901
+ .number()
902
+ .int()
903
+ .min(1)
904
+ .max(200)
905
+ .optional()
906
+ .describe("How many most-recent attempts to return. Default 20, max 200."),
907
+ tail_bytes: z
908
+ .number()
909
+ .int()
910
+ .min(0)
911
+ .max(MAX_LOG_TAIL_BYTES)
912
+ .optional()
913
+ .describe(
914
+ "Max bytes of error_log and rollback_error returned inline per row. Default 4096 (4 KiB). 0 to omit log bodies entirely; 16384 max.",
915
+ ),
916
+ }),
917
+ },
918
+ async ({ limit, tail_bytes }) => {
919
+ const qs = new URLSearchParams();
920
+ if (limit !== undefined) qs.set("limit", String(limit));
921
+ const path = qs.toString()
922
+ ? `/api/server/update/audit?${qs.toString()}`
923
+ : "/api/server/update/audit";
924
+ const res = await apiGet(path);
925
+ if (!res.ok) throw new Error(`update audit failed: ${res.status} ${await res.text()}`);
926
+ const { rows } = (await res.json()) as { rows: UpdateAuditApiRow[] };
927
+ return {
928
+ content: [{ type: "text", text: renderAuditList(rows, { tail_bytes }) }],
929
+ };
930
+ },
931
+ );
932
+
871
933
  server.registerTool(
872
934
  "moor_cleanup_plan",
873
935
  {
@@ -0,0 +1,171 @@
1
+ // Tests for the moor_update_audit renderer. Pure functions — no
2
+ // network, no DB. Lives next to its consumer so the formatting
3
+ // operators actually see has direct test coverage.
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import {
7
+ DEFAULT_LOG_TAIL_BYTES,
8
+ fmtDuration,
9
+ MAX_LOG_TAIL_BYTES,
10
+ renderAuditList,
11
+ renderAuditRow,
12
+ shortDigest,
13
+ tailLog,
14
+ type UpdateAuditApiRow,
15
+ } from "./update-audit-render";
16
+
17
+ function row(overrides: Partial<UpdateAuditApiRow> = {}): UpdateAuditApiRow {
18
+ return {
19
+ id: 1,
20
+ state: "success",
21
+ started_at: "2026-05-25 00:00:00",
22
+ duration_ms: 50_000,
23
+ from_digest: `sha256:${"a".repeat(64)}`,
24
+ to_digest: `sha256:${"b".repeat(64)}`,
25
+ prev_image_id: `sha256:${"c".repeat(64)}`,
26
+ backup_path: "/app/data/moor.db.backup-1",
27
+ error_log: null,
28
+ rollback_error: null,
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ describe("shortDigest", () => {
34
+ test("formats sha256:<64 hex> as sha256:<7>…<7>", () => {
35
+ expect(shortDigest(`sha256:${"a".repeat(64)}`)).toBe(
36
+ `sha256:${"a".repeat(7)}…${"a".repeat(7)}`,
37
+ );
38
+ });
39
+ test("strips the repo prefix from repo@sha256:... form", () => {
40
+ expect(shortDigest(`ghcr.io/caiopizzol/moor@sha256:${"b".repeat(64)}`)).toBe(
41
+ `sha256:${"b".repeat(7)}…${"b".repeat(7)}`,
42
+ );
43
+ });
44
+ test("non-digest input passes through unchanged", () => {
45
+ expect(shortDigest("sha256:short")).toBe("sha256:short");
46
+ expect(shortDigest("not-a-digest")).toBe("not-a-digest");
47
+ });
48
+ test("null/undefined → '(none)'", () => {
49
+ expect(shortDigest(null)).toBe("(none)");
50
+ expect(shortDigest(undefined)).toBe("(none)");
51
+ });
52
+ });
53
+
54
+ describe("fmtDuration", () => {
55
+ test("null → em dash", () => expect(fmtDuration(null)).toBe("—"));
56
+ test("sub-second values in ms", () => expect(fmtDuration(420)).toBe("420ms"));
57
+ test("seconds for [1s, 60s)", () => expect(fmtDuration(50_000)).toBe("50s"));
58
+ test("minutes+seconds at and above 60s", () => {
59
+ expect(fmtDuration(60_000)).toBe("1m0s");
60
+ expect(fmtDuration(125_000)).toBe("2m5s");
61
+ });
62
+ });
63
+
64
+ describe("tailLog", () => {
65
+ test("null/undefined → '' (no field rendered by caller)", () => {
66
+ expect(tailLog(null, 100)).toBe("");
67
+ expect(tailLog(undefined, 100)).toBe("");
68
+ });
69
+ test("short string passes through unchanged", () => {
70
+ expect(tailLog("short", 100)).toBe("short");
71
+ });
72
+ test("long string is tail-truncated with a visible marker", () => {
73
+ const big = "x".repeat(10_000);
74
+ const out = tailLog(big, 1000);
75
+ expect(out).toContain("[...9000 earlier bytes truncated]");
76
+ expect(out.endsWith("x".repeat(1000))).toBe(true);
77
+ });
78
+ test("tail_bytes=0 elides the field body entirely with a sized marker", () => {
79
+ const out = tailLog("hello world", 0);
80
+ expect(out).toBe("[...11 bytes elided (tail_bytes=0)]");
81
+ });
82
+ test("tail_bytes=0 on null/undefined → ''", () => {
83
+ expect(tailLog(null, 0)).toBe("");
84
+ expect(tailLog(undefined, 0)).toBe("");
85
+ });
86
+ });
87
+
88
+ describe("renderAuditRow", () => {
89
+ test("success: includes id, state, duration, from/to, backup; no error_log line", () => {
90
+ const out = renderAuditRow(row());
91
+ expect(out).toContain("audit_id=1");
92
+ expect(out).toContain("state=success");
93
+ expect(out).toContain("duration=50s");
94
+ expect(out).toContain("from: sha256:aaaaaaa");
95
+ expect(out).toContain("to: sha256:bbbbbbb");
96
+ expect(out).toContain("prev_image_id: sha256:ccccccc");
97
+ expect(out).toContain("backup: /app/data/moor.db.backup-1");
98
+ expect(out).not.toContain("error_log");
99
+ expect(out).not.toContain("rollback_error");
100
+ });
101
+
102
+ test("rolled_back: error_log shown, rollback_error omitted", () => {
103
+ const out = renderAuditRow(row({ state: "rolled_back", error_log: "health check failed" }));
104
+ expect(out).toContain("state=rolled_back");
105
+ expect(out).toContain("error_log: health check failed");
106
+ expect(out).not.toContain("rollback_error");
107
+ });
108
+
109
+ test("rollback_failed: BOTH error_log and rollback_error rendered", () => {
110
+ const out = renderAuditRow(
111
+ row({
112
+ state: "rollback_failed",
113
+ error_log: "apply up failed",
114
+ rollback_error: "rollback tag failed",
115
+ }),
116
+ );
117
+ expect(out).toContain("state=rollback_failed");
118
+ expect(out).toContain("error_log: apply up failed");
119
+ expect(out).toContain("rollback_error: rollback tag failed");
120
+ });
121
+
122
+ test("tail_bytes truncates long error_log per-field with marker", () => {
123
+ const long = "X".repeat(5000);
124
+ const out = renderAuditRow(row({ error_log: long }), { tail_bytes: 100 });
125
+ expect(out).toContain("[...4900 earlier bytes truncated]");
126
+ });
127
+
128
+ test("tail_bytes=0 elides log body but keeps the field line", () => {
129
+ const out = renderAuditRow(row({ error_log: "anything" }), { tail_bytes: 0 });
130
+ expect(out).toContain("error_log: [...");
131
+ expect(out).toContain("bytes elided (tail_bytes=0)");
132
+ expect(out).not.toContain("anything");
133
+ });
134
+
135
+ test("tail_bytes is clamped to MAX_LOG_TAIL_BYTES", () => {
136
+ // No assertion against the actual byte count; just verify the
137
+ // constant is honored and the function tolerates an over-cap input.
138
+ expect(MAX_LOG_TAIL_BYTES).toBeGreaterThan(DEFAULT_LOG_TAIL_BYTES);
139
+ const long = "Y".repeat(MAX_LOG_TAIL_BYTES + 1000);
140
+ const out = renderAuditRow(row({ error_log: long }), { tail_bytes: 999_999 });
141
+ expect(out).toContain("earlier bytes truncated");
142
+ });
143
+
144
+ test("prev_image_id null → no prev line", () => {
145
+ const out = renderAuditRow(row({ prev_image_id: null }));
146
+ expect(out).not.toContain("prev_image_id");
147
+ });
148
+
149
+ test("backup_path null → no backup line", () => {
150
+ const out = renderAuditRow(row({ backup_path: null }));
151
+ expect(out).not.toContain("backup:");
152
+ });
153
+
154
+ test("non-digest from/to (rare edge) passes through via shortDigest fallback", () => {
155
+ const out = renderAuditRow(row({ from_digest: "weird", to_digest: null }));
156
+ expect(out).toContain("from: weird");
157
+ expect(out).toContain("to: (none)");
158
+ });
159
+ });
160
+
161
+ describe("renderAuditList", () => {
162
+ test("empty → friendly hint", () => {
163
+ expect(renderAuditList([])).toContain("no update attempts recorded yet");
164
+ });
165
+ test("multi-row separated by blank line", () => {
166
+ const out = renderAuditList([row({ id: 2 }), row({ id: 1 })]);
167
+ expect(out.split("\n\n").length).toBe(2);
168
+ expect(out).toContain("audit_id=2");
169
+ expect(out).toContain("audit_id=1");
170
+ });
171
+ });
@@ -0,0 +1,94 @@
1
+ // #80 PR #6: pure renderers for moor_update_audit. Lives next to its
2
+ // consumer (the MCP tool) so the formatting that operators actually
3
+ // see has direct test coverage.
4
+ //
5
+ // Each row renders as a multi-line block with id, state, duration,
6
+ // digest deltas, prev_image_id, backup path, and per-field tailed
7
+ // error_log / rollback_error.
8
+
9
+ /** API row shape returned by GET /api/server/update/audit. Subset of
10
+ * the full DB row — only what the renderer needs. */
11
+ export type UpdateAuditApiRow = {
12
+ id: number;
13
+ state: string;
14
+ started_at: string;
15
+ duration_ms: number | null;
16
+ from_digest: string | null;
17
+ to_digest: string | null;
18
+ prev_image_id: string | null;
19
+ backup_path: string | null;
20
+ error_log: string | null;
21
+ rollback_error: string | null;
22
+ };
23
+
24
+ export const DEFAULT_LOG_TAIL_BYTES = 4096;
25
+ export const MAX_LOG_TAIL_BYTES = 16384;
26
+
27
+ /** Pure: short-form a sha256 digest for compact rendering (first 7 +
28
+ * last 7). Accepts both raw `sha256:<hex>` and `repo@sha256:<hex>`
29
+ * forms; returns the input unchanged on anything else. */
30
+ export function shortDigest(s: string | null | undefined): string {
31
+ if (!s) return "(none)";
32
+ const m = s.match(/^(?:[^@]+@)?sha256:([0-9a-f]{64})$/);
33
+ if (!m) return s;
34
+ const hex = m[1];
35
+ return `sha256:${hex.slice(0, 7)}…${hex.slice(-7)}`;
36
+ }
37
+
38
+ /** Pure: human-readable duration. Mirrors the format moor_runs uses. */
39
+ export function fmtDuration(ms: number | null): string {
40
+ if (ms == null) return "—";
41
+ if (ms < 1000) return `${ms}ms`;
42
+ const s = Math.round(ms / 1000);
43
+ if (s < 60) return `${s}s`;
44
+ const m = Math.floor(s / 60);
45
+ const rem = s % 60;
46
+ return `${m}m${rem}s`;
47
+ }
48
+
49
+ /** Pure: tail-truncate a string to maxBytes with a visible marker.
50
+ * maxBytes=0 means omit the field body entirely (returns the marker
51
+ * alone). Used per-field for error_log + rollback_error so a
52
+ * crashed update with a long log doesn't blow up the token budget. */
53
+ export function tailLog(s: string | null | undefined, maxBytes: number): string {
54
+ if (s == null) return "";
55
+ if (maxBytes <= 0) {
56
+ const enc = new TextEncoder();
57
+ return `[...${enc.encode(s).length} bytes elided (tail_bytes=0)]`;
58
+ }
59
+ const enc = new TextEncoder();
60
+ const bytes = enc.encode(s);
61
+ if (bytes.length <= maxBytes) return s;
62
+ const dropped = bytes.length - maxBytes;
63
+ const tail = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(dropped));
64
+ return `[...${dropped} earlier bytes truncated]\n${tail}`;
65
+ }
66
+
67
+ /** Pure: render one audit row. tail_bytes applies separately to
68
+ * error_log and rollback_error; absent fields are skipped. */
69
+ export function renderAuditRow(row: UpdateAuditApiRow, opts: { tail_bytes?: number } = {}): string {
70
+ const tail = Math.min(opts.tail_bytes ?? DEFAULT_LOG_TAIL_BYTES, MAX_LOG_TAIL_BYTES);
71
+ const lines: string[] = [
72
+ `audit_id=${row.id} state=${row.state} duration=${fmtDuration(row.duration_ms)} started=${row.started_at}`,
73
+ ` from: ${shortDigest(row.from_digest)} → to: ${shortDigest(row.to_digest)}`,
74
+ ];
75
+ if (row.prev_image_id) lines.push(` prev_image_id: ${shortDigest(row.prev_image_id)}`);
76
+ if (row.backup_path) lines.push(` backup: ${row.backup_path}`);
77
+ if (row.error_log) lines.push(` error_log: ${tailLog(row.error_log, tail)}`);
78
+ if (row.rollback_error) {
79
+ lines.push(` rollback_error: ${tailLog(row.rollback_error, tail)}`);
80
+ }
81
+ return lines.join("\n");
82
+ }
83
+
84
+ /** Pure: render a list of rows separated by a blank line. Empty list
85
+ * returns the documented "no updates yet" string. */
86
+ export function renderAuditList(
87
+ rows: UpdateAuditApiRow[],
88
+ opts: { tail_bytes?: number } = {},
89
+ ): string {
90
+ if (rows.length === 0) {
91
+ return "no update attempts recorded yet. Run moor_update_apply to start one.";
92
+ }
93
+ return rows.map((r) => renderAuditRow(r, opts)).join("\n\n");
94
+ }