@moor-sh/mcp 0.2.1 → 0.4.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 +248 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.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
@@ -152,6 +152,42 @@ function validateGithubUrl(url: string): void {
152
152
  }
153
153
  }
154
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
+
155
191
  /** Validate a 5-field crontab schedule against what apps/api/cron.ts can actually execute.
156
192
  * Stricter than the scheduler's permissive parser: the scheduler silently never fires
157
193
  * on bad input, so MCP rejects up-front. Returns an error string or null. */
@@ -328,15 +364,27 @@ server.registerTool(
328
364
  "moor_exec",
329
365
  {
330
366
  title: "Execute Command",
331
- description: "Run a shell command inside a project's running container.",
367
+ description:
368
+ "Run a shell command inside a project's running container. Bounded by a per-call timeout (default 10 min, max 1 h). For jobs that may exceed an hour, wait for the async exec tools to ship.",
332
369
  inputSchema: z.object({
333
370
  project: z.string().describe("Project name or ID"),
334
371
  command: z.string().describe("Shell command to execute"),
372
+ timeout_ms: z
373
+ .number()
374
+ .int()
375
+ .min(1000)
376
+ .max(3_600_000)
377
+ .optional()
378
+ .describe(
379
+ "Max time in milliseconds before the exec is aborted. Default 600000 (10 min). Max 3600000 (1 h).",
380
+ ),
335
381
  }),
336
382
  },
337
- async ({ project, command }) => {
383
+ async ({ project, command, timeout_ms }) => {
338
384
  const p = await resolveProject(project);
339
- const res = await apiPost(`/api/projects/${p.id}/exec`, { command });
385
+ const body: Record<string, unknown> = { command };
386
+ if (timeout_ms !== undefined) body.timeout_ms = timeout_ms;
387
+ const res = await apiPost(`/api/projects/${p.id}/exec`, body);
340
388
  if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
341
389
  const result = (await res.json()) as {
342
390
  exitCode: number;
@@ -775,6 +823,203 @@ server.registerTool(
775
823
  },
776
824
  );
777
825
 
826
+ server.registerTool(
827
+ "moor_deploy",
828
+ {
829
+ title: "Deploy Project",
830
+ description:
831
+ "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.",
832
+ inputSchema: z.object({
833
+ name: z
834
+ .string()
835
+ .regex(
836
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
837
+ "name must start alphanumeric; allowed chars: a-z A-Z 0-9 _ -",
838
+ )
839
+ .describe("Project name (also the container suffix: moor-<name>)"),
840
+ github_url: z
841
+ .string()
842
+ .optional()
843
+ .describe(
844
+ "GitHub repo URL: host must be github.com or www.github.com, path must be /owner/repo (optional .git). Mutually exclusive with docker_image.",
845
+ ),
846
+ docker_image: z
847
+ .string()
848
+ .optional()
849
+ .describe(
850
+ "Docker image reference (e.g. nginx:latest). Mutually exclusive with github_url.",
851
+ ),
852
+ branch: z.string().optional().describe("Git branch (API default: main)"),
853
+ dockerfile: z
854
+ .string()
855
+ .optional()
856
+ .describe("Dockerfile path in the repo (API default: Dockerfile)"),
857
+ domain: z.string().optional().describe("Public domain to route via Caddy"),
858
+ domain_port: z
859
+ .number()
860
+ .int()
861
+ .positive()
862
+ .optional()
863
+ .describe("Container port Caddy should forward to"),
864
+ restart_policy: z
865
+ .enum(["no", "on-failure", "always", "unless-stopped"])
866
+ .optional()
867
+ .describe("Docker restart policy (API default: unless-stopped)"),
868
+ env: z
869
+ .record(z.string(), z.string())
870
+ .optional()
871
+ .describe(
872
+ "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.",
873
+ ),
874
+ run: z
875
+ .boolean()
876
+ .optional()
877
+ .default(true)
878
+ .describe(
879
+ "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.",
880
+ ),
881
+ update_existing: z
882
+ .boolean()
883
+ .optional()
884
+ .default(false)
885
+ .describe("Allow updating a project that already exists. Default false (create-only)."),
886
+ }),
887
+ },
888
+ async (input) => {
889
+ // Up-front validation: do strict checks before any side effects.
890
+ if (input.github_url) validateGithubRepoUrl(input.github_url);
891
+ if (input.github_url && input.docker_image) {
892
+ throw new Error("Cannot set both github_url and docker_image");
893
+ }
894
+
895
+ // Resolve existence and check domain conflicts from a single project list.
896
+ const listRes = await apiGet("/api/projects");
897
+ if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`);
898
+ const projects = (await listRes.json()) as Project[];
899
+ const existing = projects.find((p) => p.name === input.name);
900
+
901
+ if (existing && !input.update_existing) {
902
+ throw new Error(
903
+ `Project "${input.name}" already exists. Pass update_existing: true to update it.`,
904
+ );
905
+ }
906
+
907
+ if (!existing) {
908
+ const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
909
+ if (sources !== 1) {
910
+ throw new Error("Provide exactly one of github_url or docker_image");
911
+ }
912
+ }
913
+
914
+ // Normalize once for both the conflict check and the write. The API trims but
915
+ // does not lowercase, so " Example.com " vs an existing "example.com" would
916
+ // slip past the raw-string pre-check and only surface as a Caddy collision.
917
+ const normalizedDomain =
918
+ input.domain === undefined ? undefined : input.domain.trim().toLowerCase() || null;
919
+
920
+ if (normalizedDomain) {
921
+ const conflict = projects.find(
922
+ (p) =>
923
+ p.domain && p.domain.trim().toLowerCase() === normalizedDomain && p.id !== existing?.id,
924
+ );
925
+ if (conflict) {
926
+ throw new Error(
927
+ `Domain "${normalizedDomain}" is already used by project "${conflict.name}" (id=${conflict.id}). Refusing before Caddy reload.`,
928
+ );
929
+ }
930
+ }
931
+
932
+ // Step 1: create or update project metadata.
933
+ let projectId: number;
934
+ let projectName: string;
935
+ if (!existing) {
936
+ const createBody: Record<string, unknown> = {
937
+ name: input.name,
938
+ github_url: input.github_url,
939
+ docker_image: input.docker_image,
940
+ branch: input.branch,
941
+ dockerfile: input.dockerfile,
942
+ domain: normalizedDomain,
943
+ domain_port: input.domain_port,
944
+ restart_policy: input.restart_policy,
945
+ };
946
+ const res = await apiPost("/api/projects", createBody);
947
+ if (!res.ok) throw new Error(`[create] ${await res.text()}`);
948
+ const created = (await res.json()) as Project;
949
+ projectId = created.id;
950
+ projectName = created.name;
951
+ } else {
952
+ // Update only fields explicitly provided. `name` is the lookup key here,
953
+ // not a rename target; use moor_project_update for renames.
954
+ const updateBody: Record<string, unknown> = {};
955
+ if (input.github_url !== undefined) updateBody.github_url = input.github_url;
956
+ if (input.docker_image !== undefined) updateBody.docker_image = input.docker_image;
957
+ if (input.branch !== undefined) updateBody.branch = input.branch;
958
+ if (input.dockerfile !== undefined) updateBody.dockerfile = input.dockerfile;
959
+ if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain;
960
+ if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port;
961
+ if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy;
962
+
963
+ if (Object.keys(updateBody).length > 0) {
964
+ const res = await apiPut(`/api/projects/${existing.id}`, updateBody);
965
+ if (!res.ok) throw new Error(`[update] ${await res.text()}`);
966
+ }
967
+ projectId = existing.id;
968
+ projectName = existing.name;
969
+ }
970
+
971
+ // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op.
972
+ const envEntries = input.env ? Object.entries(input.env) : [];
973
+ const envProvided = envEntries.length > 0;
974
+ if (envProvided) {
975
+ const existingRes = await apiGet(`/api/projects/${projectId}/envs`);
976
+ if (!existingRes.ok) {
977
+ throw new Error(`[set_env] Failed to read envs: ${existingRes.status}`);
978
+ }
979
+ const existingEnvs = (await existingRes.json()) as { key: string; value: string }[];
980
+ const merged = new Map(existingEnvs.map((v) => [v.key, v.value]));
981
+ for (const [k, v] of envEntries) merged.set(k, v);
982
+ const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
983
+ const putRes = await apiPut(`/api/projects/${projectId}/envs`, allVars);
984
+ if (!putRes.ok) throw new Error(`[set_env] ${await putRes.text()}`);
985
+ }
986
+
987
+ // Step 3: run, default true. Wait for the full SSE stream like moor_rebuild.
988
+ let runLogs = "";
989
+ if (input.run) {
990
+ const runRes = await apiPost(`/api/projects/${projectId}/run`);
991
+ if (!runRes.ok) throw new Error(`[run] ${await runRes.text()}`);
992
+ const { logs, error } = await readSSE(runRes);
993
+ runLogs = logs;
994
+ if (error) throw new Error(`[run] ${error}`);
995
+ }
996
+
997
+ const lines: string[] = [];
998
+ lines.push(
999
+ existing
1000
+ ? `Updated project ${projectName} (id=${projectId}).`
1001
+ : `Created project ${projectName} (id=${projectId}).`,
1002
+ );
1003
+ if (envProvided) {
1004
+ lines.push(
1005
+ `Merged ${envEntries.length} env var(s): ${envEntries.map(([k]) => k).join(", ")}.`,
1006
+ );
1007
+ }
1008
+ if (!input.run) {
1009
+ if (envProvided && existing?.status === "running") {
1010
+ lines.push(
1011
+ "Note: project is running; env changes will not take effect until the next run or restart.",
1012
+ );
1013
+ }
1014
+ } else {
1015
+ lines.push("");
1016
+ lines.push("Build/run output:");
1017
+ lines.push(runLogs || "(no output)");
1018
+ }
1019
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1020
+ },
1021
+ );
1022
+
778
1023
  // --- Start ---
779
1024
 
780
1025
  const transport = new StdioServerTransport();