@moor-sh/mcp 0.1.1 → 0.3.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 +2 -2
  2. package/src/index.ts +668 -0
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.3.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": {
7
7
  "type": "git",
8
- "url": "https://github.com/caiopizzol/moor.git",
8
+ "url": "git+https://github.com/caiopizzol/moor.git",
9
9
  "directory": "packages/mcp"
10
10
  },
11
11
  "homepage": "https://github.com/caiopizzol/moor/tree/main/packages/mcp",
package/src/index.ts CHANGED
@@ -73,6 +73,13 @@ async function apiPut(path: string, body: unknown) {
73
73
  });
74
74
  }
75
75
 
76
+ async function apiDelete(path: string) {
77
+ return fetch(`${baseUrl}${path}`, {
78
+ method: "DELETE",
79
+ headers: headers(),
80
+ });
81
+ }
82
+
76
83
  type Project = {
77
84
  id: number;
78
85
  name: string;
@@ -127,6 +134,142 @@ async function readSSE(res: Response): Promise<{ logs: string; error?: string }>
127
134
  return { logs, error };
128
135
  }
129
136
 
137
+ // --- Validators ---
138
+
139
+ /** Validate that a string is a github.com URL. Throws with a clear message on failure.
140
+ * Stricter than apps/api/routes/docker.ts:validateGithubUrl, which accepts any host
141
+ * ending in "github.com" (so "evilgithub.com" slips through). MCP rejects that and
142
+ * surfaces the error at create/update time, not at first build/run. */
143
+ function validateGithubUrl(url: string): void {
144
+ let host: string;
145
+ try {
146
+ host = new URL(url).hostname;
147
+ } catch {
148
+ throw new Error(`github_url is not a valid URL: ${url}`);
149
+ }
150
+ if (host !== "github.com" && !host.endsWith(".github.com")) {
151
+ throw new Error(`github_url must be a github.com URL (got hostname "${host}")`);
152
+ }
153
+ }
154
+
155
+ /** Strict GitHub repo URL validator used by moor_deploy. Stricter than
156
+ * validateGithubUrl: requires host = github.com or www.github.com AND a path of
157
+ * exactly /owner/repo (with optional .git suffix, optional trailing slash).
158
+ * Rejects gist.github.com, the bare root, and /owner/repo/tree/... extras.
159
+ * Failed deploys trigger an actual image build/pull, so the up-front check is
160
+ * worth being pickier than the create/update wrappers. */
161
+ function validateGithubRepoUrl(url: string): void {
162
+ let parsed: URL;
163
+ try {
164
+ parsed = new URL(url);
165
+ } catch {
166
+ throw new Error(`github_url is not a valid URL: ${url}`);
167
+ }
168
+ // The downstream build path (apps/api/docker.ts:buildImage) appends ".git" and a
169
+ // branch ref to whatever URL we forward, so a non-http protocol, query string, or
170
+ // fragment quietly mangles the resulting git remote. Reject those up front.
171
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
172
+ throw new Error(`github_url must use http or https (got protocol "${parsed.protocol}")`);
173
+ }
174
+ if (parsed.search) {
175
+ throw new Error(`github_url must not contain query parameters (got "${parsed.search}")`);
176
+ }
177
+ if (parsed.hash) {
178
+ throw new Error(`github_url must not contain a URL fragment (got "${parsed.hash}")`);
179
+ }
180
+ const host = parsed.hostname;
181
+ if (host !== "github.com" && host !== "www.github.com") {
182
+ throw new Error(`github_url must use github.com or www.github.com (got "${host}")`);
183
+ }
184
+ if (!/^\/[^/]+\/[^/]+?(\.git)?\/?$/.test(parsed.pathname)) {
185
+ throw new Error(
186
+ `github_url must point to /owner/repo (with optional .git); got "${parsed.pathname}"`,
187
+ );
188
+ }
189
+ }
190
+
191
+ /** Validate a 5-field crontab schedule against what apps/api/cron.ts can actually execute.
192
+ * Stricter than the scheduler's permissive parser: the scheduler silently never fires
193
+ * on bad input, so MCP rejects up-front. Returns an error string or null. */
194
+ const CRON_FIELDS: ReadonlyArray<{ name: string; min: number; max: number }> = [
195
+ { name: "minute", min: 0, max: 59 },
196
+ { name: "hour", min: 0, max: 23 },
197
+ { name: "day-of-month", min: 1, max: 31 },
198
+ { name: "month", min: 1, max: 12 },
199
+ { name: "day-of-week", min: 0, max: 6 }, // 0=Sunday; scheduler does not translate 7
200
+ ];
201
+
202
+ // Whitelist each comma-separated part against one of the canonical forms below.
203
+ // Anything else (empty parts, leading "-", bare "N/S", "/S", stray characters) is
204
+ // rejected. The scheduler at apps/api/cron.ts silently ignores or mis-parses these
205
+ // inputs, so the validator must be strict where the scheduler is loose.
206
+ const CRON_PART_PATTERNS = [
207
+ /^\*$/, // *
208
+ /^(\d+)$/, // N
209
+ /^(\d+)-(\d+)$/, // A-B
210
+ /^\*\/(\d+)$/, // */S
211
+ /^(\d+)-(\d+)\/(\d+)$/, // A-B/S
212
+ ];
213
+
214
+ function validateCronField(field: string, min: number, max: number, name: string): string | null {
215
+ if (field === "*") return null;
216
+ if (/[?LW#]/i.test(field)) return `${name}: ?, L, W, # are not supported`;
217
+ if (/[a-zA-Z]/.test(field))
218
+ return `${name}: month/day names are not supported, use numeric values`;
219
+
220
+ for (const part of field.split(",")) {
221
+ if (part === "") return `${name}: empty list element`;
222
+
223
+ const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null);
224
+ if (!match) return `${name}: invalid expression "${part}"`;
225
+
226
+ // Validate captured numbers against per-field bounds and step positivity.
227
+ // Capture layout depends on which pattern matched, identified by length.
228
+ const groups = match.slice(1);
229
+ if (groups.length === 1 && match[0].startsWith("*/")) {
230
+ // */S
231
+ const step = Number(groups[0]);
232
+ if (step <= 0) return `${name}: step must be a positive integer (got "${groups[0]}")`;
233
+ } else if (groups.length === 1) {
234
+ // N
235
+ const n = Number(groups[0]);
236
+ if (n < min || n > max) return `${name}: ${n} out of bounds [${min}-${max}]`;
237
+ } else if (groups.length === 2) {
238
+ // A-B
239
+ const a = Number(groups[0]);
240
+ const b = Number(groups[1]);
241
+ if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
242
+ if (a > b) return `${name}: range ${a}-${b} is descending`;
243
+ } else if (groups.length === 3) {
244
+ // A-B/S
245
+ const a = Number(groups[0]);
246
+ const b = Number(groups[1]);
247
+ const step = Number(groups[2]);
248
+ if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
249
+ if (a > b) return `${name}: range ${a}-${b} is descending`;
250
+ if (step <= 0) return `${name}: step must be a positive integer (got "${groups[2]}")`;
251
+ }
252
+ }
253
+ return null;
254
+ }
255
+
256
+ function validateCronSchedule(schedule: string): string | null {
257
+ const parts = schedule.trim().split(/\s+/);
258
+ if (parts.length !== 5) {
259
+ return `schedule must have exactly 5 space-separated fields (got ${parts.length})`;
260
+ }
261
+ for (let i = 0; i < 5; i++) {
262
+ const err = validateCronField(
263
+ parts[i],
264
+ CRON_FIELDS[i].min,
265
+ CRON_FIELDS[i].max,
266
+ CRON_FIELDS[i].name,
267
+ );
268
+ if (err) return err;
269
+ }
270
+ return null;
271
+ }
272
+
130
273
  // --- MCP Server ---
131
274
 
132
275
  const server = new McpServer({
@@ -340,6 +483,531 @@ server.registerTool(
340
483
  },
341
484
  );
342
485
 
486
+ server.registerTool(
487
+ "moor_project_get",
488
+ {
489
+ title: "Get Project",
490
+ description:
491
+ "Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).",
492
+ inputSchema: z.object({
493
+ project: z.string().describe("Project name or ID"),
494
+ }),
495
+ },
496
+ async ({ project }) => {
497
+ const p = await resolveProject(project);
498
+ return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] };
499
+ },
500
+ );
501
+
502
+ server.registerTool(
503
+ "moor_project_create",
504
+ {
505
+ title: "Create Project",
506
+ description:
507
+ "Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild (or moor_deploy in a future release) to bring it up.",
508
+ inputSchema: z.object({
509
+ name: z
510
+ .string()
511
+ .regex(
512
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
513
+ "name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -",
514
+ )
515
+ .describe("Project name (used as the container name suffix: moor-<name>)"),
516
+ github_url: z
517
+ .string()
518
+ .optional()
519
+ .describe("github.com URL; mutually exclusive with docker_image"),
520
+ docker_image: z
521
+ .string()
522
+ .optional()
523
+ .describe("Docker image reference (e.g. nginx:latest); mutually exclusive with github_url"),
524
+ branch: z.string().optional().describe("Git branch (default: main, for github_url projects)"),
525
+ dockerfile: z
526
+ .string()
527
+ .optional()
528
+ .describe("Dockerfile path within the repo (default: Dockerfile)"),
529
+ domain: z.string().optional().describe("Public domain to route to this container via Caddy"),
530
+ domain_port: z
531
+ .number()
532
+ .int()
533
+ .positive()
534
+ .optional()
535
+ .describe("Container port Caddy should forward to (required if domain is set)"),
536
+ restart_policy: z
537
+ .enum(["no", "on-failure", "always", "unless-stopped"])
538
+ .optional()
539
+ .describe("Docker restart policy (default: unless-stopped)"),
540
+ }),
541
+ },
542
+ async (input) => {
543
+ const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
544
+ if (sources !== 1) {
545
+ throw new Error("Provide exactly one of github_url or docker_image");
546
+ }
547
+ if (input.github_url) validateGithubUrl(input.github_url);
548
+
549
+ const res = await apiPost("/api/projects", input);
550
+ if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`);
551
+ const project = await res.json();
552
+ return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] };
553
+ },
554
+ );
555
+
556
+ server.registerTool(
557
+ "moor_project_update",
558
+ {
559
+ title: "Update Project",
560
+ description:
561
+ "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately.",
562
+ inputSchema: z.object({
563
+ project: z.string().describe("Project name or ID to update"),
564
+ name: z
565
+ .string()
566
+ .regex(
567
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
568
+ "name must start alphanumeric; allowed: a-z A-Z 0-9 _ -",
569
+ )
570
+ .optional(),
571
+ github_url: z.string().optional(),
572
+ docker_image: z.string().optional(),
573
+ branch: z.string().optional(),
574
+ dockerfile: z.string().optional(),
575
+ domain: z.string().optional(),
576
+ domain_port: z.number().int().positive().optional(),
577
+ restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
578
+ }),
579
+ },
580
+ async (input) => {
581
+ const { project, ...updates } = input;
582
+ if (Object.keys(updates).length === 0) {
583
+ throw new Error("Provide at least one field to update");
584
+ }
585
+ if (updates.github_url && updates.docker_image) {
586
+ throw new Error("Cannot set both github_url and docker_image in the same update");
587
+ }
588
+ if (updates.github_url) validateGithubUrl(updates.github_url);
589
+
590
+ const p = await resolveProject(project);
591
+ const res = await apiPut(`/api/projects/${p.id}`, updates);
592
+ if (!res.ok) throw new Error(`Failed to update project: ${await res.text()}`);
593
+ const updated = await res.json();
594
+ return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
595
+ },
596
+ );
597
+
598
+ server.registerTool(
599
+ "moor_project_delete",
600
+ {
601
+ title: "Delete Project",
602
+ description:
603
+ "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible.",
604
+ inputSchema: z.object({
605
+ project: z.string().describe("Project name or ID to delete"),
606
+ confirm_name: z
607
+ .string()
608
+ .describe(
609
+ "Must equal the resolved project's name. Guards against deleting the wrong project.",
610
+ ),
611
+ }),
612
+ },
613
+ async ({ project, confirm_name }) => {
614
+ const p = await resolveProject(project);
615
+ if (confirm_name !== p.name) {
616
+ throw new Error(
617
+ `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`,
618
+ );
619
+ }
620
+ const res = await apiDelete(`/api/projects/${p.id}`);
621
+ if (!res.ok) throw new Error(`Failed to delete project: ${await res.text()}`);
622
+ return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
623
+ },
624
+ );
625
+
626
+ server.registerTool(
627
+ "moor_cron_create",
628
+ {
629
+ title: "Create Cron",
630
+ description:
631
+ "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted.",
632
+ inputSchema: z.object({
633
+ project: z.string().describe("Project name or ID"),
634
+ name: z.string().min(1).describe("Human-readable name for the cron"),
635
+ schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'),
636
+ command: z.string().min(1).describe("Shell command to run inside the project's container"),
637
+ }),
638
+ },
639
+ async ({ project, name, schedule, command }) => {
640
+ const err = validateCronSchedule(schedule);
641
+ if (err) throw new Error(`Invalid schedule: ${err}`);
642
+ const p = await resolveProject(project);
643
+ const res = await apiPost(`/api/projects/${p.id}/crons`, { name, schedule, command });
644
+ if (!res.ok) throw new Error(`Failed to create cron: ${await res.text()}`);
645
+ const cron = await res.json();
646
+ return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
647
+ },
648
+ );
649
+
650
+ server.registerTool(
651
+ "moor_cron_update",
652
+ {
653
+ title: "Update Cron",
654
+ description: "Updates a cron's fields by id. Schedule is validated if provided.",
655
+ inputSchema: z.object({
656
+ cron_id: z.number().int().positive().describe("Cron ID"),
657
+ name: z.string().min(1).optional(),
658
+ schedule: z.string().optional(),
659
+ command: z.string().min(1).optional(),
660
+ enabled: z.boolean().optional().describe("Enable or disable the cron"),
661
+ }),
662
+ },
663
+ async ({ cron_id, name, schedule, command, enabled }) => {
664
+ if (schedule !== undefined) {
665
+ const err = validateCronSchedule(schedule);
666
+ if (err) throw new Error(`Invalid schedule: ${err}`);
667
+ }
668
+ const body: Record<string, unknown> = {};
669
+ if (name !== undefined) body.name = name;
670
+ if (schedule !== undefined) body.schedule = schedule;
671
+ if (command !== undefined) body.command = command;
672
+ if (enabled !== undefined) body.enabled = enabled ? 1 : 0;
673
+ if (Object.keys(body).length === 0) {
674
+ throw new Error("Provide at least one field to update");
675
+ }
676
+ const res = await apiPut(`/api/crons/${cron_id}`, body);
677
+ if (!res.ok) throw new Error(`Failed to update cron: ${await res.text()}`);
678
+ const cron = await res.json();
679
+ return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
680
+ },
681
+ );
682
+
683
+ server.registerTool(
684
+ "moor_cron_delete",
685
+ {
686
+ title: "Delete Cron",
687
+ description: "Deletes a cron by id.",
688
+ inputSchema: z.object({
689
+ cron_id: z.number().int().positive().describe("Cron ID"),
690
+ }),
691
+ },
692
+ async ({ cron_id }) => {
693
+ const res = await apiDelete(`/api/crons/${cron_id}`);
694
+ if (!res.ok) throw new Error(`Failed to delete cron: ${await res.text()}`);
695
+ // API returns 204 whether or not the row existed; phrase the response so it
696
+ // doesn't claim a row was removed when it might already have been gone.
697
+ return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] };
698
+ },
699
+ );
700
+
701
+ server.registerTool(
702
+ "moor_cron_run",
703
+ {
704
+ title: "Run Cron Now",
705
+ description:
706
+ "Triggers a cron to run immediately. Requires the project's container to be running.",
707
+ inputSchema: z.object({
708
+ cron_id: z.number().int().positive().describe("Cron ID"),
709
+ }),
710
+ },
711
+ async ({ cron_id }) => {
712
+ const res = await apiPost(`/api/crons/${cron_id}/run`);
713
+ if (!res.ok) {
714
+ const text = await res.text();
715
+ let message = text;
716
+ try {
717
+ const parsed = JSON.parse(text);
718
+ if (parsed?.error) message = parsed.error;
719
+ } catch {
720
+ // Not JSON; use raw text
721
+ }
722
+ throw new Error(message);
723
+ }
724
+ return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] };
725
+ },
726
+ );
727
+
728
+ server.registerTool(
729
+ "moor_env_delete",
730
+ {
731
+ title: "Delete Environment Variables",
732
+ description:
733
+ "Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running.",
734
+ inputSchema: z.object({
735
+ project: z.string().describe("Project name or ID"),
736
+ keys: z.array(z.string().min(1)).min(1).describe("Env var keys to remove"),
737
+ }),
738
+ },
739
+ async ({ project, keys }) => {
740
+ const p = await resolveProject(project);
741
+
742
+ const existingRes = await apiGet(`/api/projects/${p.id}/envs`);
743
+ if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`);
744
+ const existing = (await existingRes.json()) as { key: string; value: string }[];
745
+ const existingKeys = new Set(existing.map((v) => v.key));
746
+
747
+ const toDelete = keys.filter((k) => existingKeys.has(k));
748
+ const missing = keys.filter((k) => !existingKeys.has(k));
749
+
750
+ if (toDelete.length === 0) {
751
+ const existingList = [...existingKeys].sort().join(", ") || "(none)";
752
+ return {
753
+ content: [
754
+ {
755
+ type: "text",
756
+ text: `No matching keys on ${p.name}. Existing keys: ${existingList}`,
757
+ },
758
+ ],
759
+ };
760
+ }
761
+
762
+ for (const key of toDelete) {
763
+ const res = await apiDelete(`/api/projects/${p.id}/envs/${encodeURIComponent(key)}`);
764
+ if (!res.ok) throw new Error(`Failed to delete ${key}: ${await res.text()}`);
765
+ }
766
+
767
+ let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`;
768
+ if (missing.length > 0) text += ` (Not present: ${missing.join(", ")}.)`;
769
+
770
+ if (p.status === "running") {
771
+ await apiPost(`/api/projects/${p.id}/stop`);
772
+ const startRes = await apiPost(`/api/projects/${p.id}/start`);
773
+ if (!startRes.ok) {
774
+ throw new Error(`Deleted vars but failed to restart: ${await startRes.text()}`);
775
+ }
776
+ text += " Container restarted.";
777
+ }
778
+
779
+ return { content: [{ type: "text", text }] };
780
+ },
781
+ );
782
+
783
+ server.registerTool(
784
+ "moor_dns_check",
785
+ {
786
+ title: "Check Domain DNS",
787
+ description:
788
+ "Resolves a domain's A record and reports whether it matches the server's public IP. Useful before pointing a project's domain at the server.",
789
+ inputSchema: z.object({
790
+ domain: z.string().min(1).describe("Domain to check, e.g. app.example.com"),
791
+ }),
792
+ },
793
+ async ({ domain }) => {
794
+ const res = await apiPost("/api/dns-check", { domain });
795
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
796
+ const data = (await res.json()) as {
797
+ resolves: boolean;
798
+ ip: string | null;
799
+ serverIp: string | null;
800
+ };
801
+ const lines = [
802
+ `Domain: ${domain}`,
803
+ `Resolves: ${data.resolves ? "yes" : "no"}`,
804
+ `Resolved IP: ${data.ip ?? "(none)"}`,
805
+ `Server IP: ${data.serverIp ?? "(unknown)"}`,
806
+ ];
807
+ if (data.ip && data.serverIp) {
808
+ lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`);
809
+ }
810
+ return { content: [{ type: "text", text: lines.join("\n") }] };
811
+ },
812
+ );
813
+
814
+ server.registerTool(
815
+ "moor_deploy",
816
+ {
817
+ title: "Deploy Project",
818
+ description:
819
+ "Create-or-update a project end to end: metadata, env vars (merged into existing), and an optional build/run. Default fails if the project already exists; pass update_existing: true to upsert. When run: true (default), waits for the full Docker build/pull and start, which can take minutes for large images. Errors are tagged by the failing step ([create], [update], [set_env], or [run]) and do not roll back earlier steps.",
820
+ inputSchema: z.object({
821
+ name: z
822
+ .string()
823
+ .regex(
824
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
825
+ "name must start alphanumeric; allowed chars: a-z A-Z 0-9 _ -",
826
+ )
827
+ .describe("Project name (also the container suffix: moor-<name>)"),
828
+ github_url: z
829
+ .string()
830
+ .optional()
831
+ .describe(
832
+ "GitHub repo URL: host must be github.com or www.github.com, path must be /owner/repo (optional .git). Mutually exclusive with docker_image.",
833
+ ),
834
+ docker_image: z
835
+ .string()
836
+ .optional()
837
+ .describe(
838
+ "Docker image reference (e.g. nginx:latest). Mutually exclusive with github_url.",
839
+ ),
840
+ branch: z.string().optional().describe("Git branch (API default: main)"),
841
+ dockerfile: z
842
+ .string()
843
+ .optional()
844
+ .describe("Dockerfile path in the repo (API default: Dockerfile)"),
845
+ domain: z.string().optional().describe("Public domain to route via Caddy"),
846
+ domain_port: z
847
+ .number()
848
+ .int()
849
+ .positive()
850
+ .optional()
851
+ .describe("Container port Caddy should forward to"),
852
+ restart_policy: z
853
+ .enum(["no", "on-failure", "always", "unless-stopped"])
854
+ .optional()
855
+ .describe("Docker restart policy (API default: unless-stopped)"),
856
+ env: z
857
+ .record(z.string(), z.string())
858
+ .optional()
859
+ .describe(
860
+ "Env vars to MERGE into existing project envs. Omit to leave envs untouched. Pass {} for an explicit no-op. Use moor_env_delete to remove keys.",
861
+ ),
862
+ run: z
863
+ .boolean()
864
+ .optional()
865
+ .default(true)
866
+ .describe(
867
+ "Build/pull and start after create/update. Default true. Setting false leaves the container untouched; if envs changed while the container is running, the change will not apply until the next run/restart.",
868
+ ),
869
+ update_existing: z
870
+ .boolean()
871
+ .optional()
872
+ .default(false)
873
+ .describe("Allow updating a project that already exists. Default false (create-only)."),
874
+ }),
875
+ },
876
+ async (input) => {
877
+ // Up-front validation: do strict checks before any side effects.
878
+ if (input.github_url) validateGithubRepoUrl(input.github_url);
879
+ if (input.github_url && input.docker_image) {
880
+ throw new Error("Cannot set both github_url and docker_image");
881
+ }
882
+
883
+ // Resolve existence and check domain conflicts from a single project list.
884
+ const listRes = await apiGet("/api/projects");
885
+ if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`);
886
+ const projects = (await listRes.json()) as Project[];
887
+ const existing = projects.find((p) => p.name === input.name);
888
+
889
+ if (existing && !input.update_existing) {
890
+ throw new Error(
891
+ `Project "${input.name}" already exists. Pass update_existing: true to update it.`,
892
+ );
893
+ }
894
+
895
+ if (!existing) {
896
+ const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
897
+ if (sources !== 1) {
898
+ throw new Error("Provide exactly one of github_url or docker_image");
899
+ }
900
+ }
901
+
902
+ // Normalize once for both the conflict check and the write. The API trims but
903
+ // does not lowercase, so " Example.com " vs an existing "example.com" would
904
+ // slip past the raw-string pre-check and only surface as a Caddy collision.
905
+ const normalizedDomain =
906
+ input.domain === undefined ? undefined : input.domain.trim().toLowerCase() || null;
907
+
908
+ if (normalizedDomain) {
909
+ const conflict = projects.find(
910
+ (p) =>
911
+ p.domain && p.domain.trim().toLowerCase() === normalizedDomain && p.id !== existing?.id,
912
+ );
913
+ if (conflict) {
914
+ throw new Error(
915
+ `Domain "${normalizedDomain}" is already used by project "${conflict.name}" (id=${conflict.id}). Refusing before Caddy reload.`,
916
+ );
917
+ }
918
+ }
919
+
920
+ // Step 1: create or update project metadata.
921
+ let projectId: number;
922
+ let projectName: string;
923
+ if (!existing) {
924
+ const createBody: Record<string, unknown> = {
925
+ name: input.name,
926
+ github_url: input.github_url,
927
+ docker_image: input.docker_image,
928
+ branch: input.branch,
929
+ dockerfile: input.dockerfile,
930
+ domain: normalizedDomain,
931
+ domain_port: input.domain_port,
932
+ restart_policy: input.restart_policy,
933
+ };
934
+ const res = await apiPost("/api/projects", createBody);
935
+ if (!res.ok) throw new Error(`[create] ${await res.text()}`);
936
+ const created = (await res.json()) as Project;
937
+ projectId = created.id;
938
+ projectName = created.name;
939
+ } else {
940
+ // Update only fields explicitly provided. `name` is the lookup key here,
941
+ // not a rename target; use moor_project_update for renames.
942
+ const updateBody: Record<string, unknown> = {};
943
+ if (input.github_url !== undefined) updateBody.github_url = input.github_url;
944
+ if (input.docker_image !== undefined) updateBody.docker_image = input.docker_image;
945
+ if (input.branch !== undefined) updateBody.branch = input.branch;
946
+ if (input.dockerfile !== undefined) updateBody.dockerfile = input.dockerfile;
947
+ if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain;
948
+ if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port;
949
+ if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy;
950
+
951
+ if (Object.keys(updateBody).length > 0) {
952
+ const res = await apiPut(`/api/projects/${existing.id}`, updateBody);
953
+ if (!res.ok) throw new Error(`[update] ${await res.text()}`);
954
+ }
955
+ projectId = existing.id;
956
+ projectName = existing.name;
957
+ }
958
+
959
+ // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op.
960
+ const envEntries = input.env ? Object.entries(input.env) : [];
961
+ const envProvided = envEntries.length > 0;
962
+ if (envProvided) {
963
+ const existingRes = await apiGet(`/api/projects/${projectId}/envs`);
964
+ if (!existingRes.ok) {
965
+ throw new Error(`[set_env] Failed to read envs: ${existingRes.status}`);
966
+ }
967
+ const existingEnvs = (await existingRes.json()) as { key: string; value: string }[];
968
+ const merged = new Map(existingEnvs.map((v) => [v.key, v.value]));
969
+ for (const [k, v] of envEntries) merged.set(k, v);
970
+ const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
971
+ const putRes = await apiPut(`/api/projects/${projectId}/envs`, allVars);
972
+ if (!putRes.ok) throw new Error(`[set_env] ${await putRes.text()}`);
973
+ }
974
+
975
+ // Step 3: run, default true. Wait for the full SSE stream like moor_rebuild.
976
+ let runLogs = "";
977
+ if (input.run) {
978
+ const runRes = await apiPost(`/api/projects/${projectId}/run`);
979
+ if (!runRes.ok) throw new Error(`[run] ${await runRes.text()}`);
980
+ const { logs, error } = await readSSE(runRes);
981
+ runLogs = logs;
982
+ if (error) throw new Error(`[run] ${error}`);
983
+ }
984
+
985
+ const lines: string[] = [];
986
+ lines.push(
987
+ existing
988
+ ? `Updated project ${projectName} (id=${projectId}).`
989
+ : `Created project ${projectName} (id=${projectId}).`,
990
+ );
991
+ if (envProvided) {
992
+ lines.push(
993
+ `Merged ${envEntries.length} env var(s): ${envEntries.map(([k]) => k).join(", ")}.`,
994
+ );
995
+ }
996
+ if (!input.run) {
997
+ if (envProvided && existing?.status === "running") {
998
+ lines.push(
999
+ "Note: project is running; env changes will not take effect until the next run or restart.",
1000
+ );
1001
+ }
1002
+ } else {
1003
+ lines.push("");
1004
+ lines.push("Build/run output:");
1005
+ lines.push(runLogs || "(no output)");
1006
+ }
1007
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1008
+ },
1009
+ );
1010
+
343
1011
  // --- Start ---
344
1012
 
345
1013
  const transport = new StdioServerTransport();