@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.
- package/package.json +1 -1
- package/src/index.ts +278 -9
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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
|
|
656
|
-
|
|
657
|
-
|
|
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;
|