@moor-sh/mcp 0.19.0 → 0.21.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 +469 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.19.0",
3
+ "version": "0.21.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
@@ -2257,6 +2257,475 @@ server.registerTool(
2257
2257
  },
2258
2258
  );
2259
2259
 
2260
+ // --- Registry credentials ---
2261
+ //
2262
+ // Server-wide credentials used by the pull path to attach
2263
+ // X-Registry-Auth on /images/create for private images. Read shape
2264
+ // is write-only: the raw secret never leaves the API. Tool descriptions
2265
+ // flag that *inputs* (add/update) carry the secret over the tool-call
2266
+ // path and are visible to the MCP client - same security model as
2267
+ // moor_env_set. structuredContent mirrors the API metadata shape so
2268
+ // agents can reason over it without scraping the human text.
2269
+
2270
+ type RegistryCredentialMetadata = {
2271
+ id: number;
2272
+ hostname: string;
2273
+ username: string;
2274
+ secret: { configured: true; kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown" };
2275
+ created_at: string;
2276
+ updated_at: string;
2277
+ };
2278
+
2279
+ function renderCredentialLine(c: RegistryCredentialMetadata): string {
2280
+ return `id=${c.id} ${c.hostname} user=${c.username} kind=${c.secret.kind} updated=${c.updated_at}`;
2281
+ }
2282
+
2283
+ server.registerTool(
2284
+ "moor_registry_credentials_list",
2285
+ {
2286
+ title: "List Registry Credentials",
2287
+ description:
2288
+ "List all stored Docker registry credentials. Returns metadata only - the raw secret value is never returned by any read path. Each row carries `secret: { configured: true, kind }` where kind is derived from known token prefixes (github_classic_pat, github_fine_grained_pat) or 'unknown'.",
2289
+ },
2290
+ async () => {
2291
+ const res = await apiGet("/api/server/registry-credentials");
2292
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2293
+ const data = (await res.json()) as { rows: RegistryCredentialMetadata[] };
2294
+ const text =
2295
+ data.rows.length === 0
2296
+ ? "No registry credentials configured. The pull path falls back to anonymous for every registry."
2297
+ : data.rows.map(renderCredentialLine).join("\n");
2298
+ return {
2299
+ content: [{ type: "text", text }],
2300
+ structuredContent: { rows: data.rows },
2301
+ };
2302
+ },
2303
+ );
2304
+
2305
+ server.registerTool(
2306
+ "moor_registry_credential_get",
2307
+ {
2308
+ title: "Get Registry Credential",
2309
+ description:
2310
+ "Get a single stored registry credential by id. Returns metadata only - the raw secret is never returned. Use this before moor_registry_credential_delete to confirm the hostname you intend to delete.",
2311
+ inputSchema: z.object({
2312
+ id: z
2313
+ .number()
2314
+ .int()
2315
+ .positive()
2316
+ .describe("Credential id (from moor_registry_credentials_list)"),
2317
+ }),
2318
+ },
2319
+ async ({ id }) => {
2320
+ const res = await apiGet(`/api/server/registry-credentials/${id}`);
2321
+ if (res.status === 404) throw new Error(`credential id=${id} not found`);
2322
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2323
+ const row = (await res.json()) as RegistryCredentialMetadata;
2324
+ return {
2325
+ content: [{ type: "text", text: renderCredentialLine(row) }],
2326
+ structuredContent: row as unknown as Record<string, unknown>,
2327
+ };
2328
+ },
2329
+ );
2330
+
2331
+ server.registerTool(
2332
+ "moor_registry_credential_add",
2333
+ {
2334
+ title: "Add Registry Credential",
2335
+ description:
2336
+ "Store a credential for a Docker registry. The pull path will attach X-Registry-Auth to /images/create whenever an image ref matches this hostname. Hostname must be the bare host as it appears in the image ref (e.g. ghcr.io, docker.io, localhost:5000) - no scheme, no path. Note: the secret value passes through the MCP client and tool-call transport, same security model as moor_env_set; rotate via moor_registry_credential_update if it has been exposed.",
2337
+ inputSchema: z.object({
2338
+ hostname: z
2339
+ .string()
2340
+ .describe(
2341
+ "Bare registry host as parsed from an image ref. Examples: ghcr.io, docker.io, localhost:5000, registry.example.com:5000. No scheme, no path.",
2342
+ ),
2343
+ username: z
2344
+ .string()
2345
+ .describe("Registry username. For GHCR with a classic PAT, use your GitHub username."),
2346
+ secret: z
2347
+ .string()
2348
+ .describe(
2349
+ "Registry password or token. For GHCR, a classic PAT with read:packages is the documented path. Visible to the MCP client on input.",
2350
+ ),
2351
+ }),
2352
+ },
2353
+ async ({ hostname, username, secret }) => {
2354
+ const res = await apiPost("/api/server/registry-credentials", { hostname, username, secret });
2355
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2356
+ const row = (await res.json()) as RegistryCredentialMetadata;
2357
+ return {
2358
+ content: [
2359
+ {
2360
+ type: "text",
2361
+ text: `Added credential for ${row.hostname} (id=${row.id}, kind=${row.secret.kind}).`,
2362
+ },
2363
+ ],
2364
+ structuredContent: row as unknown as Record<string, unknown>,
2365
+ };
2366
+ },
2367
+ );
2368
+
2369
+ server.registerTool(
2370
+ "moor_registry_credential_update",
2371
+ {
2372
+ title: "Update Registry Credential",
2373
+ description:
2374
+ "Rotate username and/or secret on an existing credential. Hostname is intentionally not patchable - changing the lookup key on an existing row would silently break the pull path. To change hostnames, delete and re-create. Requires at least one of username or secret. Note: the secret value passes through the MCP client on input - same security model as moor_env_set.",
2375
+ inputSchema: z.object({
2376
+ id: z.number().int().positive().describe("Credential id to update"),
2377
+ username: z.string().optional().describe("New username (optional)"),
2378
+ secret: z
2379
+ .string()
2380
+ .optional()
2381
+ .describe("New secret (optional). Visible to the MCP client on input."),
2382
+ }),
2383
+ },
2384
+ async ({ id, username, secret }) => {
2385
+ if (username === undefined && secret === undefined) {
2386
+ throw new Error("must provide at least one of username or secret to update");
2387
+ }
2388
+ const patch: Record<string, string> = {};
2389
+ if (username !== undefined) patch.username = username;
2390
+ if (secret !== undefined) patch.secret = secret;
2391
+ const res = await apiPut(`/api/server/registry-credentials/${id}`, patch);
2392
+ if (res.status === 404) throw new Error(`credential id=${id} not found`);
2393
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2394
+ const row = (await res.json()) as RegistryCredentialMetadata;
2395
+ const rotated: string[] = [];
2396
+ if (username !== undefined) rotated.push("username");
2397
+ if (secret !== undefined) rotated.push("secret");
2398
+ return {
2399
+ content: [
2400
+ {
2401
+ type: "text",
2402
+ text: `Updated credential id=${id} (${row.hostname}): rotated ${rotated.join(" + ")}. kind=${row.secret.kind}.`,
2403
+ },
2404
+ ],
2405
+ structuredContent: row as unknown as Record<string, unknown>,
2406
+ };
2407
+ },
2408
+ );
2409
+
2410
+ server.registerTool(
2411
+ "moor_registry_credential_delete",
2412
+ {
2413
+ title: "Delete Registry Credential",
2414
+ description:
2415
+ "Delete a stored registry credential. Requires confirm_hostname to match the resolved row's hostname exactly - guards against deleting the wrong row from a stale id. After deletion, pulls for that registry fall back to anonymous. Irreversible.",
2416
+ inputSchema: z.object({
2417
+ id: z.number().int().positive().describe("Credential id to delete"),
2418
+ confirm_hostname: z
2419
+ .string()
2420
+ .describe(
2421
+ "Must equal the credential row's hostname exactly. Resolved via moor_registry_credential_get and compared before deletion.",
2422
+ ),
2423
+ }),
2424
+ },
2425
+ async ({ id, confirm_hostname }) => {
2426
+ const getRes = await apiGet(`/api/server/registry-credentials/${id}`);
2427
+ if (getRes.status === 404) throw new Error(`credential id=${id} not found`);
2428
+ if (!getRes.ok)
2429
+ throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`);
2430
+ const row = (await getRes.json()) as RegistryCredentialMetadata;
2431
+ if (confirm_hostname !== row.hostname) {
2432
+ throw new Error(
2433
+ `confirm_hostname "${confirm_hostname}" does not match resolved hostname "${row.hostname}". Refusing to delete.`,
2434
+ );
2435
+ }
2436
+ const delRes = await apiDelete(`/api/server/registry-credentials/${id}`);
2437
+ if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`);
2438
+ return {
2439
+ content: [{ type: "text", text: `Deleted credential for ${row.hostname} (id=${id}).` }],
2440
+ structuredContent: { deleted: { id, hostname: row.hostname } },
2441
+ };
2442
+ },
2443
+ );
2444
+
2445
+ // --- Source credentials (HTTPS PATs for private Git repos) ---
2446
+ //
2447
+ // v1 ships HTTPS PATs only. Read paths return metadata + secret.kind;
2448
+ // the raw token only crosses MCP on `add` and `update`. Delete uses
2449
+ // confirm_label since (hostname, label) is the disambiguation key
2450
+ // (multiple github.com rows coexist by design). The check tool runs a
2451
+ // real `git ls-remote` inside moor to validate access before any
2452
+ // deploy commits state.
2453
+
2454
+ type SourceCredentialMetadata = {
2455
+ id: number;
2456
+ hostname: string;
2457
+ label: string;
2458
+ username: string;
2459
+ secret: {
2460
+ configured: true;
2461
+ kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown";
2462
+ };
2463
+ state: "active" | "failed";
2464
+ expires_at: string | null;
2465
+ last_checked_at: string | null;
2466
+ last_check_status: string | null;
2467
+ created_at: string;
2468
+ updated_at: string;
2469
+ };
2470
+
2471
+ function renderSourceCredentialLine(c: SourceCredentialMetadata): string {
2472
+ const checked = c.last_check_status ? ` last_check=${c.last_check_status}` : "";
2473
+ return `id=${c.id} ${c.hostname} label=${c.label} user=${c.username} kind=${c.secret.kind} state=${c.state}${checked}`;
2474
+ }
2475
+
2476
+ server.registerTool(
2477
+ "moor_source_credentials_list",
2478
+ {
2479
+ title: "List Source Credentials",
2480
+ description:
2481
+ "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.",
2482
+ },
2483
+ async () => {
2484
+ const res = await apiGet("/api/server/source-credentials");
2485
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2486
+ const data = (await res.json()) as { rows: SourceCredentialMetadata[] };
2487
+ const text =
2488
+ data.rows.length === 0
2489
+ ? "No source credentials configured. Public repos work anonymously."
2490
+ : data.rows.map(renderSourceCredentialLine).join("\n");
2491
+ return {
2492
+ content: [{ type: "text", text }],
2493
+ structuredContent: { rows: data.rows },
2494
+ };
2495
+ },
2496
+ );
2497
+
2498
+ server.registerTool(
2499
+ "moor_source_credential_get",
2500
+ {
2501
+ title: "Get Source Credential",
2502
+ description:
2503
+ "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.",
2504
+ inputSchema: z.object({
2505
+ id: z.number().int().positive().describe("Credential id (from moor_source_credentials_list)"),
2506
+ }),
2507
+ },
2508
+ async ({ id }) => {
2509
+ const res = await apiGet(`/api/server/source-credentials/${id}`);
2510
+ if (res.status === 404) throw new Error(`source credential id=${id} not found`);
2511
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2512
+ const row = (await res.json()) as SourceCredentialMetadata;
2513
+ return {
2514
+ content: [{ type: "text", text: renderSourceCredentialLine(row) }],
2515
+ structuredContent: row as unknown as Record<string, unknown>,
2516
+ };
2517
+ },
2518
+ );
2519
+
2520
+ server.registerTool(
2521
+ "moor_source_credential_add",
2522
+ {
2523
+ title: "Add Source Credential",
2524
+ description:
2525
+ "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.",
2526
+ inputSchema: z.object({
2527
+ hostname: z
2528
+ .string()
2529
+ .describe("Bare Git host: github.com, gitlab.com, etc. No scheme, no path."),
2530
+ label: z
2531
+ .string()
2532
+ .describe(
2533
+ "Operator-supplied label for disambiguation when multiple credentials share a host (e.g. 'personal', 'work-org', 'acme-clients'). Trimmed at storage.",
2534
+ ),
2535
+ username: z
2536
+ .string()
2537
+ .describe(
2538
+ "Git username. For GitHub fine-grained PATs, use 'x-access-token'. For classic PATs, your GitHub username works too.",
2539
+ ),
2540
+ secret: z
2541
+ .string()
2542
+ .describe(
2543
+ "Git token (PAT). Visible to the MCP client on input; rotate via moor_source_credential_update if exposed.",
2544
+ ),
2545
+ expires_at: z
2546
+ .string()
2547
+ .nullable()
2548
+ .optional()
2549
+ .describe(
2550
+ "Operator-supplied expiry timestamp (PAT expiry from GitHub). Optional; helps rotation reminders.",
2551
+ ),
2552
+ }),
2553
+ },
2554
+ async ({ hostname, label, username, secret, expires_at }) => {
2555
+ const body: Record<string, unknown> = { hostname, label, username, secret };
2556
+ if (expires_at !== undefined) body.expires_at = expires_at;
2557
+ const res = await apiPost("/api/server/source-credentials", body);
2558
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2559
+ const row = (await res.json()) as SourceCredentialMetadata;
2560
+ return {
2561
+ content: [
2562
+ {
2563
+ type: "text",
2564
+ text: `Added source credential for ${row.hostname} label="${row.label}" (id=${row.id}, kind=${row.secret.kind}).`,
2565
+ },
2566
+ ],
2567
+ structuredContent: row as unknown as Record<string, unknown>,
2568
+ };
2569
+ },
2570
+ );
2571
+
2572
+ server.registerTool(
2573
+ "moor_source_credential_update",
2574
+ {
2575
+ title: "Update Source Credential",
2576
+ description:
2577
+ "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.",
2578
+ inputSchema: z.object({
2579
+ id: z.number().int().positive().describe("Credential id to update"),
2580
+ username: z.string().optional().describe("New username (optional)"),
2581
+ secret: z
2582
+ .string()
2583
+ .optional()
2584
+ .describe("New secret (optional). Visible to the MCP client on input."),
2585
+ label: z.string().optional().describe("New label (optional). Trimmed at storage."),
2586
+ expires_at: z.string().nullable().optional().describe("New expiry; null to clear"),
2587
+ }),
2588
+ },
2589
+ async ({ id, username, secret, label, expires_at }) => {
2590
+ if (
2591
+ username === undefined &&
2592
+ secret === undefined &&
2593
+ label === undefined &&
2594
+ expires_at === undefined
2595
+ ) {
2596
+ throw new Error("must provide at least one of username, secret, label, or expires_at");
2597
+ }
2598
+ const patch: Record<string, unknown> = {};
2599
+ if (username !== undefined) patch.username = username;
2600
+ if (secret !== undefined) patch.secret = secret;
2601
+ if (label !== undefined) patch.label = label;
2602
+ if (expires_at !== undefined) patch.expires_at = expires_at;
2603
+ const res = await apiPut(`/api/server/source-credentials/${id}`, patch);
2604
+ if (res.status === 404) throw new Error(`source credential id=${id} not found`);
2605
+ if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2606
+ const row = (await res.json()) as SourceCredentialMetadata;
2607
+ const fields: string[] = [];
2608
+ if (username !== undefined) fields.push("username");
2609
+ if (secret !== undefined) fields.push("secret");
2610
+ if (label !== undefined) fields.push("label");
2611
+ if (expires_at !== undefined) fields.push("expires_at");
2612
+ return {
2613
+ content: [
2614
+ {
2615
+ type: "text",
2616
+ text: `Updated source credential id=${id} (${row.hostname}, ${row.label}): rotated ${fields.join(" + ")}. kind=${row.secret.kind}.`,
2617
+ },
2618
+ ],
2619
+ structuredContent: row as unknown as Record<string, unknown>,
2620
+ };
2621
+ },
2622
+ );
2623
+
2624
+ server.registerTool(
2625
+ "moor_source_credential_delete",
2626
+ {
2627
+ title: "Delete Source Credential",
2628
+ description:
2629
+ "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.",
2630
+ inputSchema: z.object({
2631
+ id: z.number().int().positive().describe("Credential id to delete"),
2632
+ confirm_label: z
2633
+ .string()
2634
+ .describe(
2635
+ "Must equal the credential row's label exactly. Resolved via moor_source_credential_get and compared before deletion.",
2636
+ ),
2637
+ }),
2638
+ },
2639
+ async ({ id, confirm_label }) => {
2640
+ const getRes = await apiGet(`/api/server/source-credentials/${id}`);
2641
+ if (getRes.status === 404) throw new Error(`source credential id=${id} not found`);
2642
+ if (!getRes.ok)
2643
+ throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`);
2644
+ const row = (await getRes.json()) as SourceCredentialMetadata;
2645
+ if (confirm_label !== row.label) {
2646
+ throw new Error(
2647
+ `confirm_label "${confirm_label}" does not match resolved label "${row.label}". Refusing to delete.`,
2648
+ );
2649
+ }
2650
+ const delRes = await apiDelete(
2651
+ `/api/server/source-credentials/${id}?confirm_label=${encodeURIComponent(row.label)}`,
2652
+ );
2653
+ if (delRes.status === 409) {
2654
+ const body = (await delRes.json()) as { projects: string[] };
2655
+ throw new Error(
2656
+ `credential_in_use: ${body.projects.length} project(s) still reference id=${id}: ${body.projects.join(", ")}`,
2657
+ );
2658
+ }
2659
+ if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`);
2660
+ return {
2661
+ content: [
2662
+ {
2663
+ type: "text",
2664
+ text: `Deleted source credential ${row.hostname}/${row.label} (id=${id}).`,
2665
+ },
2666
+ ],
2667
+ structuredContent: { deleted: { id, hostname: row.hostname, label: row.label } },
2668
+ };
2669
+ },
2670
+ );
2671
+
2672
+ server.registerTool(
2673
+ "moor_source_credential_check",
2674
+ {
2675
+ title: "Check Source Credential Access",
2676
+ description:
2677
+ "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.",
2678
+ inputSchema: z.object({
2679
+ github_url: z
2680
+ .string()
2681
+ .describe(
2682
+ "HTTPS Git repo URL: https://host/owner/repo (optional .git). No query, no fragment, no embedded credentials.",
2683
+ ),
2684
+ branch: z
2685
+ .string()
2686
+ .optional()
2687
+ .describe("Specific branch to verify. Omit to discover the default branch."),
2688
+ source_credential_id: z
2689
+ .number()
2690
+ .int()
2691
+ .positive()
2692
+ .optional()
2693
+ .describe("Pin a specific credential. Required when multiple credentials match the host."),
2694
+ }),
2695
+ },
2696
+ async ({ github_url, branch, source_credential_id }) => {
2697
+ const body: Record<string, unknown> = { github_url };
2698
+ if (branch !== undefined) body.branch = branch;
2699
+ if (source_credential_id !== undefined) body.source_credential_id = source_credential_id;
2700
+ const res = await apiPost("/api/server/source-credentials/check", body);
2701
+ const json = (await res.json()) as Record<string, unknown>;
2702
+ if (res.ok) {
2703
+ const def = json.default_branch ? ` default_branch=${json.default_branch}` : "";
2704
+ const head = json.head_sha ? ` head_sha=${String(json.head_sha).slice(0, 12)}` : "";
2705
+ const auto = json.auto_selected_credential_id
2706
+ ? ` auto_selected=${json.auto_selected_credential_id}`
2707
+ : "";
2708
+ return {
2709
+ content: [{ type: "text", text: `reachable${def}${head}${auto}` }],
2710
+ structuredContent: json,
2711
+ };
2712
+ }
2713
+ // Non-OK: surface the structured failure shape directly. The agent
2714
+ // can branch on `code` to decide what to do next (ask for a PAT,
2715
+ // pick from candidates, etc.).
2716
+ return {
2717
+ content: [
2718
+ {
2719
+ type: "text",
2720
+ text: `check failed: code=${json.code}${json.reason ? ` reason=${json.reason}` : ""}`,
2721
+ },
2722
+ ],
2723
+ structuredContent: json,
2724
+ isError: true,
2725
+ };
2726
+ },
2727
+ );
2728
+
2260
2729
  function formatMs(ms: number): string {
2261
2730
  if (ms < 1000) return `${ms}ms`;
2262
2731
  const s = Math.floor(ms / 1000);