@moor-sh/mcp 0.20.0 → 0.22.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 +314 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.20.0",
3
+ "version": "0.22.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
@@ -1174,6 +1174,15 @@ server.registerTool(
1174
1174
  .describe(
1175
1175
  "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.",
1176
1176
  ),
1177
+ source_credential_id: z
1178
+ .number()
1179
+ .int()
1180
+ .positive()
1181
+ .nullable()
1182
+ .optional()
1183
+ .describe(
1184
+ "For github_url projects: pin the source credential row (from moor_source_credential_add) the build path should use. Build synthesizes the credentialed clone URL in memory; the secret is never stored on the project. Ignored when docker_image is set; save-time validation is structural only (id exists).",
1185
+ ),
1177
1186
  }),
1178
1187
  },
1179
1188
  async (input) => {
@@ -1254,6 +1263,15 @@ server.registerTool(
1254
1263
  .describe(
1255
1264
  "Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate.",
1256
1265
  ),
1266
+ source_credential_id: z
1267
+ .number()
1268
+ .int()
1269
+ .positive()
1270
+ .nullable()
1271
+ .optional()
1272
+ .describe(
1273
+ "Pin (or unlink, by passing null) the source credential the build path should use for this github_url project. Switching to docker_image force-clears the id regardless of input. Save-time validation is structural only; host-mismatch / not-active is enforced at build time.",
1274
+ ),
1257
1275
  }),
1258
1276
  },
