@moor-sh/mcp 0.8.0 → 0.10.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 +156 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.8.0",
3
+ "version": "0.10.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
@@ -491,7 +491,8 @@ server.registerTool(
491
491
  "moor_stats",
492
492
  {
493
493
  title: "Server Stats",
494
- description: "Get server resource usage: CPU, memory, disk, and container counts.",
494
+ description:
495
+ "Get server resource usage: load, memory, root disk, Docker disk by category (images/containers/volumes/build cache) with reclaimable bytes, and container counts. Note: cpu.percent is load-derived (load avg ÷ cores), not instantaneous CPU; use the `load` field for the same signal with explicit naming.",
495
496
  },
496
497
  async () => {
497
498
  const res = await apiGet("/api/server/stats");
@@ -501,23 +502,172 @@ server.registerTool(
501
502
  os: string;
502
503
  uptime: string;
503
504
  cpu: { percent: number; cores: number };
505
+ load?: { one_min: number; cores: number; normalized_percent: number };
504
506
  memory: { total: string; used: string; percent: number };
505
507
  disk: { total: string; used: string; percent: number };
506
508
  containers: { running: number; total: number };
509
+ docker?: {
510
+ images: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number };
511
+ containers: {
512
+ bytes: number;
513
+ reclaimable_bytes: number;
514
+ count: number;
515
+ stopped_count: number;
516
+ };
517
+ volumes: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number };
518
+ build_cache: { bytes: number; reclaimable_bytes: number; count: number };
519
+ } | null;
507
520
  };
508
- const text = [
521
+ const lines = [
509
522
  `Host: ${s.hostname}`,
510
523
  `OS: ${s.os}`,
511
524
  `Uptime: ${s.uptime}`,
512
- `CPU: ${s.cpu.percent}% (${s.cpu.cores} cores)`,
525
+ `CPU: ${s.cpu.percent}% (${s.cpu.cores} cores) — load-derived, not instantaneous`,
526
+ ];
527
+ if (s.load) {
528
+ lines.push(
529
+ `Load (1m): ${s.load.one_min.toFixed(2)} on ${s.load.cores} cores (${s.load.normalized_percent}%)`,
530
+ );
531
+ }
532
+ lines.push(
513
533
  `Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`,
514
- `Disk: ${s.disk.used} / ${s.disk.total} (${s.disk.percent}%)`,
534
+ `Disk (root /): ${s.disk.used} / ${s.disk.total} (${s.disk.percent}%)`,
515
535
  `Containers: ${s.containers.running} running / ${s.containers.total} total`,
516
- ].join("\n");
517
- return { content: [{ type: "text", text }] };
536
+ );
537
+ if (s.docker) {
538
+ const d = s.docker;
539
+ lines.push(
540
+ "Docker disk:",
541
+ ` Images: ${formatBytes(d.images.bytes)} (${formatBytes(d.images.reclaimable_bytes)} reclaimable, ${d.images.unused_count}/${d.images.count} unused)`,
542
+ ` Containers: ${formatBytes(d.containers.bytes)} (${formatBytes(d.containers.reclaimable_bytes)} reclaimable, ${d.containers.stopped_count}/${d.containers.count} stopped)`,
543
+ ` Volumes: ${formatBytes(d.volumes.bytes)} (${formatBytes(d.volumes.reclaimable_bytes)} reclaimable, ${d.volumes.unused_count}/${d.volumes.count} unused)`,
544
+ ` Build cache: ${formatBytes(d.build_cache.bytes)} (${formatBytes(d.build_cache.reclaimable_bytes)} reclaimable, ${d.build_cache.count} entries)`,
545
+ );
546
+ }
547
+ return { content: [{ type: "text", text: lines.join("\n") }] };
548
+ },
549
+ );
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") }] };
518
606
  },
519
607
  );
520
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
+ function formatBytes(bytes: number): string {
664
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
665
+ const units = ["B", "KB", "MB", "GB", "TB"];
666
+ const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
667
+ const val = bytes / 1024 ** i;
668
+ return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`;
669
+ }
670
+
521
671
  server.registerTool(
522
672
  "moor_project_get",
523
673
  {