@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.
- package/package.json +1 -1
- package/src/index.ts +227 -11
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -582,11 +582,25 @@ server.registerTool(
|
|
|
582
582
|
),
|
|
583
583
|
cpus: z
|
|
584
584
|
.number()
|
|
585
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
.
|
|
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
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
.
|
|
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;
|