@moor-sh/mcp 0.2.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 +1 -1
  2. package/src/index.ts +233 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.2.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": {
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. */
@@ -775,6 +811,203 @@ server.registerTool(
775
811
  },
776
812
  );
777
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
+
778
1011
  // --- Start ---
779
1012
 
780
1013
  const transport = new StdioServerTransport();