@moor-sh/mcp 0.5.1 → 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 +278 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.5.1",
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
@@ -572,6 +572,35 @@ server.registerTool(
572
572
  .enum(["no", "on-failure", "always", "unless-stopped"])
573
573
  .optional()
574
574
  .describe("Docker restart policy (default: unless-stopped)"),
575
+ memory_limit_mb: z
576
+ .number()
577
+ .int()
578
+ .min(6)
579
+ .optional()
580
+ .describe(
581
+ "Max RAM in MB (also caps swap to the same value so the container can't burn through host swap). Min 6 (Docker's floor), max host total memory. Omit for unbounded. Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run).",
582
+ ),
583
+ cpus: z
584
+ .number()
585
+ .min(0.001)
586
+ .optional()
587
+ .describe(
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
+ ),
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
+ ),
575
604
  }),
576
605
  },
577
606
  async (input) => {
@@ -581,10 +610,35 @@ server.registerTool(
581
610
  }
582
611
  if (input.github_url) validateGithubUrl(input.github_url);
583
612
 
584
- const res = await apiPost("/api/projects", input);
613
+ const { volumes, ...createBody } = input;
614
+ const res = await apiPost("/api/projects", createBody);
585
615
  if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`);
586
- const project = await res.json();
587
- 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") }] };
588
642
  },
589
643
  );
590
644
 
@@ -593,7 +647,7 @@ server.registerTool(
593
647
  {
594
648
  title: "Update Project",
595
649
  description:
596
- "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately.",
650
+ "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately. Resource-limit changes (memory_limit_mb, cpus) take effect on the next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run) — an already-running container keeps its existing limits.",
597
651
  inputSchema: z.object({
598
652
  project: z.string().describe("Project name or ID to update"),
599
653
  name: z
@@ -610,6 +664,23 @@ server.registerTool(
610
664
  domain: z.string().optional(),
611
665
  domain_port: z.number().int().positive().optional(),
612
666
  restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
667
+ memory_limit_mb: z
668
+ .number()
669
+ .int()
670
+ .min(6)
671
+ .nullable()
672
+ .optional()
673
+ .describe(
674
+ "Max RAM in MB. Pass null to clear (return to unbounded). Min 6, max host total memory. Takes effect on container recreate.",
675
+ ),
676
+ cpus: z
677
+ .number()
678
+ .min(0.001)
679
+ .nullable()
680
+ .optional()
681
+ .describe(
682
+ "Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate.",
683
+ ),
613
684
  }),
614
685
  },
615
686
  async (input) => {
@@ -635,7 +706,7 @@ server.registerTool(
635
706
  {
636
707
  title: "Delete Project",
637
708
  description:
638
- "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.",
639
710
  inputSchema: z.object({
640
711
  project: z.string().describe("Project name or ID to delete"),
641
712
  confirm_name: z
@@ -643,18 +714,47 @@ server.registerTool(
643
714
  .describe(
644
715
  "Must equal the resolved project's name. Guards against deleting the wrong project.",
645
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
+ ),
646
724
  }),
647
725
  },
648
- async ({ project, confirm_name }) => {
726
+ async ({ project, confirm_name, purge_volumes }) => {
649
727
  const p = await resolveProject(project);
650
728
  if (confirm_name !== p.name) {
651
729
  throw new Error(
652
730
  `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`,
653
731
  );
654
732
  }
655
- const res = await apiDelete(`/api/projects/${p.id}`);
656
- if (!res.ok) throw new Error(`Failed to delete project: ${await res.text()}`);
657
- 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
+ };
658
758
  },
659
759
  );
660
760
 
@@ -846,6 +946,98 @@ server.registerTool(
846
946
  },
847
947
  );
848
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
+
849
1041
  server.registerTool(
850
1042
  "moor_deploy",
851
1043
  {
@@ -888,6 +1080,34 @@ server.registerTool(
888
1080
  .enum(["no", "on-failure", "always", "unless-stopped"])
889
1081
  .optional()
890
1082
  .describe("Docker restart policy (API default: unless-stopped)"),
1083
+ memory_limit_mb: z
1084
+ .number()
1085
+ .int()
1086
+ .min(6)
1087
+ .nullable()
1088
+ .optional()
1089
+ .describe(
1090
+ "Max RAM in MB (also caps swap to the same value). Min 6, max host total memory. Pass null on update to clear. Limits apply on container recreate, which deploy always does when run: true.",
1091
+ ),
1092
+ cpus: z
1093
+ .number()
1094
+ .min(0.001)
1095
+ .nullable()
1096
+ .optional()
1097
+ .describe(
1098
+ "Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear.",
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
+ ),
891
1111
  env: z
892
1112
  .record(z.string(), z.string())
893
1113
  .optional()
@@ -965,6 +1185,8 @@ server.registerTool(
965
1185
  domain: normalizedDomain,
966
1186
  domain_port: input.domain_port,
967
1187
  restart_policy: input.restart_policy,
1188
+ memory_limit_mb: input.memory_limit_mb,
1189
+ cpus: input.cpus,
968
1190
  };
969
1191
  const res = await apiPost("/api/projects", createBody);
970
1192
  if (!res.ok) throw new Error(`[create] ${await res.text()}`);
@@ -982,6 +1204,8 @@ server.registerTool(
982
1204
  if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain;
983
1205
  if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port;
984
1206
  if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy;
1207
+ if (input.memory_limit_mb !== undefined) updateBody.memory_limit_mb = input.memory_limit_mb;
1208
+ if (input.cpus !== undefined) updateBody.cpus = input.cpus;
985
1209
 
986
1210
  if (Object.keys(updateBody).length > 0) {
987
1211
  const res = await apiPut(`/api/projects/${existing.id}`, updateBody);
@@ -991,6 +1215,51 @@ server.registerTool(
991
1215
  projectName = existing.name;
992
1216
  }
993
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
+
994
1263
  // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op.
995
1264
  const envEntries = input.env ? Object.entries(input.env) : [];
996
1265
  const envProvided = envEntries.length > 0;