@moor-sh/mcp 0.9.0 → 0.11.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 +153 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.9.0",
3
+ "version": "0.11.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
@@ -548,6 +548,159 @@ server.registerTool(
548
548
  },
549
549
  );
550
550
 
551
+ server.registerTool(
552
+ "moor_cleanup_plan",
553
+ {
554
+ title: "Cleanup Plan (dry-run)",
555
+ description:
556
+ "Dry-run: list Docker resources that are safe to delete on this host. v1 covers build cache (host-wide prune) and dangling images (per-ID). Returns candidates with reclaimable bytes. Pass the same candidate list to moor_cleanup_execute to actually delete. No state is kept between plan and execute — execute re-validates eligibility against current Docker state.",
557
+ inputSchema: z.object({
558
+ scope: z
559
+ .array(z.enum(["build_cache", "dangling_image"]))
560
+ .optional()
561
+ .describe("Subset of categories to plan. Defaults to all v1 categories."),
562
+ }),
563
+ },
564
+ async ({ scope }) => {
565
+ const res = await apiPost("/api/server/cleanup/plan", { scope });
566
+ if (!res.ok) throw new Error(`plan failed: ${res.status} ${await res.text()}`);
567
+ const data = (await res.json()) as {
568
+ candidates: Array<
569
+ | { category: "build_cache"; reclaimable_bytes: number; label: string }
570
+ | {
571
+ category: "dangling_image";
572
+ id: string;
573
+ reclaimable_bytes: number;
574
+ repo_tags: string[];
575
+ label: string;
576
+ }
577
+ >;
578
+ total_reclaimable_bytes: number;
579
+ };
580
+ if (data.candidates.length === 0) {
581
+ return { content: [{ type: "text", text: "Nothing to clean up." }] };
582
+ }
583
+ const lines = [
584
+ `${data.candidates.length} candidate(s), total reclaimable: ${formatBytes(data.total_reclaimable_bytes)}.`,
585
+ "Pass the candidates_json block below back to moor_cleanup_execute to delete.",
586
+ "",
587
+ ];
588
+ for (const c of data.candidates) {
589
+ if (c.category === "build_cache") {
590
+ lines.push(
591
+ `build_cache [${c.label}] — ${formatBytes(c.reclaimable_bytes)} reclaimable (host-wide prune)`,
592
+ );
593
+ } else {
594
+ const tags = c.repo_tags.length > 0 ? ` tags=${c.repo_tags.join(",")}` : "";
595
+ lines.push(
596
+ `dangling_image [${c.label}] id=${c.id} ${formatBytes(c.reclaimable_bytes)}${tags}`,
597
+ );
598
+ }
599
+ }
600
+ // Emit candidates_json so the agent doesn't have to reconstruct identifiers
601
+ // from the prose lines above. The execute side ignores extra fields, so
602
+ // passing the whole candidate objects (label, reclaimable_bytes, etc.) is
603
+ // safe — server re-validates eligibility and computes actual freed bytes.
604
+ lines.push("", "candidates_json:", JSON.stringify(data.candidates, null, 2));
605
+ return { content: [{ type: "text", text: lines.join("\n") }] };
606
+ },
607
+ );
608
+
609
+ server.registerTool(
610
+ "moor_cleanup_execute",
611
+ {
612
+ title: "Cleanup Execute",
613
+ description:
614
+ "Delete the candidates returned by moor_cleanup_plan. Server uses only the identifying fields (category + id where applicable) and re-validates eligibility against current Docker state immediately before each delete — Docker state can change between plan and execute. Reclaimable byte estimates from plan are ignored; the server reports the actual freed bytes. Every execute writes an audit row.",
615
+ inputSchema: z.object({
616
+ candidates: z
617
+ .array(
618
+ z.union([
619
+ z.object({ category: z.literal("build_cache") }).passthrough(),
620
+ z
621
+ .object({ category: z.literal("dangling_image"), id: z.string().min(1) })
622
+ .passthrough(),
623
+ ]),
624
+ )
625
+ .min(1)
626
+ .describe("Candidates from moor_cleanup_plan. Extra fields are ignored server-side."),
627
+ }),
628
+ },
629
+ async ({ candidates }) => {
630
+ const res = await apiPost("/api/server/cleanup/execute", { candidates });
631
+ if (!res.ok) throw new Error(`execute failed: ${res.status} ${await res.text()}`);
632
+ const data = (await res.json()) as {
633
+ audit_id: number;
634
+ total_reclaimed_bytes: number;
635
+ results: Array<
636
+ | { category: "build_cache"; reclaimed_bytes: number; error: string | null }
637
+ | {
638
+ category: "dangling_image";
639
+ id: string;
640
+ reclaimed_bytes: number;
641
+ error: string | null;
642
+ }
643
+ >;
644
+ };
645
+ const lines = [
646
+ `audit_id=${data.audit_id} total_reclaimed=${formatBytes(data.total_reclaimed_bytes)}`,
647
+ "",
648
+ ];
649
+ for (const r of data.results) {
650
+ const status = r.error ? `ERROR: ${r.error}` : "ok";
651
+ if (r.category === "build_cache") {
652
+ lines.push(`build_cache: reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`);
653
+ } else {
654
+ lines.push(
655
+ `dangling_image id=${r.id} reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`,
656
+ );
657
+ }
658
+ }
659
+ return { content: [{ type: "text", text: lines.join("\n") }] };
660
+ },
661
+ );
662
+
663
+ server.registerTool(
664
+ "moor_project_stats",
665
+ {
666
+ title: "Project Container Stats (live)",
667
+ description:
668
+ "Live container stats for one project: CPU percent, memory (excluding page cache, same accounting as `docker stats`), network and block I/O totals, PID count. Single Docker stats snapshot — CPU uses the cpu_stats/precpu_stats delta the daemon already includes. Stopped or never-started projects return running=false with zeroed counters (no 404).",
669
+ inputSchema: z.object({
670
+ project: z.string().describe("Project name or ID"),
671
+ }),
672
+ },
673
+ async ({ project }) => {
674
+ const p = await resolveProject(project);
675
+ const res = await apiGet(`/api/projects/${p.id}/container-stats`);
676
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
677
+ const s = (await res.json()) as {
678
+ running: boolean;
679
+ cpu_percent: number;
680
+ memory_bytes: number;
681
+ memory_limit_bytes: number;
682
+ memory_percent: number;
683
+ network_rx_bytes: number;
684
+ network_tx_bytes: number;
685
+ block_read_bytes: number;
686
+ block_write_bytes: number;
687
+ pids: number;
688
+ };
689
+ if (!s.running) {
690
+ return {
691
+ content: [{ type: "text", text: `${p.name}: not running (zeroed counters returned).` }],
692
+ };
693
+ }
694
+ const memLimit = s.memory_limit_bytes > 0 ? formatBytes(s.memory_limit_bytes) : "unlimited";
695
+ const lines = [
696
+ `${p.name}: CPU ${s.cpu_percent}% | Memory ${formatBytes(s.memory_bytes)} / ${memLimit} (${s.memory_percent}%) | PIDs ${s.pids}`,
697
+ `Network: rx ${formatBytes(s.network_rx_bytes)} / tx ${formatBytes(s.network_tx_bytes)}`,
698
+ `Block I/O: read ${formatBytes(s.block_read_bytes)} / write ${formatBytes(s.block_write_bytes)}`,
699
+ ];
700
+ return { content: [{ type: "text", text: lines.join("\n") }] };
701
+ },
702
+ );
703
+
551
704
  function formatBytes(bytes: number): string {
552
705
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
553
706
  const units = ["B", "KB", "MB", "GB", "TB"];