1259
1277
  async (input) => {
@@ -1879,6 +1897,15 @@ server.registerTool(
1879
1897
  .describe(
1880
1898
  "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.",
1881
1899
  ),
1900
+ source_credential_id: z
1901
+ .number()
1902
+ .int()
1903
+ .positive()
1904
+ .nullable()
1905
+ .optional()
1906
+ .describe(
1907
+ "For github_url projects: pin the source credential row (created via moor_source_credential_add). Build path synthesizes the credentialed clone URL in memory; secret never gets stored on the project row. Pass null to detach without switching source type. Ignored when docker_image is set. Save-time validation is structural only (id exists); host-mismatch / not-active is enforced at build time so configuration can survive transient credential outages.",
1908
+ ),
1882
1909
  run: z
1883
1910
  .boolean()
1884
1911
  .optional()
@@ -1978,6 +2005,7 @@ server.registerTool(
1978
2005
  restart_policy: input.restart_policy,
1979
2006
  memory_limit_mb: input.memory_limit_mb,
1980
2007
  cpus: input.cpus,
2008
+ source_credential_id: input.source_credential_id,
1981
2009
  };
1982
2010
  const res = await apiPost("/api/projects", createBody);
1983
2011
  if (!res.ok) throw new Error(`[create] ${await res.text()}`);
@@ -1997,6 +2025,8 @@ server.registerTool(
1997
2025
  if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy;
1998
2026
  if (input.memory_limit_mb !== undefined) updateBody.memory_limit_mb = input.memory_limit_mb;
1999
2027
  if (input.cpus !== undefined) updateBody.cpus = input.cpus;
2028
+ if (input.source_credential_id !== undefined)
2029
+ updateBody.source_credential_id = input.source_credential_id;
2000
2030
 
2001
2031
  if (Object.keys(updateBody).length > 0) {
2002
2032
  const res = await apiPut(`/api/projects/${existing.id}`, updateBody);
@@ -2442,6 +2472,290 @@ server.registerTool(
2442
2472
  },
2443
2473
  );
2444
2474
 
2475
+ // --- Source credentials (HTTPS PATs for private Git repos) ---
2476
+ //
2477
+ // v1 ships HTTPS PATs only. Read paths return metadata + secret.kind;
2478
+ // the raw token only crosses MCP on `add` and `update`. Delete uses
2479
+ // confirm_label since (hostname, label) is the disambiguation key
2480
+ // (multiple github.com rows coexist by design). The check tool runs a
2481
+ // real `git ls-remote` inside moor to validate access before any
2482
+ // deploy commits state.
2483
+
2484
+ type SourceCredentialMetadata = {
2485
+ id: number;
2486
+ hostname: string;
2487
+ label: string;
2488
+ username: string;
2489
+ secret: {
2490
+ configured: true;
2491
+ kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown";
2492
+ };
2493
+ state: "active" | "failed";
2494
+ expires_at: string | null;
2495
+ last_checked_at: string | null;
2496
+ last_check_status: string | null;
2497
+ created_at: string;
2498
+ updated_at: string;
2499
+ };
2500
+
2501
+ function renderSourceCredentialLine(c: SourceCredentialMetadata): string {
2502
+ const checked = c.last_check_status ? ` last_check=${c.last_check_status}` : "";
2503
+ return `id=${c.id} ${c.hostname} label=${c.label} user=${c.username} kind=${c.secret.kind} state=${c.state}${checked}`;
2504
+ }
2505
+
2506
+ server.registerTool(
2507
+ "moor_source_credentials_list",
2508
+ {
2509
+ title: "List Source Credentials",
2510
+ description:
2511
+ "List all stored Git source credentials (HTTPS PATs). Returns metadata only - the raw secret value is never returned by any read path. Multiple credentials can share a hostname (e.g. two github.com rows for different orgs); use label to disambiguate.",
2512
+ },
2513
+ async () => {
2514
+ const res = await apiGet("/api/server/source-credentials");
2515
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2516
+ const data = (await res.json()) as { rows: SourceCredentialMetadata[] };
2517
+ const text =
2518
+ data.rows.length === 0
2519
+ ? "No source credentials configured. Public repos work anonymously."
2520
+ : data.rows.map(renderSourceCredentialLine).join("\n");
2521
+ return {
2522
+ content: [{ type: "text", text }],
2523
+ structuredContent: { rows: data.rows },
2524
+ };
2525
+ },
2526
+ );
2527
+
2528
+ server.registerTool(
2529
+ "moor_source_credential_get",
2530
+ {
2531
+ title: "Get Source Credential",
2532
+ description:
2533
+ "Get a single source credential by id. Returns metadata only. Use this before moor_source_credential_delete to confirm the label you intend to delete.",
2534
+ inputSchema: z.object({
2535
+ id: z.number().int().positive().describe("Credential id (from moor_source_credentials_list)"),
2536
+ }),
2537
+ },
2538
+ async ({ id }) => {
2539
+ const res = await apiGet(`/api/server/source-credentials/${id}`);
2540
+ if (res.status === 404) throw new Error(`source credential id=${id} not found`);
2541
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2542
+ const row = (await res.json()) as SourceCredentialMetadata;
2543
+ return {
2544
+ content: [{ type: "text", text: renderSourceCredentialLine(row) }],
2545
+ structuredContent: row as unknown as Record<string, unknown>,
2546
+ };
2547
+ },
2548
+ );
2549
+
2550
+ server.registerTool(
2551
+ "moor_source_credential_add",
2552
+ {
2553
+ title: "Add Source Credential",
2554
+ description:
2555
+ "Store a Git source credential (HTTPS PAT) for a private repo host. v1 supports HTTPS PATs only; SSH deploy keys may be added in a future version. Hostname must be the bare host as parsed from a Git URL (github.com, gitlab.com, etc.) - no scheme, no path. Multiple credentials can share a hostname; the (hostname, label) pair is unique. For GitHub: use a fine-grained PAT with `Contents: read` (username `x-access-token`), or a classic PAT with `repo` scope. Note: the secret value passes through the MCP client and tool-call transport on input, same security model as moor_env_set.",
2556
+ inputSchema: z.object({
2557
+ hostname: z
2558
+ .string()
2559
+ .describe("Bare Git host: github.com, gitlab.com, etc. No scheme, no path."),
2560
+ label: z
2561
+ .string()
2562
+ .describe(
2563
+ "Operator-supplied label for disambiguation when multiple credentials share a host (e.g. 'personal', 'work-org', 'acme-clients'). Trimmed at storage.",
2564
+ ),
2565
+ username: z
2566
+ .string()
2567
+ .describe(
2568
+ "Git username. For GitHub fine-grained PATs, use 'x-access-token'. For classic PATs, your GitHub username works too.",
2569
+ ),
2570
+ secret: z
2571
+ .string()
2572
+ .describe(
2573
+ "Git token (PAT). Visible to the MCP client on input; rotate via moor_source_credential_update if exposed.",
2574
+ ),
2575
+ expires_at: z
2576
+ .string()
2577
+ .nullable()
2578
+ .optional()
2579
+ .describe(
2580
+ "Operator-supplied expiry timestamp (PAT expiry from GitHub). Optional; helps rotation reminders.",
2581
+ ),
2582
+ }),
2583
+ },
2584
+ async ({ hostname, label, username, secret, expires_at }) => {
2585
+ const body: Record<string, unknown> = { hostname, label, username, secret };
2586
+ if (expires_at !== undefined) body.expires_at = expires_at;
2587
+ const res = await apiPost("/api/server/source-credentials", body);
2588
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2589
+ const row = (await res.json()) as SourceCredentialMetadata;
2590
+ return {
2591
+ content: [
2592
+ {
2593
+ type: "text",
2594
+ text: `Added source credential for ${row.hostname} label="${row.label}" (id=${row.id}, kind=${row.secret.kind}).`,
2595
+ },
2596
+ ],
2597
+ structuredContent: row as unknown as Record<string, unknown>,
2598
+ };
2599
+ },
2600
+ );
2601
+
2602
+ server.registerTool(
2603
+ "moor_source_credential_update",
2604
+ {
2605
+ title: "Update Source Credential",
2606
+ description:
2607
+ "Rotate username, secret, label, or expires_at on an existing source credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break in-flight builds. To change hostname, delete and recreate. Requires at least one of username, secret, label, or expires_at.",
2608
+ inputSchema: z.object({
2609
+ id: z.number().int().positive().describe("Credential id to update"),
2610
+ username: z.string().optional().describe("New username (optional)"),
2611
+ secret: z
2612
+ .string()
2613
+ .optional()
2614
+ .describe("New secret (optional). Visible to the MCP client on input."),
2615
+ label: z.string().optional().describe("New label (optional). Trimmed at storage."),
2616
+ expires_at: z.string().nullable().optional().describe("New expiry; null to clear"),
2617
+ }),
2618
+ },
2619
+ async ({ id, username, secret, label, expires_at }) => {
2620
+ if (
2621
+ username === undefined &&
2622
+ secret === undefined &&
2623
+ label === undefined &&
2624
+ expires_at === undefined
2625
+ ) {
2626
+ throw new Error("must provide at least one of username, secret, label, or expires_at");
2627
+ }
2628
+ const patch: Record<string, unknown> = {};
2629
+ if (username !== undefined) patch.username = username;
2630
+ if (secret !== undefined) patch.secret = secret;
2631
+ if (label !== undefined) patch.label = label;
2632
+ if (expires_at !== undefined) patch.expires_at = expires_at;
2633
+ const res = await apiPut(`/api/server/source-credentials/${id}`, patch);
2634
+ if (res.status === 404) throw new Error(`source credential id=${id} not found`);
2635
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2636
+ const row = (await res.json()) as SourceCredentialMetadata;
2637
+ const fields: string[] = [];
2638
+ if (username !== undefined) fields.push("username");
2639
+ if (secret !== undefined) fields.push("secret");
2640
+ if (label !== undefined) fields.push("label");
2641
+ if (expires_at !== undefined) fields.push("expires_at");
2642
+ return {
2643
+ content: [
2644
+ {
2645
+ type: "text",
2646
+ text: `Updated source credential id=${id} (${row.hostname}, ${row.label}): rotated ${fields.join(" + ")}. kind=${row.secret.kind}.`,
2647
+ },
2648
+ ],
2649
+ structuredContent: row as unknown as Record<string, unknown>,
2650
+ };
2651
+ },
2652
+ );
2653
+
2654
+ server.registerTool(
2655
+ "moor_source_credential_delete",
2656
+ {
2657
+ title: "Delete Source Credential",
2658
+ description:
2659
+ "Delete a stored source credential. Requires confirm_label to match the resolved row's label exactly - protects against deleting the wrong credential on a host that has several (e.g. two github.com rows). Refused with credential_in_use if any project still references this credential. Irreversible.",
2660
+ inputSchema: z.object({
2661
+ id: z.number().int().positive().describe("Credential id to delete"),
2662
+ confirm_label: z
2663
+ .string()
2664
+ .describe(
2665
+ "Must equal the credential row's label exactly. Resolved via moor_source_credential_get and compared before deletion.",
2666
+ ),
2667
+ }),
2668
+ },
2669
+ async ({ id, confirm_label }) => {
2670
+ const getRes = await apiGet(`/api/server/source-credentials/${id}`);
2671
+ if (getRes.status === 404) throw new Error(`source credential id=${id} not found`);
2672
+ if (!getRes.ok)
2673
+ throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`);
2674
+ const row = (await getRes.json()) as SourceCredentialMetadata;
2675
+ if (confirm_label !== row.label) {
2676
+ throw new Error(
2677
+ `confirm_label "${confirm_label}" does not match resolved label "${row.label}". Refusing to delete.`,
2678
+ );
2679
+ }
2680
+ const delRes = await apiDelete(
2681
+ `/api/server/source-credentials/${id}?confirm_label=${encodeURIComponent(row.label)}`,
2682
+ );
2683
+ if (delRes.status === 409) {
2684
+ const body = (await delRes.json()) as { projects: string[] };
2685
+ throw new Error(
2686
+ `credential_in_use: ${body.projects.length} project(s) still reference id=${id}: ${body.projects.join(", ")}`,
2687
+ );
2688
+ }
2689
+ if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`);
2690
+ return {
2691
+ content: [
2692
+ {
2693
+ type: "text",
2694
+ text: `Deleted source credential ${row.hostname}/${row.label} (id=${id}).`,
2695
+ },
2696
+ ],
2697
+ structuredContent: { deleted: { id, hostname: row.hostname, label: row.label } },
2698
+ };
2699
+ },
2700
+ );
2701
+
2702
+ server.registerTool(
2703
+ "moor_source_credential_check",
2704
+ {
2705
+ title: "Check Source Credential Access",
2706
+ description:
2707
+ "Run a real `git ls-remote` against the repo URL to verify access. Without source_credential_id, probes anonymously first; if private and exactly one credential matches the host, auto-selects it. With source_credential_id, tests that exact credential. With branch, tests the specific branch (branch_not_found is distinct from auth failure). Discovers default_branch via HEAD symref when no branch is provided. Side effect: updates last_checked_at and last_check_status on the credential row; flips state to failed on a credentialed rejection.",
2708
+ inputSchema: z.object({
2709
+ github_url: z
2710
+ .string()
2711
+ .describe(
2712
+ "HTTPS Git repo URL: https://host/owner/repo (optional .git). No query, no fragment, no embedded credentials.",
2713
+ ),
2714
+ branch: z
2715
+ .string()
2716
+ .optional()
2717
+ .describe("Specific branch to verify. Omit to discover the default branch."),
2718
+ source_credential_id: z
2719
+ .number()
2720
+ .int()
2721
+ .positive()
2722
+ .optional()
2723
+ .describe("Pin a specific credential. Required when multiple credentials match the host."),
2724
+ }),
2725
+ },
2726
+ async ({ github_url, branch, source_credential_id }) => {
2727
+ const body: Record<string, unknown> = { github_url };
2728
+ if (branch !== undefined) body.branch = branch;
2729
+ if (source_credential_id !== undefined) body.source_credential_id = source_credential_id;
2730
+ const res = await apiPost("/api/server/source-credentials/check", body);
2731
+ const json = (await res.json()) as Record<string, unknown>;
2732
+ if (res.ok) {
2733
+ const def = json.default_branch ? ` default_branch=${json.default_branch}` : "";
2734
+ const head = json.head_sha ? ` head_sha=${String(json.head_sha).slice(0, 12)}` : "";
2735
+ const auto = json.auto_selected_credential_id
2736
+ ? ` auto_selected=${json.auto_selected_credential_id}`
2737
+ : "";
2738
+ return {
2739
+ content: [{ type: "text", text: `reachable${def}${head}${auto}` }],
2740
+ structuredContent: json,
2741
+ };
2742
+ }
2743
+ // Non-OK: surface the structured failure shape directly. The agent
2744
+ // can branch on `code` to decide what to do next (ask for a PAT,
2745
+ // pick from candidates, etc.).
2746
+ return {
2747
+ content: [
2748
+ {
2749
+ type: "text",
2750
+ text: `check failed: code=${json.code}${json.reason ? ` reason=${json.reason}` : ""}`,
2751
+ },
2752
+ ],
2753
+ structuredContent: json,
2754
+ isError: true,
2755
+ };
2756
+ },
2757
+ );
2758
+
2445
2759
  function formatMs(ms: number): string {
2446
2760
  if (ms < 1000) return `${ms}ms`;
2447
2761
  const s = Math.floor(ms / 1000);