@moor-sh/mcp 0.6.0 → 0.7.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 +227 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
@@ -582,11 +582,25 @@ server.registerTool(
582
582
  ),
583
583
  cpus: z
584
584
  .number()
585
- .positive()
585
+ .min(0.001)
586
586
  .optional()
587
587
  .describe(
588
588
  "Max CPU cores. Fractional values OK (e.g. 0.5 = half a core). Min 0.001 (anything smaller rounds to Docker NanoCpus=0, which means unlimited — use omit for that). Max host core count. Takes effect on container recreate.",
589
589
  ),
590
+ volumes: z
591
+ .array(
592
+ z.object({
593
+ name: z.string().min(1).describe("Logical volume name (unique per project)"),
594
+ target: z
595
+ .string()
596
+ .min(1)
597
+ .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"),
598
+ }),
599
+ )
600
+ .optional()
601
+ .describe(
602
+ "Named Docker volumes to attach. Each entry creates a per-project volume (stored as moor-<project>-<name>) and mounts it at the given target on next container recreate. Data survives container/project rebuilds unless explicitly purged via project delete with purge_volumes=true.",
603
+ ),
590
604
  }),
591
605
  },
592
606
  async (input) => {
@@ -596,10 +610,35 @@ server.registerTool(
596
610
  }
597
611
  if (input.github_url) validateGithubUrl(input.github_url);
598
612
 
599
- const res = await apiPost("/api/projects", input);
613
+ const { volumes, ...createBody } = input;
614
+ const res = await apiPost("/api/projects", createBody);
600
615
  if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`);
601
- const project = await res.json();
602
- return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] };
616
+ const project = (await res.json()) as { id: number };
617
+
618
+ // Volumes are a separate endpoint so the API stays single-concern. Loop
619
+ // through them; if any one fails, report what landed and what didn't.
620
+ const volumeFailures: Array<{ name: string; error: string }> = [];
621
+ const volumeCreated: string[] = [];
622
+ if (volumes && volumes.length > 0) {
623
+ for (const v of volumes) {
624
+ const vRes = await apiPost(`/api/projects/${project.id}/volumes`, v);
625
+ if (vRes.ok) volumeCreated.push(v.name);
626
+ else volumeFailures.push({ name: v.name, error: await vRes.text() });
627
+ }
628
+ }
629
+
630
+ const lines = [JSON.stringify(project, null, 2)];
631
+ if (volumeCreated.length > 0) {
632
+ lines.push(`\nCreated volumes: ${volumeCreated.join(", ")}`);
633
+ }
634
+ if (volumeFailures.length > 0) {
635
+ lines.push(
636
+ `\nVolume failures (project was still created): ${volumeFailures
637
+ .map((f) => `${f.name}: ${f.error}`)
638
+ .join("; ")}`,
639
+ );
640
+ }
641
+ return { content: [{ type: "text", text: lines.join("\n") }] };
603
642
  },
604
643
  );
605
644
 
@@ -636,7 +675,7 @@ server.registerTool(
636
675
  ),
637
676
  cpus: z
638
677
  .number()
639
- .positive()
678
+ .min(0.001)
640
679
  .nullable()
641
680
  .optional()
642
681
  .describe(
@@ -667,7 +706,7 @@ server.registerTool(
667
706
  {
668
707
  title: "Delete Project",
669
708
  description:
670
- "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible.",
709
+ "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible. Named Docker volumes are preserved by default (data survives so a recreated project can remount them); pass purge_volumes: true to also delete the underlying Docker volumes — that deletion is also irreversible.",
671
710
  inputSchema: z.object({
672
711
  project: z.string().describe("Project name or ID to delete"),
673
712
  confirm_name: z
@@ -675,18 +714,47 @@ server.registerTool(
675
714
  .describe(
676
715
  "Must equal the resolved project's name. Guards against deleting the wrong project.",
677
716
  ),
717
+ purge_volumes: z
718
+ .boolean()
719
+ .optional()
720
+ .default(false)
721
+ .describe(
722
+ "Also delete the underlying Docker volumes (their data). Default false: project gone, volumes (and their data) preserved. The volume metadata is cleaned up either way; this flag only controls whether the data goes too.",
723
+ ),
678
724
  }),
679
725
  },
680
- async ({ project, confirm_name }) => {
726
+ async ({ project, confirm_name, purge_volumes }) => {
681
727
  const p = await resolveProject(project);
682
728
  if (confirm_name !== p.name) {
683
729
  throw new Error(
684
730
  `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`,
685
731
  );
686
732
  }
687
- const res = await apiDelete(`/api/projects/${p.id}`);
688
- if (!res.ok) throw new Error(`Failed to delete project: ${await res.text()}`);
689
- return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
733
+ const qs = purge_volumes ? "?purge_volumes=true" : "";
734
+ const res = await apiDelete(`/api/projects/${p.id}${qs}`);
735
+ if (!res.ok) {
736
+ const text = await res.text();
737
+ try {
738
+ const parsed = JSON.parse(text);
739
+ if (parsed?.message) throw new Error(parsed.message);
740
+ } catch {
741
+ // not json
742
+ }
743
+ throw new Error(`Failed to delete project: ${text}`);
744
+ }
745
+ // 204 No Content (no purge or no volumes) vs 200 JSON (purge with results)
746
+ if (res.status === 204) {
747
+ return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
748
+ }
749
+ const body = (await res.json()) as { volumes_purged?: number };
750
+ return {
751
+ content: [
752
+ {
753
+ type: "text",
754
+ text: `Deleted project ${p.name} (id=${p.id}). Purged ${body.volumes_purged ?? 0} Docker volume(s).`,
755
+ },
756
+ ],
757
+ };
690
758
  },
691
759
  );
692
760
 
@@ -878,6 +946,98 @@ server.registerTool(
878
946
  },
879
947
  );
880
948
 
949
+ // --- Volumes (#35) ---
950
+
951
+ server.registerTool(
952
+ "moor_volume_list",
953
+ {
954
+ title: "List Project Volumes",
955
+ description:
956
+ "List the named Docker volumes attached to a project. Each entry includes the logical name (per-project handle), the in-container target path, and the actual Docker volume name (for `docker volume ls` / `docker volume inspect` outside moor).",
957
+ inputSchema: z.object({
958
+ project: z.string().describe("Project name or ID"),
959
+ }),
960
+ },
961
+ async ({ project }) => {
962
+ const p = await resolveProject(project);
963
+ const res = await apiGet(`/api/projects/${p.id}/volumes`);
964
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
965
+ const rows = (await res.json()) as Array<{
966
+ id: number;
967
+ name: string;
968
+ target: string;
969
+ docker_name: string;
970
+ }>;
971
+ if (rows.length === 0) {
972
+ return { content: [{ type: "text", text: `No volumes attached to ${p.name}.` }] };
973
+ }
974
+ const lines = rows.map(
975
+ (v) => `id=${v.id} name=${v.name} target=${v.target} docker_name=${v.docker_name}`,
976
+ );
977
+ return { content: [{ type: "text", text: lines.join("\n") }] };
978
+ },
979
+ );
980
+
981
+ server.registerTool(
982
+ "moor_volume_add",
983
+ {
984
+ title: "Add Project Volume",
985
+ description:
986
+ "Attach a named Docker volume to a project. The volume is created lazily by Docker on first container start; moor stores the mount config (logical name, in-container target, and the generated docker_name like moor-<project>-<name>). Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run) — already-running containers keep their existing mounts.",
987
+ inputSchema: z.object({
988
+ project: z.string().describe("Project name or ID"),
989
+ name: z
990
+ .string()
991
+ .min(1)
992
+ .describe("Logical volume name (unique per project; alphanumeric/_/-)"),
993
+ target: z
994
+ .string()
995
+ .min(1)
996
+ .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"),
997
+ }),
998
+ },
999
+ async ({ project, name, target }) => {
1000
+ const p = await resolveProject(project);
1001
+ const res = await apiPost(`/api/projects/${p.id}/volumes`, { name, target });
1002
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1003
+ const created = (await res.json()) as {
1004
+ id: number;
1005
+ name: string;
1006
+ target: string;
1007
+ docker_name: string;
1008
+ };
1009
+ return {
1010
+ content: [
1011
+ {
1012
+ type: "text",
1013
+ text: `Attached volume to ${p.name}: id=${created.id}, name=${created.name}, target=${created.target}, docker_name=${created.docker_name}. Mount applies on next container recreate.`,
1014
+ },
1015
+ ],
1016
+ };
1017
+ },
1018
+ );
1019
+
1020
+ server.registerTool(
1021
+ "moor_volume_remove",
1022
+ {
1023
+ title: "Remove Project Volume Mount",
1024
+ description:
1025
+ "Detach a named volume from a project's mount config. The underlying Docker volume (and its data) is intentionally preserved — to actually delete the data, use moor_project_delete with purge_volumes:true, or run `docker volume rm <docker_name>` manually. Takes effect on next container recreate.",
1026
+ inputSchema: z.object({
1027
+ project: z.string().describe("Project name or ID"),
1028
+ volume_id: z.number().int().positive().describe("Volume ID from moor_volume_list"),
1029
+ }),
1030
+ },
1031
+ async ({ project, volume_id }) => {
1032
+ const p = await resolveProject(project);
1033
+ const res = await apiDelete(`/api/projects/${p.id}/volumes/${volume_id}`);
1034
+ if (res.status === 404) throw new Error(`Volume ${volume_id} not found on project ${p.name}`);
1035
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1036
+ const body = (await res.json()) as { docker_name: string; message: string };
1037
+ return { content: [{ type: "text", text: body.message }] };
1038
+ },
1039
+ );
1040
+
881
1041
  server.registerTool(
882
1042
  "moor_deploy",
883
1043
  {
@@ -931,12 +1091,23 @@ server.registerTool(
931
1091
  ),
932
1092
  cpus: z
933
1093
  .number()
934
- .positive()
1094
+ .min(0.001)
935
1095
  .nullable()
936
1096
  .optional()
937
1097
  .describe(
938
1098
  "Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear.",
939
1099
  ),
1100
+ volumes: z
1101
+ .array(
1102
+ z.object({
1103
+ name: z.string().min(1),
1104
+ target: z.string().min(1),
1105
+ }),
1106
+ )
1107
+ .optional()
1108
+ .describe(
1109
+ "Named Docker volumes to attach. Each entry becomes a per-project volume (stored as moor-<project>-<name>) and mounts at the given target on container recreate. On update_existing, additions only — no removals. Data survives container/project rebuilds unless explicitly purged via moor_project_delete with confirm_name (purge_volumes is a separate flag).",
1110
+ ),
940
1111
  env: z
941
1112
  .record(z.string(), z.string())
942
1113
  .optional()
@@ -1044,6 +1215,51 @@ server.registerTool(
1044
1215
  projectName = existing.name;
1045
1216
  }
1046
1217
 
1218
+ // Step 1.5: add named volumes (additions only — moor_deploy never removes
1219
+ // volumes, even on update_existing). Mounts apply on next container
1220
+ // recreate, which the run step below triggers by default.
1221
+ if (input.volumes && input.volumes.length > 0) {
1222
+ // Cache the existing list once so we can resolve 409s without re-fetching
1223
+ // per conflict. Only fetched if a 409 actually occurs.
1224
+ let existingVolumes: Array<{ name: string; target: string }> | null = null;
1225
+ for (const v of input.volumes) {
1226
+ const vRes = await apiPost(`/api/projects/${projectId}/volumes`, v);
1227
+ if (vRes.ok) continue;
1228
+ const text = await vRes.text();
1229
+ if (vRes.status !== 409) {
1230
+ throw new Error(`[volumes] failed to add ${v.name}: ${text}`);
1231
+ }
1232
+ // 409 is tolerable ONLY if the existing volume matches the requested
1233
+ // spec exactly (same name, same target). A 409 with a drifted target
1234
+ // means the operator changed the desired mount and we'd silently
1235
+ // ignore the change — fail loudly instead.
1236
+ if (existingVolumes === null) {
1237
+ const listRes = await apiGet(`/api/projects/${projectId}/volumes`);
1238
+ if (!listRes.ok) {
1239
+ throw new Error(
1240
+ `[volumes] could not resolve 409 on ${v.name}: failed to list existing volumes: ${await listRes.text()}`,
1241
+ );
1242
+ }
1243
+ existingVolumes = (await listRes.json()) as Array<{ name: string; target: string }>;
1244
+ }
1245
+ const match = existingVolumes.find((e) => e.name === v.name);
1246
+ if (!match) {
1247
+ // 409 was for some other reason (target collision under a different
1248
+ // name, or cross-project docker_name collision). Operator must
1249
+ // intervene.
1250
+ throw new Error(
1251
+ `[volumes] conflict adding ${v.name}: ${text} (no existing volume by that name; check for target collision)`,
1252
+ );
1253
+ }
1254
+ if (match.target !== v.target) {
1255
+ throw new Error(
1256
+ `[volumes] conflict adding ${v.name}: existing target "${match.target}" differs from requested "${v.target}". moor_deploy does not change mount targets; use moor_volume_remove + moor_volume_add explicitly.`,
1257
+ );
1258
+ }
1259
+ // Same name, same target — idempotent re-run, tolerable.
1260
+ }
1261
+ }
1262
+
1047
1263
  // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op.
1048
1264
  const envEntries = input.env ? Object.entries(input.env) : [];
1049
1265
  const envProvided = envEntries.length > 0;