@moor-sh/mcp 0.19.0 → 0.20.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 +185 -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.20.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,191 @@ 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
+
2260
2445
  function formatMs(ms: number): string {
2261
2446
  if (ms < 1000) return `${ms}ms`;
2262
2447
  const s = Math.floor(ms / 1000);