@moor-sh/mcp 0.27.1 → 0.27.2

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.
package/src/index.ts DELETED
@@ -1,3100 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
3
- import { z } from "zod";
4
- import { tailUtf8 } from "./tail-utf8";
5
-
6
- // --- Config ---
7
-
8
- const baseUrl = (process.env.MOOR_URL || "").replace(/\/$/, "");
9
- const apiKey = process.env.MOOR_API_KEY || "";
10
-
11
- if (!baseUrl || !apiKey) {
12
- console.error("MOOR_URL and MOOR_API_KEY environment variables are required");
13
- process.exit(1);
14
- }
15
-
16
- // --- Startup probe ---
17
- // Fail closed: verify URL is reachable AND the bearer token authenticates before
18
- // registering tools. Misconfigs surface here with a clear stderr message instead
19
- // of later as opaque tool-call failures inside the MCP client.
20
- {
21
- let probeRes: Response;
22
- try {
23
- probeRes = await fetch(`${baseUrl}/api/projects`, {
24
- headers: { Authorization: `Bearer ${apiKey}` },
25
- signal: AbortSignal.timeout(5000),
26
- });
27
- } catch (e) {
28
- const msg = e instanceof Error ? e.message : String(e);
29
- console.error(`Cannot reach moor at ${baseUrl}: ${msg}`);
30
- console.error("Check MOOR_URL and that moor is running (and tunneled, if remote).");
31
- process.exit(1);
32
- }
33
- if (probeRes.status === 401) {
34
- console.error(`Authentication failed against ${baseUrl}.`);
35
- console.error("Check MOOR_API_KEY matches the value in moor's .env on the server.");
36
- process.exit(1);
37
- }
38
- if (probeRes.status === 503) {
39
- console.error(`moor at ${baseUrl} returned 503.`);
40
- console.error("Likely cause: MOOR_INITIAL_PASSWORD not configured. Set it and restart moor.");
41
- process.exit(1);
42
- }
43
- if (!probeRes.ok) {
44
- console.error(`moor at ${baseUrl} returned ${probeRes.status} on startup probe.`);
45
- process.exit(1);
46
- }
47
- }
48
-
49
- // --- HTTP client ---
50
-
51
- function headers(json = false): Record<string, string> {
52
- const h: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
53
- if (json) h["Content-Type"] = "application/json";
54
- return h;
55
- }
56
-
57
- async function apiGet(path: string) {
58
- return fetch(`${baseUrl}${path}`, { headers: headers() });
59
- }
60
-
61
- async function apiPost(path: string, body?: unknown) {
62
- return fetch(`${baseUrl}${path}`, {
63
- method: "POST",
64
- headers: headers(body !== undefined),
65
- body: body !== undefined ? JSON.stringify(body) : undefined,
66
- });
67
- }
68
-
69
- async function apiPut(path: string, body: unknown) {
70
- return fetch(`${baseUrl}${path}`, {
71
- method: "PUT",
72
- headers: headers(true),
73
- body: JSON.stringify(body),
74
- });
75
- }
76
-
77
- async function apiDelete(path: string) {
78
- return fetch(`${baseUrl}${path}`, {
79
- method: "DELETE",
80
- headers: headers(),
81
- });
82
- }
83
-
84
- type Project = {
85
- id: number;
86
- name: string;
87
- status: string;
88
- container_id: string | null;
89
- image_tag: string | null;
90
- domain: string | null;
91
- docker_image: string | null;
92
- github_url: string | null;
93
- // #71: live_* fields are written by the API's status reconciler.
94
- // status above is moor's RECORDED state (only changes on explicit
95
- // start/stop/build/cancel). live_status reflects Docker's view at
96
- // last successful inspect. Differences mean moor missed an external
97
- // change (or the reconciler hasn't run yet). live_error non-null
98
- // means the most recent inspect failed; the live_status / exit_code
99
- // shown is the last successful snapshot.
100
- live_status?: "running" | "stopped" | "error" | "missing" | null;
101
- live_exit_code?: number | null;
102
- live_checked_at?: string | null;
103
- live_error?: string | null;
104
- };
105
-
106
- async function resolveProject(name: string): Promise<Project> {
107
- const res = await apiGet("/api/projects");
108
- if (!res.ok) throw new Error(`Failed to list projects: ${res.status}`);
109
- const projects = (await res.json()) as Project[];
110
- const match = projects.find((p) => p.name === name || String(p.id) === name);
111
- if (!match) throw new Error(`Project "${name}" not found`);
112
- return match;
113
- }
114
-
115
- // --- SSE stream reader ---
116
-
117
- async function readSSE(res: Response): Promise<{
118
- logs: string;
119
- error?: string;
120
- structuredError?: { code: string; message: string };
121
- }> {
122
- const reader = res.body?.getReader();
123
- if (!reader) return { logs: "" };
124
-
125
- const decoder = new TextDecoder();
126
- let buffer = "";
127
- let currentEvent = "";
128
- let logs = "";
129
- let error: string | undefined;
130
- let structuredError: { code: string; message: string } | undefined;
131
-
132
- while (true) {
133
- const { done, value } = await reader.read();
134
- if (done) break;
135
- buffer += decoder.decode(value, { stream: true });
136
-
137
- const lines = buffer.split("\n");
138
- buffer = lines.pop() || "";
139
-
140
- for (const line of lines) {
141
- if (line.startsWith("event: ")) {
142
- currentEvent = line.slice(7).trim();
143
- } else if (line.startsWith("data: ")) {
144
- const data = JSON.parse(line.slice(6));
145
- if (currentEvent === "log") logs += data;
146
- else if (currentEvent === "error") error = data;
147
- // #119: structured-error fires alongside event: error when the
148
- // server classifies a build failure (today: source_credential_required).
149
- // Captured here so deploy/rebuild tools can surface the code to
150
- // the agent instead of throwing a generic message.
151
- else if (currentEvent === "structured-error") structuredError = data;
152
- currentEvent = "";
153
- }
154
- }
155
- }
156
- return { logs, error, structuredError };
157
- }
158
-
159
- // --- Validators ---
160
-
161
- /** Validate that a string is a github.com URL. Throws with a clear message on failure.
162
- * Stricter than apps/api/routes/docker.ts:validateGithubUrl, which accepts any host
163
- * ending in "github.com" (so "evilgithub.com" slips through). MCP rejects that and
164
- * surfaces the error at create/update time, not at first build/run. */
165
- function validateGithubUrl(url: string): void {
166
- let host: string;
167
- try {
168
- host = new URL(url).hostname;
169
- } catch {
170
- throw new Error(`github_url is not a valid URL: ${url}`);
171
- }
172
- if (host !== "github.com" && !host.endsWith(".github.com")) {
173
- throw new Error(`github_url must be a github.com URL (got hostname "${host}")`);
174
- }
175
- }
176
-
177
- /** Strict GitHub repo URL validator used by moor_deploy. Stricter than
178
- * validateGithubUrl: requires host = github.com or www.github.com AND a path of
179
- * exactly /owner/repo (with optional .git suffix, optional trailing slash).
180
- * Rejects gist.github.com, the bare root, and /owner/repo/tree/... extras.
181
- * Failed deploys trigger an actual image build/pull, so the up-front check is
182
- * worth being pickier than the create/update wrappers. */
183
- function validateGithubRepoUrl(url: string): void {
184
- let parsed: URL;
185
- try {
186
- parsed = new URL(url);
187
- } catch {
188
- throw new Error(`github_url is not a valid URL: ${url}`);
189
- }
190
- // The downstream build path (apps/api/docker.ts:buildImageStreaming) appends ".git"
191
- // and a branch ref to whatever URL we forward, so a non-http protocol, query string,
192
- // or fragment quietly mangles the resulting git remote. Reject those up front.
193
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
194
- throw new Error(`github_url must use http or https (got protocol "${parsed.protocol}")`);
195
- }
196
- if (parsed.search) {
197
- throw new Error(`github_url must not contain query parameters (got "${parsed.search}")`);
198
- }
199
- if (parsed.hash) {
200
- throw new Error(`github_url must not contain a URL fragment (got "${parsed.hash}")`);
201
- }
202
- const host = parsed.hostname;
203
- if (host !== "github.com" && host !== "www.github.com") {
204
- throw new Error(`github_url must use github.com or www.github.com (got "${host}")`);
205
- }
206
- if (!/^\/[^/]+\/[^/]+?(\.git)?\/?$/.test(parsed.pathname)) {
207
- throw new Error(
208
- `github_url must point to /owner/repo (with optional .git); got "${parsed.pathname}"`,
209
- );
210
- }
211
- }
212
-
213
- /** Validate a 5-field crontab schedule against what apps/api/cron.ts can actually execute.
214
- * Stricter than the scheduler's permissive parser: the scheduler silently never fires
215
- * on bad input, so MCP rejects up-front. Returns an error string or null. */
216
- const CRON_FIELDS: ReadonlyArray<{ name: string; min: number; max: number }> = [
217
- { name: "minute", min: 0, max: 59 },
218
- { name: "hour", min: 0, max: 23 },
219
- { name: "day-of-month", min: 1, max: 31 },
220
- { name: "month", min: 1, max: 12 },
221
- { name: "day-of-week", min: 0, max: 6 }, // 0=Sunday; scheduler does not translate 7
222
- ];
223
-
224
- // Whitelist each comma-separated part against one of the canonical forms below.
225
- // Anything else (empty parts, leading "-", bare "N/S", "/S", stray characters) is
226
- // rejected. The scheduler at apps/api/cron.ts silently ignores or mis-parses these
227
- // inputs, so the validator must be strict where the scheduler is loose.
228
- const CRON_PART_PATTERNS = [
229
- /^\*$/, // *
230
- /^(\d+)$/, // N
231
- /^(\d+)-(\d+)$/, // A-B
232
- /^\*\/(\d+)$/, // */S
233
- /^(\d+)-(\d+)\/(\d+)$/, // A-B/S
234
- ];
235
-
236
- function validateCronField(field: string, min: number, max: number, name: string): string | null {
237
- if (field === "*") return null;
238
- if (/[?LW#]/i.test(field)) return `${name}: ?, L, W, # are not supported`;
239
- if (/[a-zA-Z]/.test(field))
240
- return `${name}: month/day names are not supported, use numeric values`;
241
-
242
- for (const part of field.split(",")) {
243
- if (part === "") return `${name}: empty list element`;
244
-
245
- const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null);
246
- if (!match) return `${name}: invalid expression "${part}"`;
247
-
248
- // Validate captured numbers against per-field bounds and step positivity.
249
- // Capture layout depends on which pattern matched, identified by length.
250
- const groups = match.slice(1);
251
- if (groups.length === 1 && match[0].startsWith("*/")) {
252
- // */S
253
- const step = Number(groups[0]);
254
- if (step <= 0) return `${name}: step must be a positive integer (got "${groups[0]}")`;
255
- } else if (groups.length === 1) {
256
- // N
257
- const n = Number(groups[0]);
258
- if (n < min || n > max) return `${name}: ${n} out of bounds [${min}-${max}]`;
259
- } else if (groups.length === 2) {
260
- // A-B
261
- const a = Number(groups[0]);
262
- const b = Number(groups[1]);
263
- if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
264
- if (a > b) return `${name}: range ${a}-${b} is descending`;
265
- } else if (groups.length === 3) {
266
- // A-B/S
267
- const a = Number(groups[0]);
268
- const b = Number(groups[1]);
269
- const step = Number(groups[2]);
270
- if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
271
- if (a > b) return `${name}: range ${a}-${b} is descending`;
272
- if (step <= 0) return `${name}: step must be a positive integer (got "${groups[2]}")`;
273
- }
274
- }
275
- return null;
276
- }
277
-
278
- function validateCronSchedule(schedule: string): string | null {
279
- const parts = schedule.trim().split(/\s+/);
280
- if (parts.length !== 5) {
281
- return `schedule must have exactly 5 space-separated fields (got ${parts.length})`;
282
- }
283
- for (let i = 0; i < 5; i++) {
284
- const err = validateCronField(
285
- parts[i],
286
- CRON_FIELDS[i].min,
287
- CRON_FIELDS[i].max,
288
- CRON_FIELDS[i].name,
289
- );
290
- if (err) return err;
291
- }
292
- return null;
293
- }
294
-
295
- // --- MCP Server ---
296
-
297
- const server = new McpServer({
298
- name: "moor",
299
- version: "0.1.0",
300
- });
301
-
302
- // --- Tools ---
303
-
304
- server.registerTool(
305
- "moor_status",
306
- {
307
- title: "List Projects",
308
- description:
309
- "List all projects managed by Moor. `status` is moor's recorded state (only changes on explicit start/stop/build/cancel). `live_status` is Docker's view at last successful inspect; differences (e.g. recorded='running' live='error') mean moor missed an external change like a host docker stop, crash, or OOM kill. `live_error` non-null means the most recent inspect failed and the live_* values are the last successful snapshot, not necessarily current.",
310
- },
311
- async () => {
312
- const res = await apiGet("/api/projects");
313
- if (!res.ok) throw new Error(`Failed: ${res.status}`);
314
- const projects = (await res.json()) as Project[];
315
- const summary = projects.map((p) => ({
316
- name: p.name,
317
- status: p.status,
318
- live_status: p.live_status ?? null,
319
- live_exit_code: p.live_exit_code ?? null,
320
- live_checked_at: p.live_checked_at ?? null,
321
- live_error: p.live_error ?? null,
322
- source: p.docker_image || p.github_url || null,
323
- domain: p.domain,
324
- }));
325
- return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
326
- },
327
- );
328
-
329
- server.registerTool(
330
- "moor_logs",
331
- {
332
- title: "Get Container Logs",
333
- description:
334
- "Get recent logs from a project's container. Annotates output with state: ok (container running), exited (container is stopped but Docker still has logs), no_container (project never started), or missing (container_id is set but Docker doesn't have it). Throws only on docker_error (Docker daemon 5xx / unreachable) so an operator can distinguish infrastructure failure from app silence — pre-#74 the tool returned empty logs for all of these.",
335
- inputSchema: z.object({
336
- project: z.string().describe("Project name or ID"),
337
- lines: z.number().optional().default(100).describe("Number of log lines to retrieve"),
338
- }),
339
- },
340
- async ({ project, lines }) => {
341
- const p = await resolveProject(project);
342
- const res = await apiGet(`/api/projects/${p.id}/logs?tail=${lines}`);
343
- // 502 = API surfaced a Docker daemon failure. Throw so the agent
344
- // gets a tool error, not silent empty logs.
345
- if (res.status === 502) {
346
- const data = (await res.json().catch(() => ({}))) as { error?: string };
347
- throw new Error(`Docker error: ${data.error ?? "unknown"}`);
348
- }
349
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
350
- const data = (await res.json()) as { logs: string; state?: string };
351
- switch (data.state) {
352
- case "no_container":
353
- return {
354
- content: [{ type: "text", text: "(project hasn't been started yet — no container)" }],
355
- };
356
- case "missing":
357
- return {
358
- content: [
359
- {
360
- type: "text",
361
- text: "(container_id was recorded but Docker doesn't have it; moor may need to recreate the project)",
362
- },
363
- ],
364
- };
365
- case "exited":
366
- return {
367
- content: [
368
- {
369
- type: "text",
370
- text: `${data.logs || "(no logs captured)"}\n\n(container is exited; logs above are from before)`,
371
- },
372
- ],
373
- };
374
- default:
375
- // "ok" or undefined (older API) — render raw.
376
- return {
377
- content: [{ type: "text", text: data.logs || "(no logs)" }],
378
- };
379
- }
380
- },
381
- );
382
-
383
- server.registerTool(
384
- "moor_rebuild",
385
- {
386
- title: "Rebuild Project",
387
- description:
388
- "Rebuild a project from source (git pull + docker build) and restart the container. Returns the build output when it finishes. While a build is in flight, the most recent moor_runs entry has finished_at=null — call moor_run_get on its id to tail the live output. Use moor_rebuild for code, Dockerfile, or base-image changes. For env vars / resource limits / port / volume / restart-policy changes, or to recover a crashed container from the existing image, use moor_restart — it skips the build and is much faster.",
389
- inputSchema: z.object({
390
- project: z.string().describe("Project name or ID"),
391
- no_cache: z.boolean().optional().default(false).describe("Build without Docker cache"),
392
- }),
393
- },
394
- async ({ project, no_cache }) => {
395
- const p = await resolveProject(project);
396
- const query = no_cache ? "?nocache=true" : "";
397
- const res = await apiPost(`/api/projects/${p.id}/run${query}`);
398
- // /run can fail BEFORE opening the SSE stream — resolver validation,
399
- // drain mode, invalid URL, credential_not_active. Those land as a
400
- // plain JSON or text body that readSSE walks without matching any
401
- // event:/data: lines, returning empty everything. Without this guard
402
- // the tool would silently report "Rebuild complete." on a failed build.
403
- // Mirrors the existing moor_deploy guard at the /run call site.
404
- if (!res.ok) throw new Error(`[run] ${await res.text()}`);
405
- const { logs, error, structuredError } = await readSSE(res);
406
- // #119: a classified failure (today: source_credential_required) gets
407
- // returned as isError with a structured payload the agent can branch
408
- // on. Unclassified errors keep throwing so the existing UX is preserved.
409
- if (structuredError) {
410
- return {
411
- content: [
412
- {
413
- type: "text",
414
- text: `rebuild failed: code=${structuredError.code} message=${structuredError.message}`,
415
- },
416
- ],
417
- structuredContent: structuredError,
418
- isError: true,
419
- };
420
- }
421
- if (error) throw new Error(error);
422
- return { content: [{ type: "text", text: logs || "Rebuild complete." }] };
423
- },
424
- );
425
-
426
- server.registerTool(
427
- "moor_restart",
428
- {
429
- title: "Restart Project",
430
- description:
431
- "Stop and recreate a project's container from its existing image. Does NOT pull from git or rebuild — uses the existing image_tag. Right tool for: applying changed env vars / resource limits / ports / volumes / restart policy, recovering a crashed container, or simply bouncing the process. Wrong tool for: code or Dockerfile changes (use moor_rebuild — those need a new image).",
432
- inputSchema: z.object({
433
- project: z.string().describe("Project name or ID"),
434
- }),
435
- },
436
- async ({ project }) => {
437
- const p = await resolveProject(project);
438
- const stopRes = await apiPost(`/api/projects/${p.id}/stop`);
439
- if (!stopRes.ok) throw new Error(`Failed to stop: ${await stopRes.text()}`);
440
- const startRes = await apiPost(`/api/projects/${p.id}/start`);
441
- if (!startRes.ok) throw new Error(`Failed to start: ${await startRes.text()}`);
442
- return { content: [{ type: "text", text: `${p.name} restarted.` }] };
443
- },
444
- );
445
-
446
- server.registerTool(
447
- "moor_exec",
448
- {
449
- title: "Execute Command",
450
- description:
451
- "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.",
452
- inputSchema: z.object({
453
- project: z.string().describe("Project name or ID"),
454
- command: z.string().describe("Shell command to execute"),
455
- timeout_ms: z
456
- .number()
457
- .int()
458
- .min(1000)
459
- .max(3_600_000)
460
- .optional()
461
- .describe(
462
- "Max time in milliseconds before the exec is aborted. Default 600000 (10 min). Max 3600000 (1 h).",
463
- ),
464
- }),
465
- },
466
- async ({ project, command, timeout_ms }) => {
467
- const p = await resolveProject(project);
468
- const body: Record<string, unknown> = { command };
469
- if (timeout_ms !== undefined) body.timeout_ms = timeout_ms;
470
- const res = await apiPost(`/api/projects/${p.id}/exec`, body);
471
- // The API returns 504 with a structured timeout body when the exec hit
472
- // timeout_ms. Surface the kill outcome in the tool error so the agent can
473
- // tell "the process was actually stopped" from "we just stopped waiting."
474
- if (res.status === 504) {
475
- const t = (await res.json()) as {
476
- timeout_ms: number;
477
- killed: boolean;
478
- killed_pid: string | null;
479
- live_remaining: number;
480
- message: string;
481
- };
482
- let detail: string;
483
- if (t.killed) {
484
- detail = `Process tree terminated (container pid ${t.killed_pid}).`;
485
- } else if (t.killed_pid !== null) {
486
- detail = `Kill attempted on container pid ${t.killed_pid} but ${t.live_remaining} descendant process(es) still running inside the container.`;
487
- } else {
488
- detail =
489
- "Process kill could not locate the running process — it may still be running inside the container.";
490
- }
491
- throw new Error(`Exec timed out after ${t.timeout_ms}ms. ${detail}`);
492
- }
493
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
494
- const result = (await res.json()) as {
495
- exitCode: number;
496
- stdout: string;
497
- stderr: string;
498
- };
499
- let text = "";
500
- if (result.stdout) text += result.stdout;
501
- if (result.stderr) text += `\n[stderr] ${result.stderr}`;
502
- text += `\n[exit code: ${result.exitCode}]`;
503
- return { content: [{ type: "text", text }] };
504
- },
505
- );
506
-
507
- server.registerTool(
508
- "moor_env_list",
509
- {
510
- title: "List Environment Variables",
511
- description: "List all environment variables set for a project.",
512
- inputSchema: z.object({
513
- project: z.string().describe("Project name or ID"),
514
- }),
515
- },
516
- async ({ project }) => {
517
- const p = await resolveProject(project);
518
- const res = await apiGet(`/api/projects/${p.id}/envs`);
519
- if (!res.ok) throw new Error(`Failed: ${res.status}`);
520
- const vars = (await res.json()) as { key: string; value: string }[];
521
- if (vars.length === 0)
522
- return { content: [{ type: "text", text: "No environment variables set." }] };
523
- const text = vars.map((v) => `${v.key}=${v.value}`).join("\n");
524
- return { content: [{ type: "text", text }] };
525
- },
526
- );
527
-
528
- server.registerTool(
529
- "moor_env_set",
530
- {
531
- title: "Set Environment Variables",
532
- description:
533
- "Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running.",
534
- inputSchema: z.object({
535
- project: z.string().describe("Project name or ID"),
536
- vars: z
537
- .record(z.string(), z.string())
538
- .describe('Key-value pairs to set, e.g. { "DATABASE_URL": "postgres://..." }'),
539
- }),
540
- },
541
- async ({ project, vars }) => {
542
- const p = await resolveProject(project);
543
-
544
- // Fetch existing and merge
545
- const existingRes = await apiGet(`/api/projects/${p.id}/envs`);
546
- if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`);
547
- const existing = (await existingRes.json()) as { key: string; value: string }[];
548
- const merged = new Map(existing.map((v) => [v.key, v.value]));
549
- for (const [key, value] of Object.entries(vars)) {
550
- merged.set(key, value);
551
- }
552
- const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
553
-
554
- const setRes = await apiPut(`/api/projects/${p.id}/envs`, allVars);
555
- if (!setRes.ok) throw new Error(`Failed to set envs: ${await setRes.text()}`);
556
-
557
- const keys = Object.keys(vars).join(", ");
558
- let text = `Set ${keys} on ${p.name}.`;
559
-
560
- // Restart if running
561
- if (p.status === "running") {
562
- await apiPost(`/api/projects/${p.id}/stop`);
563
- const startRes = await apiPost(`/api/projects/${p.id}/start`);
564
- if (!startRes.ok) throw new Error(`Set vars but failed to restart: ${await startRes.text()}`);
565
- text += " Container restarted.";
566
- }
567
-
568
- return { content: [{ type: "text", text }] };
569
- },
570
- );
571
-
572
- server.registerTool(
573
- "moor_stats",
574
- {
575
- title: "Server Stats",
576
- description:
577
- "Get server resource usage: load, memory, per-filesystem disk usage (the filesystems the moor container can see, plus any operator-configured monitored host disks via MOOR_MONITORED_DISKS), Docker disk by category (images/containers/volumes/build cache) with reclaimable bytes, and container counts. Note: cpu.percent is load-derived (load avg ÷ cores), not instantaneous CPU; use the `load` field for the same signal with explicit naming.",
578
- },
579
- async () => {
580
- const res = await apiGet("/api/server/stats");
581
- if (!res.ok) throw new Error(`Failed: ${res.status}`);
582
- const s = (await res.json()) as {
583
- hostname: string;
584
- os: string;
585
- uptime: string;
586
- cpu: { percent: number; cores: number };
587
- load?: { one_min: number; cores: number; normalized_percent: number };
588
- memory: { total: string; used: string; percent: number };
589
- disk: { total: string; used: string; percent: number };
590
- disks?: { mount: string; total: string; used: string; percent: number; label?: string }[];
591
- containers: { running: number; total: number };
592
- docker?: {
593
- images: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number };
594
- containers: {
595
- bytes: number;
596
- reclaimable_bytes: number;
597
- count: number;
598
- stopped_count: number;
599
- };
600
- volumes: { bytes: number; reclaimable_bytes: number; count: number; unused_count: number };
601
- build_cache: { bytes: number; reclaimable_bytes: number; count: number };
602
- } | null;
603
- };
604
- const lines = [
605
- `Host: ${s.hostname}`,
606
- `OS: ${s.os}`,
607
- `Uptime: ${s.uptime}`,
608
- `CPU: ${s.cpu.percent}% (${s.cpu.cores} cores) — load-derived, not instantaneous`,
609
- ];
610
- if (s.load) {
611
- lines.push(
612
- `Load (1m): ${s.load.one_min.toFixed(2)} on ${s.load.cores} cores (${s.load.normalized_percent}%)`,
613
- );
614
- }
615
- lines.push(`Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`);
616
- const disks = s.disks?.length ? s.disks : [{ mount: "/", ...s.disk }];
617
- for (const d of disks) {
618
- const name = d.label ? `${d.label} (${d.mount})` : `Disk ${d.mount}`;
619
- lines.push(`${name}: ${d.used} / ${d.total} (${d.percent}%)`);
620
- }
621
- lines.push(`Containers: ${s.containers.running} running / ${s.containers.total} total`);
622
- if (s.docker) {
623
- const d = s.docker;
624
- lines.push(
625
- "Docker disk:",
626
- ` Images: ${formatBytes(d.images.bytes)} (${formatBytes(d.images.reclaimable_bytes)} reclaimable, ${d.images.unused_count}/${d.images.count} unused)`,
627
- ` Containers: ${formatBytes(d.containers.bytes)} (${formatBytes(d.containers.reclaimable_bytes)} reclaimable, ${d.containers.stopped_count}/${d.containers.count} stopped)`,
628
- ` Volumes: ${formatBytes(d.volumes.bytes)} (${formatBytes(d.volumes.reclaimable_bytes)} reclaimable, ${d.volumes.unused_count}/${d.volumes.count} unused)`,
629
- ` Build cache: ${formatBytes(d.build_cache.bytes)} (${formatBytes(d.build_cache.reclaimable_bytes)} reclaimable, ${d.build_cache.count} entries)`,
630
- );
631
- }
632
- return { content: [{ type: "text", text: lines.join("\n") }] };
633
- },
634
- );
635
-
636
- server.registerTool(
637
- "moor_update_status",
638
- {
639
- title: "Update status / preflight",
640
- description:
641
- "Report moor's current version + image digest, the latest available digest on GHCR, active in-flight work counts, DB backup recency, and a safe_to_update boolean. update_available is null (not false) when either the local repo_digest or the registry digest is unknown — never lies by comparing across identifier spaces. unsafe_reasons is a human-readable array; render inline rather than re-deriving from booleans. Read-only diagnostic — does NOT perform any update.",
642
- },
643
- async () => {
644
- const res = await apiGet("/api/server/update-status");
645
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
646
- const s = (await res.json()) as {
647
- current: {
648
- version: string;
649
- image_id: string | null;
650
- repo_digest: string | null;
651
- started_at: string;
652
- };
653
- available: {
654
- latest_tag: string;
655
- latest_digest: string | null;
656
- update_available: boolean | null;
657
- registry_error: string | null;
658
- };
659
- active_work: {
660
- builds_in_flight: number;
661
- execs_in_flight: number;
662
- crons_in_flight: number;
663
- terminals_open: number;
664
- };
665
- db_backup: {
666
- last_backup_at: string | null;
667
- age_seconds: number | null;
668
- location: string | null;
669
- };
670
- safe_to_update: boolean;
671
- unsafe_reasons: string[];
672
- recommended_command: string;
673
- };
674
- const lines: string[] = [];
675
- lines.push(`moor ${s.current.version} (image_id: ${s.current.image_id ?? "unknown"})`);
676
- lines.push(
677
- `repo_digest: ${s.current.repo_digest ?? "(none — locally built or stale inspect)"}`,
678
- );
679
-
680
- if (s.available.update_available === true) {
681
- lines.push(`update AVAILABLE → latest: ${s.available.latest_digest}`);
682
- } else if (s.available.update_available === false) {
683
- lines.push(`up to date (latest: ${s.available.latest_digest})`);
684
- } else {
685
- // null — explain WHICH side is unknown.
686
- const why = s.available.registry_error
687
- ? `registry unreachable: ${s.available.registry_error}`
688
- : s.current.repo_digest === null
689
- ? "no local repo_digest (built locally?)"
690
- : "comparison unavailable";
691
- lines.push(`update availability unknown — ${why}`);
692
- }
693
-
694
- lines.push(
695
- `active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`,
696
- );
697
-
698
- if (s.safe_to_update) {
699
- lines.push("safe_to_update: YES");
700
- } else {
701
- lines.push("safe_to_update: NO");
702
- for (const r of s.unsafe_reasons) lines.push(` - ${r}`);
703
- }
704
- lines.push(`recommended: ${s.recommended_command}`);
705
- return { content: [{ type: "text", text: lines.join("\n") }] };
706
- },
707
- );
708
-
709
- // #79: drain mode. Operator-facing primitive that gates new work-against-
710
- // container actions (deploys, builds, async/sync execs, manual cron
711
- // triggers, terminal upgrades) so an upgrade can wait for in-flight work
712
- // to complete cleanly. Drain refuses NEW work; it never kills in-flight
713
- // work. The TTL is load-bearing — every refusal carries expires_at and
714
- // the row auto-clears at expiry so a forgotten drain doesn't lock moor
715
- // forever.
716
-
717
- type DrainStateResponse = {
718
- state: {
719
- enabled: boolean;
720
- reason: string | null;
721
- started_at: string | null;
722
- expires_at: string | null;
723
- clear_after_version: string | null;
724
- };
725
- };
726
-
727
- type DrainStatusResponse = DrainStateResponse & {
728
- active_work: {
729
- builds_in_flight: number;
730
- execs_in_flight: number;
731
- crons_in_flight: number;
732
- terminals_open: number;
733
- };
734
- };
735
-
736
- function renderDrainState(s: DrainStateResponse["state"]): string[] {
737
- if (!s.enabled) return ["drain: OFF"];
738
- const lines = [`drain: ON (reason: ${s.reason ?? "(none)"})`];
739
- if (s.started_at) lines.push(` started_at: ${s.started_at}`);
740
- if (s.expires_at) lines.push(` expires_at: ${s.expires_at} (auto-clear)`);
741
- if (s.clear_after_version) {
742
- lines.push(
743
- ` clear_after_version: ${s.clear_after_version} (auto-clear on matching boot version)`,
744
- );
745
- }
746
- return lines;
747
- }
748
-
749
- server.registerTool(
750
- "moor_drain_status",
751
- {
752
- title: "Drain Status",
753
- description:
754
- "Read-only: current drain state (enabled, reason, expires_at, clear_after_version) plus counts of active work the operator should wait on before an update. active_work uses the same counter as moor_update_status so the two never disagree.",
755
- },
756
- async () => {
757
- const res = await apiGet("/api/server/drain");
758
- if (!res.ok) throw new Error(`drain status failed: ${res.status} ${await res.text()}`);
759
- const s = (await res.json()) as DrainStatusResponse;
760
- const lines = renderDrainState(s.state);
761
- lines.push(
762
- `active: builds=${s.active_work.builds_in_flight} execs=${s.active_work.execs_in_flight} crons=${s.active_work.crons_in_flight} terminals=${s.active_work.terminals_open}`,
763
- );
764
- return { content: [{ type: "text", text: lines.join("\n") }] };
765
- },
766
- );
767
-
768
- server.registerTool(
769
- "moor_drain_enable",
770
- {
771
- title: "Enable Drain Mode",
772
- description:
773
- "Refuse new builds, deploys, execs, manual cron runs, and terminal upgrades with a 503 carrying { reason, expires_at, hint }. Existing in-flight work runs to completion — drain does NOT kill anything. Scheduled cron ticks during drain write a synthetic 'skipped due to drain' run row instead of executing. Read-only routes (status, logs, runs) keep working. Default TTL is 30 minutes; set ttl_minutes to override. clear_after_version is the updater's hook — when set, the drain auto-clears on boot if the running moor version matches.",
774
- inputSchema: z.object({
775
- reason: z
776
- .string()
777
- .optional()
778
- .describe(
779
- "Freeform reason shown in every refusal response (e.g. 'preparing for 0.34 upgrade').",
780
- ),
781
- ttl_minutes: z
782
- .number()
783
- .optional()
784
- .describe("Auto-clear after this many minutes. Default 30. Clamped to [0.05 min, 7 days]."),
785
- clear_after_version: z
786
- .string()
787
- .optional()
788
- .describe(
789
- "Optional: on next boot, if the running moor version equals this value, auto-clear the drain. Typically set by the updater path; safe for manual use too.",
790
- ),
791
- }),
792
- },
793
- async ({ reason, ttl_minutes, clear_after_version }) => {
794
- const res = await apiPost("/api/server/drain/enable", {
795
- reason,
796
- ttl_minutes,
797
- clear_after_version,
798
- });
799
- if (!res.ok) throw new Error(`drain enable failed: ${res.status} ${await res.text()}`);
800
- const s = (await res.json()) as DrainStateResponse;
801
- return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] };
802
- },
803
- );
804
-
805
- server.registerTool(
806
- "moor_drain_disable",
807
- {
808
- title: "Disable Drain Mode",
809
- description:
810
- "Explicit operator action to clear drain immediately. Does not kill or restart anything — just removes the gate so new builds/deploys/execs/cron triggers/terminal upgrades succeed again.",
811
- },
812
- async () => {
813
- const res = await apiPost("/api/server/drain/disable", {});
814
- if (!res.ok) throw new Error(`drain disable failed: ${res.status} ${await res.text()}`);
815
- const s = (await res.json()) as DrainStateResponse;
816
- return { content: [{ type: "text", text: renderDrainState(s.state).join("\n") }] };
817
- },
818
- );
819
-
820
- // #90: operator-initiated DB snapshot. Uses VACUUM INTO on the server so
821
- // hot WAL state is captured safely (cp would copy a corrupt-looking file).
822
- // Backups land next to moor.db; retention prunes to the N most recent.
823
- // Pair with MOOR_DB_BACKUP_INTERVAL_HOURS for scheduled snapshots — this
824
- // tool is for taking one right before a manual update.
825
- server.registerTool(
826
- "moor_db_backup",
827
- {
828
- title: "DB Backup (snapshot)",
829
- description:
830
- "Take a SQLite snapshot of moor.db via VACUUM INTO. The file lands next to the main DB as moor.db.backup-<epoch-ms>. Retention is enforced after each snapshot (keeps the 7 most recent by default; older ones are pruned). After this returns, moor_update_status' db_backup.age_seconds will read close to 0. Use before a manual `docker compose pull moor && up -d` if you don't have MOOR_DB_BACKUP_INTERVAL_HOURS scheduled.",
831
- },
832
- async () => {
833
- const res = await apiPost("/api/server/backup", {});
834
- if (!res.ok) throw new Error(`db backup failed: ${res.status} ${await res.text()}`);
835
- const r = (await res.json()) as { path: string; sizeBytes: number; durationMs: number };
836
- const mb = (r.sizeBytes / (1024 * 1024)).toFixed(2);
837
- return {
838
- content: [
839
- {
840
- type: "text",
841
- text: `Snapshot written: ${r.path}\nsize: ${r.sizeBytes}B (${mb} MB)\nduration: ${r.durationMs}ms`,
842
- },
843
- ],
844
- };
845
- },
846
- );
847
-
848
- // #80: moor_update_apply — kick off a transient-respawner update of
849
- // moor itself. The respawner runs async; this tool returns the audit_id
850
- // immediately. Poll moor_update_audit (history of attempts) or
851
- // moor_update_status (version change) for the outcome.
852
- //
853
- // Outcomes (encoded in the audit row's `state` field, surfaced by
854
- // moor_update_audit):
855
- // - success : new image is healthy.
856
- // - failed : pull failed before moor was replaced (no rollback).
857
- // - rolled_back : up/health failed; rollback to prev image succeeded.
858
- // - rollback_failed : up/health failed AND rollback also failed —
859
- // operator must investigate (manual recovery).
860
- // - in_progress : respawner still running.
861
- // - crashed : 30-min grace elapsed without a marker
862
- // (respawner died or moor never read the marker).
863
- server.registerTool(
864
- "moor_update_apply",
865
- {
866
- title: "Apply moor update (transient respawner)",
867
- description:
868
- "Update moor in-place via a transient respawner container. Runs preflight, enables drain, takes a fresh DB backup, then launches a one-shot Compose-aware respawner that pulls + re-creates the moor service. The respawner writes a marker file when done; this tool returns the audit_id immediately so the caller can poll via moor_update_audit. Outcomes: success | failed (pull failed pre-replacement) | rolled_back (up/health failed, automatic rollback succeeded) | rollback_failed (rollback also failed — manual recovery needed) | crashed (no marker after 30-min grace). Bypass is per-blocker: pass {bypass:['active_work']} to interrupt in-flight builds/execs/crons via the existing shutdown coordinator; {bypass:['unknown_digest']} when the registry comparison was inconclusive. Backup is mandatory and not bypassable.",
869
- inputSchema: z.object({
870
- target_digest: z
871
- .string()
872
- .regex(/^sha256:[0-9a-f]{64}$/, "target_digest must be sha256:<64 hex>")
873
- .optional()
874
- .describe(
875
- "Pin the update to this exact image digest. Default: the registry's current `:latest` digest from moor_update_status.",
876
- ),
877
- bypass: z
878
- .array(z.enum(["active_work", "unknown_digest"]))
879
- .optional()
880
- .describe(
881
- "Per-blocker bypass. `active_work` accepts that in-flight builds/execs/crons will be interrupted via the shutdown coordinator. `unknown_digest` accepts proceeding when the registry comparison is inconclusive (locally-built image, GHCR unreachable). Backup is mandatory and not in this list.",
882
- ),
883
- }),
884
- },
885
- async (input) => {
886
- const res = await apiPost("/api/server/update/apply", input ?? {});
887
- if (res.status === 202) {
888
- const { audit_id } = (await res.json()) as { audit_id: number };
889
- return {
890
- content: [
891
- {
892
- type: "text",
893
- text: `Update started: audit_id=${audit_id}. Respawner is running async. Poll moor_update_audit to watch the outcome, or moor_update_status to watch the version. Possible terminal states:
894
- - success (new image healthy)
895
- - failed (pull failed before moor was replaced)
896
- - rolled_back (up/health failed; automatic rollback succeeded; drain stays on)
897
- - rollback_failed (up/health failed AND rollback failed; manual recovery)
898
- - crashed (no marker after 30-min grace; respawner died)
899
- Recovery: rolled_back means moor is on the previous image again; the failed update is captured in error_log. rollback_failed or crashed mean an operator should investigate (likely manual docker compose up).`,
900
- },
901
- ],
902
- };
903
- }
904
- // Error: surface the structured reason so callers can act on it.
905
- const body = (await res.json().catch(() => ({}))) as {
906
- error?: { code: string; reason?: string; unsafe_reasons?: string[] };
907
- };
908
- const code = body.error?.code ?? `HTTP ${res.status}`;
909
- const reason = body.error?.reason ?? "no detail";
910
- const extra = body.error?.unsafe_reasons
911
- ? `\nunsafe_reasons:\n - ${body.error.unsafe_reasons.join("\n - ")}`
912
- : "";
913
- throw new Error(`moor_update_apply refused [${code}]: ${reason}${extra}`);
914
- },
915
- );
916
-
917
- // #80 PR #6: moor_update_audit — recent history of moor_update_apply
918
- // attempts. Read-only diagnostic; the orchestration lives entirely on
919
- // the moor side. Tail-truncates error_log / rollback_error per-field
920
- // so a crashed update with a long error doesn't blow up the token
921
- // budget; opt in to a larger tail when actively debugging.
922
- //
923
- // Rendering helpers (shortDigest / fmtDuration / tailLog / renderAuditRow
924
- // / renderAuditList) live in ./update-audit-render and are unit-tested
925
- // directly — this tool is a thin shell around them.
926
- import { MAX_LOG_TAIL_BYTES, renderAuditList, type UpdateAuditApiRow } from "./update-audit-render";
927
-
928
- server.registerTool(
929
- "moor_update_audit",
930
- {
931
- title: "Update history (audit log)",
932
- description:
933
- "Read-only: recent moor_update_apply attempts and their outcomes. Each row shows audit_id, state (success | failed | rolled_back | rollback_failed | in_progress | crashed), duration, digest deltas, backup path, and any error logs. error_log preserves the ORIGINAL apply failure (never overwritten by rollback step details); rollback_error is set only on rollback_failed. Default tail is 4 KiB per log field; pass tail_bytes=0 to omit log bodies entirely (keeps the metadata line and replaces the body with a sized marker), or up to 16384 to read more.",
934
- inputSchema: z.object({
935
- limit: z
936
- .number()
937
- .int()
938
- .min(1)
939
- .max(200)
940
- .optional()
941
- .describe("How many most-recent attempts to return. Default 20, max 200."),
942
- tail_bytes: z
943
- .number()
944
- .int()
945
- .min(0)
946
- .max(MAX_LOG_TAIL_BYTES)
947
- .optional()
948
- .describe(
949
- "Max bytes of error_log and rollback_error returned inline per row. Default 4096 (4 KiB). 0 to omit log bodies entirely; 16384 max.",
950
- ),
951
- }),
952
- },
953
- async ({ limit, tail_bytes }) => {
954
- const qs = new URLSearchParams();
955
- if (limit !== undefined) qs.set("limit", String(limit));
956
- const path = qs.toString()
957
- ? `/api/server/update/audit?${qs.toString()}`
958
- : "/api/server/update/audit";
959
- const res = await apiGet(path);
960
- if (!res.ok) throw new Error(`update audit failed: ${res.status} ${await res.text()}`);
961
- const { rows } = (await res.json()) as { rows: UpdateAuditApiRow[] };
962
- return {
963
- content: [{ type: "text", text: renderAuditList(rows, { tail_bytes }) }],
964
- };
965
- },
966
- );
967
-
968
- server.registerTool(
969
- "moor_cleanup_plan",
970
- {
971
- title: "Cleanup Plan (dry-run)",
972
- description:
973
- "Dry-run: list Docker resources that are safe to delete on this host. v1 covers build cache (host-wide prune) and dangling images (per-ID). Returns candidates with reclaimable bytes. Pass the same candidate list to moor_cleanup_execute to actually delete. No state is kept between plan and execute — execute re-validates eligibility against current Docker state.",
974
- inputSchema: z.object({
975
- scope: z
976
- .array(z.enum(["build_cache", "dangling_image"]))
977
- .optional()
978
- .describe("Subset of categories to plan. Defaults to all v1 categories."),
979
- }),
980
- },
981
- async ({ scope }) => {
982
- const res = await apiPost("/api/server/cleanup/plan", { scope });
983
- if (!res.ok) throw new Error(`plan failed: ${res.status} ${await res.text()}`);
984
- const data = (await res.json()) as {
985
- candidates: Array<
986
- | { category: "build_cache"; reclaimable_bytes: number; label: string }
987
- | {
988
- category: "dangling_image";
989
- id: string;
990
- reclaimable_bytes: number;
991
- repo_tags: string[];
992
- label: string;
993
- }
994
- >;
995
- total_reclaimable_bytes: number;
996
- };
997
- if (data.candidates.length === 0) {
998
- return { content: [{ type: "text", text: "Nothing to clean up." }] };
999
- }
1000
- const lines = [
1001
- `${data.candidates.length} candidate(s), total reclaimable: ${formatBytes(data.total_reclaimable_bytes)}.`,
1002
- "Pass the candidates_json block below back to moor_cleanup_execute to delete.",
1003
- "",
1004
- ];
1005
- for (const c of data.candidates) {
1006
- if (c.category === "build_cache") {
1007
- lines.push(
1008
- `build_cache [${c.label}] — ${formatBytes(c.reclaimable_bytes)} reclaimable (host-wide prune)`,
1009
- );
1010
- } else {
1011
- const tags = c.repo_tags.length > 0 ? ` tags=${c.repo_tags.join(",")}` : "";
1012
- lines.push(
1013
- `dangling_image [${c.label}] id=${c.id} ${formatBytes(c.reclaimable_bytes)}${tags}`,
1014
- );
1015
- }
1016
- }
1017
- // Emit candidates_json so the agent doesn't have to reconstruct identifiers
1018
- // from the prose lines above. The execute side ignores extra fields, so
1019
- // passing the whole candidate objects (label, reclaimable_bytes, etc.) is
1020
- // safe — server re-validates eligibility and computes actual freed bytes.
1021
- lines.push("", "candidates_json:", JSON.stringify(data.candidates, null, 2));
1022
- return { content: [{ type: "text", text: lines.join("\n") }] };
1023
- },
1024
- );
1025
-
1026
- server.registerTool(
1027
- "moor_cleanup_execute",
1028
- {
1029
- title: "Cleanup Execute",
1030
- description:
1031
- "Delete the candidates returned by moor_cleanup_plan. Server uses only the identifying fields (category + id where applicable) and re-validates eligibility against current Docker state immediately before each delete — Docker state can change between plan and execute. Reclaimable byte estimates from plan are ignored; the server reports the actual freed bytes. Every execute writes an audit row.",
1032
- inputSchema: z.object({
1033
- candidates: z
1034
- .array(
1035
- z.union([
1036
- z.object({ category: z.literal("build_cache") }).passthrough(),
1037
- z
1038
- .object({ category: z.literal("dangling_image"), id: z.string().min(1) })
1039
- .passthrough(),
1040
- ]),
1041
- )
1042
- .min(1)
1043
- .describe("Candidates from moor_cleanup_plan. Extra fields are ignored server-side."),
1044
- }),
1045
- },
1046
- async ({ candidates }) => {
1047
- const res = await apiPost("/api/server/cleanup/execute", { candidates });
1048
- if (!res.ok) throw new Error(`execute failed: ${res.status} ${await res.text()}`);
1049
- const data = (await res.json()) as {
1050
- audit_id: number;
1051
- total_reclaimed_bytes: number;
1052
- results: Array<
1053
- | { category: "build_cache"; reclaimed_bytes: number; error: string | null }
1054
- | {
1055
- category: "dangling_image";
1056
- id: string;
1057
- reclaimed_bytes: number;
1058
- error: string | null;
1059
- }
1060
- >;
1061
- };
1062
- const lines = [
1063
- `audit_id=${data.audit_id} total_reclaimed=${formatBytes(data.total_reclaimed_bytes)}`,
1064
- "",
1065
- ];
1066
- for (const r of data.results) {
1067
- const status = r.error ? `ERROR: ${r.error}` : "ok";
1068
- if (r.category === "build_cache") {
1069
- lines.push(`build_cache: reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`);
1070
- } else {
1071
- lines.push(
1072
- `dangling_image id=${r.id} reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`,
1073
- );
1074
- }
1075
- }
1076
- return { content: [{ type: "text", text: lines.join("\n") }] };
1077
- },
1078
- );
1079
-
1080
- server.registerTool(
1081
- "moor_project_stats",
1082
- {
1083
- title: "Project Container Stats (live)",
1084
- description:
1085
- "Live container stats for one project: CPU percent, memory (excluding page cache, same accounting as `docker stats`), network and block I/O totals, PID count. Single Docker stats snapshot — CPU uses the cpu_stats/precpu_stats delta the daemon already includes. Stopped or never-started projects return running=false with zeroed counters (no 404).",
1086
- inputSchema: z.object({
1087
- project: z.string().describe("Project name or ID"),
1088
- }),
1089
- },
1090
- async ({ project }) => {
1091
- const p = await resolveProject(project);
1092
- const res = await apiGet(`/api/projects/${p.id}/container-stats`);
1093
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
1094
- const s = (await res.json()) as {
1095
- running: boolean;
1096
- cpu_percent: number;
1097
- memory_bytes: number;
1098
- memory_limit_bytes: number;
1099
- memory_percent: number;
1100
- network_rx_bytes: number;
1101
- network_tx_bytes: number;
1102
- block_read_bytes: number;
1103
- block_write_bytes: number;
1104
- pids: number;
1105
- };
1106
- if (!s.running) {
1107
- return {
1108
- content: [{ type: "text", text: `${p.name}: not running (zeroed counters returned).` }],
1109
- };
1110
- }
1111
- const memLimit = s.memory_limit_bytes > 0 ? formatBytes(s.memory_limit_bytes) : "unlimited";
1112
- const lines = [
1113
- `${p.name}: CPU ${s.cpu_percent}% | Memory ${formatBytes(s.memory_bytes)} / ${memLimit} (${s.memory_percent}%) | PIDs ${s.pids}`,
1114
- `Network: rx ${formatBytes(s.network_rx_bytes)} / tx ${formatBytes(s.network_tx_bytes)}`,
1115
- `Block I/O: read ${formatBytes(s.block_read_bytes)} / write ${formatBytes(s.block_write_bytes)}`,
1116
- ];
1117
- return { content: [{ type: "text", text: lines.join("\n") }] };
1118
- },
1119
- );
1120
-
1121
- function formatBytes(bytes: number): string {
1122
- if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
1123
- const units = ["B", "KB", "MB", "GB", "TB"];
1124
- const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
1125
- const val = bytes / 1024 ** i;
1126
- return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`;
1127
- }
1128
-
1129
- server.registerTool(
1130
- "moor_project_history",
1131
- {
1132
- title: "Project History (stored)",
1133
- description:
1134
- "Stored resource history + lifecycle events for one project over a time window — answers 'what was going on with this project around this case?' (NOT live: use moor_project_stats for a current snapshot). Resource samples are taken ~every minute; CPU is averaged across each interval and network/block reported as rates, both computed from raw counters and reset-aware. Events come from the Docker event stream (start/die/oom/kill/restart) and moor's own state changes. Window defaults to the last `hours` (24); pass from_ms/to_ms (epoch ms) for an exact window. A gap warning means events may be incomplete in that window.",
1135
- inputSchema: z.object({
1136
- project: z.string().describe("Project name or ID"),
1137
- hours: z
1138
- .number()
1139
- .optional()
1140
- .describe("Lookback window in hours (default 24). Ignored if from_ms/to_ms are given."),
1141
- from_ms: z.number().optional().describe("Window start, epoch milliseconds"),
1142
- to_ms: z.number().optional().describe("Window end, epoch milliseconds"),
1143
- }),
1144
- },
1145
- async ({ project, hours, from_ms, to_ms }) => {
1146
- const p = await resolveProject(project);
1147
- const to = to_ms ?? Date.now();
1148
- const from = from_ms ?? to - (hours ?? 24) * 3_600_000;
1149
- const res = await apiGet(`/api/projects/${p.id}/stats/history?from=${from}&to=${to}`);
1150
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
1151
- const h = (await res.json()) as {
1152
- from_ms: number;
1153
- to_ms: number;
1154
- events: Array<{ occurred_at_ms: number; source: string; action: string }>;
1155
- summary: {
1156
- sample_count: number;
1157
- running_sample_count: number;
1158
- cpu_percent_avg: number | null;
1159
- cpu_percent_max: number | null;
1160
- mem_bytes_max: number | null;
1161
- net_rx_bytes_total: number;
1162
- net_tx_bytes_total: number;
1163
- event_counts: Record<string, number>;
1164
- has_gap: boolean;
1165
- };
1166
- };
1167
- const s = h.summary;
1168
- const windowH = Math.round(((h.to_ms - h.from_ms) / 3_600_000) * 10) / 10;
1169
- const lines = [
1170
- `${p.name} history — window ~${windowH}h${s.has_gap ? " [⚠ event gap recorded: events may be incomplete]" : ""}`,
1171
- `Samples: ${s.sample_count} total, ${s.running_sample_count} running`,
1172
- `CPU: avg ${s.cpu_percent_avg ?? "n/a"}% / max ${s.cpu_percent_max ?? "n/a"}%`,
1173
- `Memory: max ${s.mem_bytes_max !== null ? formatBytes(s.mem_bytes_max) : "n/a"}`,
1174
- `Network: in ${formatBytes(s.net_rx_bytes_total)} / out ${formatBytes(s.net_tx_bytes_total)}`,
1175
- ];
1176
- const counts = Object.entries(s.event_counts);
1177
- if (counts.length > 0) {
1178
- lines.push(`Events: ${counts.map(([a, n]) => `${a} ${n}`).join(", ")}`);
1179
- }
1180
- const recent = h.events.slice(-8);
1181
- if (recent.length > 0) {
1182
- lines.push("Recent events:");
1183
- for (const e of recent) {
1184
- lines.push(` ${new Date(e.occurred_at_ms).toISOString()} ${e.action} (${e.source})`);
1185
- }
1186
- }
1187
- if (s.sample_count === 0 && h.events.length === 0) {
1188
- lines.push("(no stored history in this window)");
1189
- }
1190
- return { content: [{ type: "text", text: lines.join("\n") }] };
1191
- },
1192
- );
1193
-
1194
- server.registerTool(
1195
- "moor_project_get",
1196
- {
1197
- title: "Get Project",
1198
- description:
1199
- "Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).",
1200
- inputSchema: z.object({
1201
- project: z.string().describe("Project name or ID"),
1202
- }),
1203
- },
1204
- async ({ project }) => {
1205
- const p = await resolveProject(project);
1206
- return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] };
1207
- },
1208
- );
1209
-
1210
- server.registerTool(
1211
- "moor_project_create",
1212
- {
1213
- title: "Create Project",
1214
- description:
1215
- "Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild (or moor_deploy in a future release) to bring it up.",
1216
- inputSchema: z.object({
1217
- name: z
1218
- .string()
1219
- .regex(
1220
- /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
1221
- "name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -",
1222
- )
1223
- .describe("Project name (used as the container name suffix: moor-<name>)"),
1224
- github_url: z
1225
- .string()
1226
- .optional()
1227
- .describe("github.com URL; mutually exclusive with docker_image"),
1228
- docker_image: z
1229
- .string()
1230
- .optional()
1231
- .describe("Docker image reference (e.g. nginx:latest); mutually exclusive with github_url"),
1232
- branch: z.string().optional().describe("Git branch (default: main, for github_url projects)"),
1233
- dockerfile: z
1234
- .string()
1235
- .optional()
1236
- .describe("Dockerfile path within the repo (default: Dockerfile)"),
1237
- domain: z.string().optional().describe("Public domain to route to this container via Caddy"),
1238
- domain_port: z
1239
- .number()
1240
- .int()
1241
- .positive()
1242
- .optional()
1243
- .describe("Container port Caddy should forward to (required if domain is set)"),
1244
- restart_policy: z
1245
- .enum(["no", "on-failure", "always", "unless-stopped"])
1246
- .optional()
1247
- .describe("Docker restart policy (default: unless-stopped)"),
1248
- memory_limit_mb: z
1249
- .number()
1250
- .int()
1251
- .min(6)
1252
- .optional()
1253
- .describe(
1254
- "Max RAM in MB (also caps swap to the same value so the container can't burn through host swap). Min 6 (Docker's floor), max host total memory. Omit for unbounded. Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run).",
1255
- ),
1256
- cpus: z
1257
- .number()
1258
- .min(0.001)
1259
- .optional()
1260
- .describe(
1261
- "Max CPU cores. Fractional values OK (e.g. 0.5 = half a core). Min 0.001 (anything smaller rounds to Docker NanoCpus=0, which means unlimited — use omit for that). Max host core count. Takes effect on container recreate.",
1262
- ),
1263
- volumes: z
1264
- .array(
1265
- z.object({
1266
- name: z.string().min(1).describe("Logical volume name (unique per project)"),
1267
- target: z
1268
- .string()
1269
- .min(1)
1270
- .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"),
1271
- }),
1272
- )
1273
- .optional()
1274
- .describe(
1275
- "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.",
1276
- ),
1277
- source_credential_id: z
1278
- .number()
1279
- .int()
1280
- .positive()
1281
- .nullable()
1282
- .optional()
1283
- .describe(
1284
- "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).",
1285
- ),
1286
- command: z
1287
- .array(z.string())
1288
- .nullable()
1289
- .optional()
1290
- .describe(
1291
- 'Override the image\'s default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Lets a stock image run a custom command with no throwaway Dockerfile. Omit to keep the image default; pass [] or null to clear a previously-set override. Applies on container recreate.',
1292
- ),
1293
- entrypoint: z
1294
- .array(z.string())
1295
- .nullable()
1296
- .optional()
1297
- .describe(
1298
- "Override the image's ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.",
1299
- ),
1300
- }),
1301
- },
1302
- async (input) => {
1303
- const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
1304
- if (sources !== 1) {
1305
- throw new Error("Provide exactly one of github_url or docker_image");
1306
- }
1307
- if (input.github_url) validateGithubUrl(input.github_url);
1308
-
1309
- const { volumes, ...createBody } = input;
1310
- const res = await apiPost("/api/projects", createBody);
1311
- if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`);
1312
- const project = (await res.json()) as { id: number };
1313
-
1314
- // Volumes are a separate endpoint so the API stays single-concern. Loop
1315
- // through them; if any one fails, report what landed and what didn't.
1316
- const volumeFailures: Array<{ name: string; error: string }> = [];
1317
- const volumeCreated: string[] = [];
1318
- if (volumes && volumes.length > 0) {
1319
- for (const v of volumes) {
1320
- const vRes = await apiPost(`/api/projects/${project.id}/volumes`, v);
1321
- if (vRes.ok) volumeCreated.push(v.name);
1322
- else volumeFailures.push({ name: v.name, error: await vRes.text() });
1323
- }
1324
- }
1325
-
1326
- const lines = [JSON.stringify(project, null, 2)];
1327
- if (volumeCreated.length > 0) {
1328
- lines.push(`\nCreated volumes: ${volumeCreated.join(", ")}`);
1329
- }
1330
- if (volumeFailures.length > 0) {
1331
- lines.push(
1332
- `\nVolume failures (project was still created): ${volumeFailures
1333
- .map((f) => `${f.name}: ${f.error}`)
1334
- .join("; ")}`,
1335
- );
1336
- }
1337
- return { content: [{ type: "text", text: lines.join("\n") }] };
1338
- },
1339
- );
1340
-
1341
- server.registerTool(
1342
- "moor_project_update",
1343
- {
1344
- title: "Update Project",
1345
- description:
1346
- "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately. Resource-limit changes (memory_limit_mb, cpus) take effect on the next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run) — an already-running container keeps its existing limits.",
1347
- inputSchema: z.object({
1348
- project: z.string().describe("Project name or ID to update"),
1349
- name: z
1350
- .string()
1351
- .regex(
1352
- /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
1353
- "name must start alphanumeric; allowed: a-z A-Z 0-9 _ -",
1354
- )
1355
- .optional(),
1356
- github_url: z.string().optional(),
1357
- docker_image: z.string().optional(),
1358
- branch: z.string().optional(),
1359
- dockerfile: z.string().optional(),
1360
- domain: z.string().optional(),
1361
- domain_port: z.number().int().positive().optional(),
1362
- restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
1363
- memory_limit_mb: z
1364
- .number()
1365
- .int()
1366
- .min(6)
1367
- .nullable()
1368
- .optional()
1369
- .describe(
1370
- "Max RAM in MB. Pass null to clear (return to unbounded). Min 6, max host total memory. Takes effect on container recreate.",
1371
- ),
1372
- cpus: z
1373
- .number()
1374
- .min(0.001)
1375
- .nullable()
1376
- .optional()
1377
- .describe(
1378
- "Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate.",
1379
- ),
1380
- source_credential_id: z
1381
- .number()
1382
- .int()
1383
- .positive()
1384
- .nullable()
1385
- .optional()
1386
- .describe(
1387
- "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.",
1388
- ),
1389
- command: z
1390
- .array(z.string())
1391
- .nullable()
1392
- .optional()
1393
- .describe(
1394
- 'Override the image\'s default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Pass [] or null to clear the override and return to the image default. Takes effect on container recreate.',
1395
- ),
1396
- entrypoint: z
1397
- .array(z.string())
1398
- .nullable()
1399
- .optional()
1400
- .describe(
1401
- "Override the image's ENTRYPOINT as an argv array. Pass [] or null to clear. Takes effect on container recreate.",
1402
- ),
1403
- }),
1404
- },
1405
- async (input) => {
1406
- const { project, ...updates } = input;
1407
- if (Object.keys(updates).length === 0) {
1408
- throw new Error("Provide at least one field to update");
1409
- }
1410
- if (updates.github_url && updates.docker_image) {
1411
- throw new Error("Cannot set both github_url and docker_image in the same update");
1412
- }
1413
- if (updates.github_url) validateGithubUrl(updates.github_url);
1414
-
1415
- const p = await resolveProject(project);
1416
- const res = await apiPut(`/api/projects/${p.id}`, updates);
1417
- if (!res.ok) throw new Error(`Failed to update project: ${await res.text()}`);
1418
- const updated = await res.json();
1419
- return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
1420
- },
1421
- );
1422
-
1423
- server.registerTool(
1424
- "moor_project_delete",
1425
- {
1426
- title: "Delete Project",
1427
- description:
1428
- "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible. Named Docker volumes are preserved by default (data survives so a recreated project can remount them); pass purge_volumes: true to also delete the underlying Docker volumes — that deletion is also irreversible.",
1429
- inputSchema: z.object({
1430
- project: z.string().describe("Project name or ID to delete"),
1431
- confirm_name: z
1432
- .string()
1433
- .describe(
1434
- "Must equal the resolved project's name. Guards against deleting the wrong project.",
1435
- ),
1436
- purge_volumes: z
1437
- .boolean()
1438
- .optional()
1439
- .default(false)
1440
- .describe(
1441
- "Also delete the underlying Docker volumes (their data). Default false: project gone, volumes (and their data) preserved. The volume metadata is cleaned up either way; this flag only controls whether the data goes too.",
1442
- ),
1443
- }),
1444
- },
1445
- async ({ project, confirm_name, purge_volumes }) => {
1446
- const p = await resolveProject(project);
1447
- if (confirm_name !== p.name) {
1448
- throw new Error(
1449
- `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`,
1450
- );
1451
- }
1452
- const qs = purge_volumes ? "?purge_volumes=true" : "";
1453
- const res = await apiDelete(`/api/projects/${p.id}${qs}`);
1454
- if (!res.ok) {
1455
- const text = await res.text();
1456
- try {
1457
- const parsed = JSON.parse(text);
1458
- if (parsed?.message) throw new Error(parsed.message);
1459
- } catch {
1460
- // not json
1461
- }
1462
- throw new Error(`Failed to delete project: ${text}`);
1463
- }
1464
- // 204 No Content (no purge or no volumes) vs 200 JSON (purge with results)
1465
- if (res.status === 204) {
1466
- return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
1467
- }
1468
- const body = (await res.json()) as { volumes_purged?: number };
1469
- return {
1470
- content: [
1471
- {
1472
- type: "text",
1473
- text: `Deleted project ${p.name} (id=${p.id}). Purged ${body.volumes_purged ?? 0} Docker volume(s).`,
1474
- },
1475
- ],
1476
- };
1477
- },
1478
- );
1479
-
1480
- server.registerTool(
1481
- "moor_cron_create",
1482
- {
1483
- title: "Create Cron",
1484
- description:
1485
- "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted.",
1486
- inputSchema: z.object({
1487
- project: z.string().describe("Project name or ID"),
1488
- name: z.string().min(1).describe("Human-readable name for the cron"),
1489
- schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'),
1490
- command: z.string().min(1).describe("Shell command to run inside the project's container"),
1491
- }),
1492
- },
1493
- async ({ project, name, schedule, command }) => {
1494
- const err = validateCronSchedule(schedule);
1495
- if (err) throw new Error(`Invalid schedule: ${err}`);
1496
- const p = await resolveProject(project);
1497
- const res = await apiPost(`/api/projects/${p.id}/crons`, { name, schedule, command });
1498
- if (!res.ok) throw new Error(`Failed to create cron: ${await res.text()}`);
1499
- const cron = await res.json();
1500
- return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
1501
- },
1502
- );
1503
-
1504
- server.registerTool(
1505
- "moor_cron_update",
1506
- {
1507
- title: "Update Cron",
1508
- description: "Updates a cron's fields by id. Schedule is validated if provided.",
1509
- inputSchema: z.object({
1510
- cron_id: z.number().int().positive().describe("Cron ID"),
1511
- name: z.string().min(1).optional(),
1512
- schedule: z.string().optional(),
1513
- command: z.string().min(1).optional(),
1514
- enabled: z.boolean().optional().describe("Enable or disable the cron"),
1515
- }),
1516
- },
1517
- async ({ cron_id, name, schedule, command, enabled }) => {
1518
- if (schedule !== undefined) {
1519
- const err = validateCronSchedule(schedule);
1520
- if (err) throw new Error(`Invalid schedule: ${err}`);
1521
- }
1522
- const body: Record<string, unknown> = {};
1523
- if (name !== undefined) body.name = name;
1524
- if (schedule !== undefined) body.schedule = schedule;
1525
- if (command !== undefined) body.command = command;
1526
- if (enabled !== undefined) body.enabled = enabled ? 1 : 0;
1527
- if (Object.keys(body).length === 0) {
1528
- throw new Error("Provide at least one field to update");
1529
- }
1530
- const res = await apiPut(`/api/crons/${cron_id}`, body);
1531
- if (!res.ok) throw new Error(`Failed to update cron: ${await res.text()}`);
1532
- const cron = await res.json();
1533
- return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
1534
- },
1535
- );
1536
-
1537
- server.registerTool(
1538
- "moor_cron_delete",
1539
- {
1540
- title: "Delete Cron",
1541
- description: "Deletes a cron by id.",
1542
- inputSchema: z.object({
1543
- cron_id: z.number().int().positive().describe("Cron ID"),
1544
- }),
1545
- },
1546
- async ({ cron_id }) => {
1547
- const res = await apiDelete(`/api/crons/${cron_id}`);
1548
- if (!res.ok) throw new Error(`Failed to delete cron: ${await res.text()}`);
1549
- // API returns 204 whether or not the row existed; phrase the response so it
1550
- // doesn't claim a row was removed when it might already have been gone.
1551
- return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] };
1552
- },
1553
- );
1554
-
1555
- server.registerTool(
1556
- "moor_cron_run",
1557
- {
1558
- title: "Run Cron Now",
1559
- description:
1560
- "Triggers a cron to run immediately. Requires the project's container to be running.",
1561
- inputSchema: z.object({
1562
- cron_id: z.number().int().positive().describe("Cron ID"),
1563
- }),
1564
- },
1565
- async ({ cron_id }) => {
1566
- const res = await apiPost(`/api/crons/${cron_id}/run`);
1567
- if (!res.ok) {
1568
- const text = await res.text();
1569
- let message = text;
1570
- try {
1571
- const parsed = JSON.parse(text);
1572
- if (parsed?.error) message = parsed.error;
1573
- } catch {
1574
- // Not JSON; use raw text
1575
- }
1576
- throw new Error(message);
1577
- }
1578
- return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] };
1579
- },
1580
- );
1581
-
1582
- server.registerTool(
1583
- "moor_env_delete",
1584
- {
1585
- title: "Delete Environment Variables",
1586
- description:
1587
- "Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running.",
1588
- inputSchema: z.object({
1589
- project: z.string().describe("Project name or ID"),
1590
- keys: z.array(z.string().min(1)).min(1).describe("Env var keys to remove"),
1591
- }),
1592
- },
1593
- async ({ project, keys }) => {
1594
- const p = await resolveProject(project);
1595
-
1596
- const existingRes = await apiGet(`/api/projects/${p.id}/envs`);
1597
- if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`);
1598
- const existing = (await existingRes.json()) as { key: string; value: string }[];
1599
- const existingKeys = new Set(existing.map((v) => v.key));
1600
-
1601
- const toDelete = keys.filter((k) => existingKeys.has(k));
1602
- const missing = keys.filter((k) => !existingKeys.has(k));
1603
-
1604
- if (toDelete.length === 0) {
1605
- const existingList = [...existingKeys].sort().join(", ") || "(none)";
1606
- return {
1607
- content: [
1608
- {
1609
- type: "text",
1610
- text: `No matching keys on ${p.name}. Existing keys: ${existingList}`,
1611
- },
1612
- ],
1613
- };
1614
- }
1615
-
1616
- for (const key of toDelete) {
1617
- const res = await apiDelete(`/api/projects/${p.id}/envs/${encodeURIComponent(key)}`);
1618
- if (!res.ok) throw new Error(`Failed to delete ${key}: ${await res.text()}`);
1619
- }
1620
-
1621
- let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`;
1622
- if (missing.length > 0) text += ` (Not present: ${missing.join(", ")}.)`;
1623
-
1624
- if (p.status === "running") {
1625
- await apiPost(`/api/projects/${p.id}/stop`);
1626
- const startRes = await apiPost(`/api/projects/${p.id}/start`);
1627
- if (!startRes.ok) {
1628
- throw new Error(`Deleted vars but failed to restart: ${await startRes.text()}`);
1629
- }
1630
- text += " Container restarted.";
1631
- }
1632
-
1633
- return { content: [{ type: "text", text }] };
1634
- },
1635
- );
1636
-
1637
- server.registerTool(
1638
- "moor_dns_check",
1639
- {
1640
- title: "Check Domain DNS",
1641
- description:
1642
- "Resolves a domain's A record and reports whether it matches the server's public IP. Useful before pointing a project's domain at the server.",
1643
- inputSchema: z.object({
1644
- domain: z.string().min(1).describe("Domain to check, e.g. app.example.com"),
1645
- }),
1646
- },
1647
- async ({ domain }) => {
1648
- const res = await apiPost("/api/dns-check", { domain });
1649
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1650
- const data = (await res.json()) as {
1651
- resolves: boolean;
1652
- ip: string | null;
1653
- serverIp: string | null;
1654
- };
1655
- const lines = [
1656
- `Domain: ${domain}`,
1657
- `Resolves: ${data.resolves ? "yes" : "no"}`,
1658
- `Resolved IP: ${data.ip ?? "(none)"}`,
1659
- `Server IP: ${data.serverIp ?? "(unknown)"}`,
1660
- ];
1661
- if (data.ip && data.serverIp) {
1662
- lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`);
1663
- }
1664
- return { content: [{ type: "text", text: lines.join("\n") }] };
1665
- },
1666
- );
1667
-
1668
- // --- Volumes (#35) ---
1669
-
1670
- server.registerTool(
1671
- "moor_volume_list",
1672
- {
1673
- title: "List Project Volumes",
1674
- description:
1675
- "List the named Docker volumes attached to a project. Each entry includes the logical name (per-project handle), the in-container target path, and the actual Docker volume name (for `docker volume ls` / `docker volume inspect` outside moor).",
1676
- inputSchema: z.object({
1677
- project: z.string().describe("Project name or ID"),
1678
- }),
1679
- },
1680
- async ({ project }) => {
1681
- const p = await resolveProject(project);
1682
- const res = await apiGet(`/api/projects/${p.id}/volumes`);
1683
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1684
- const rows = (await res.json()) as Array<{
1685
- id: number;
1686
- name: string;
1687
- target: string;
1688
- docker_name: string;
1689
- }>;
1690
- if (rows.length === 0) {
1691
- return { content: [{ type: "text", text: `No volumes attached to ${p.name}.` }] };
1692
- }
1693
- const lines = rows.map(
1694
- (v) => `id=${v.id} name=${v.name} target=${v.target} docker_name=${v.docker_name}`,
1695
- );
1696
- return { content: [{ type: "text", text: lines.join("\n") }] };
1697
- },
1698
- );
1699
-
1700
- server.registerTool(
1701
- "moor_volume_add",
1702
- {
1703
- title: "Add Project Volume",
1704
- description:
1705
- "Attach a named Docker volume to a project. The volume is created lazily by Docker on first container start; moor stores the mount config (logical name, in-container target, and the generated docker_name like moor-<project>-<name>). Takes effect on container recreate (next moor_rebuild / moor_restart / moor_deploy / moor_project run) — already-running containers keep their existing mounts.",
1706
- inputSchema: z.object({
1707
- project: z.string().describe("Project name or ID"),
1708
- name: z
1709
- .string()
1710
- .min(1)
1711
- .describe("Logical volume name (unique per project; alphanumeric/_/-)"),
1712
- target: z
1713
- .string()
1714
- .min(1)
1715
- .describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)"),
1716
- }),
1717
- },
1718
- async ({ project, name, target }) => {
1719
- const p = await resolveProject(project);
1720
- const res = await apiPost(`/api/projects/${p.id}/volumes`, { name, target });
1721
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1722
- const created = (await res.json()) as {
1723
- id: number;
1724
- name: string;
1725
- target: string;
1726
- docker_name: string;
1727
- };
1728
- return {
1729
- content: [
1730
- {
1731
- type: "text",
1732
- text: `Attached volume to ${p.name}: id=${created.id}, name=${created.name}, target=${created.target}, docker_name=${created.docker_name}. Mount applies on next container recreate.`,
1733
- },
1734
- ],
1735
- };
1736
- },
1737
- );
1738
-
1739
- server.registerTool(
1740
- "moor_volume_remove",
1741
- {
1742
- title: "Remove Project Volume Mount",
1743
- description:
1744
- "Detach a named volume from a project's mount config. The underlying Docker volume (and its data) is intentionally preserved — to actually delete the data, use moor_project_delete with purge_volumes:true, or run `docker volume rm <docker_name>` manually. Takes effect on next container recreate.",
1745
- inputSchema: z.object({
1746
- project: z.string().describe("Project name or ID"),
1747
- volume_id: z.number().int().positive().describe("Volume ID from moor_volume_list"),
1748
- }),
1749
- },
1750
- async ({ project, volume_id }) => {
1751
- const p = await resolveProject(project);
1752
- const res = await apiDelete(`/api/projects/${p.id}/volumes/${volume_id}`);
1753
- if (res.status === 404) throw new Error(`Volume ${volume_id} not found on project ${p.name}`);
1754
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1755
- const body = (await res.json()) as { docker_name: string; message: string };
1756
- return { content: [{ type: "text", text: body.message }] };
1757
- },
1758
- );
1759
-
1760
- // --- Declarative file injection ---
1761
-
1762
- server.registerTool(
1763
- "moor_file_set",
1764
- {
1765
- title: "Set Project File",
1766
- description:
1767
- "Declare a file to inject into a project's container. moor writes it via a tar archive PUT right before the container starts, on every recreate, honoring the octal mode (e.g. 0600 for a TLS key). Identified by path — setting the same path again updates its content/mode rather than duplicating. Provide exactly one of content (inline) or env_ref (the name of a project env var to source content from at create time, so a secret stays in the env store instead of plaintext here). Takes effect on next container recreate (moor_rebuild / moor_restart / moor_deploy / moor_project run).",
1768
- inputSchema: z.object({
1769
- project: z.string().describe("Project name or ID"),
1770
- path: z
1771
- .string()
1772
- .min(1)
1773
- .describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"),
1774
- content: z
1775
- .string()
1776
- .optional()
1777
- .describe("Inline file contents. Provide exactly one of content or env_ref."),
1778
- env_ref: z
1779
- .string()
1780
- .optional()
1781
- .describe(
1782
- "Name of a project env var to source the contents from at create time. Keeps secrets (keys, certs) in the env store instead of plaintext here. Provide exactly one of content or env_ref.",
1783
- ),
1784
- mode: z
1785
- .string()
1786
- .optional()
1787
- .describe(
1788
- "Octal permission string applied in the tar header, e.g. '0600'. Default '0644'.",
1789
- ),
1790
- }),
1791
- },
1792
- async ({ project, path, content, env_ref, mode }) => {
1793
- const p = await resolveProject(project);
1794
- const body: Record<string, unknown> = { path };
1795
- if (content !== undefined) body.content = content;
1796
- if (env_ref !== undefined) body.env_ref = env_ref;
1797
- if (mode !== undefined) body.mode = mode;
1798
- const res = await apiPost(`/api/projects/${p.id}/files`, body);
1799
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1800
- const saved = (await res.json()) as {
1801
- id: number;
1802
- path: string;
1803
- mode: string;
1804
- source: string;
1805
- env_ref: string | null;
1806
- };
1807
- const verb = res.status === 201 ? "Added" : "Updated";
1808
- return {
1809
- content: [
1810
- {
1811
- type: "text",
1812
- text: `${verb} file on ${p.name}: id=${saved.id}, path=${saved.path}, mode=${saved.mode}, source=${saved.source}${saved.env_ref ? ` (env_ref=${saved.env_ref})` : ""}. Written into the container on next recreate.`,
1813
- },
1814
- ],
1815
- };
1816
- },
1817
- );
1818
-
1819
- server.registerTool(
1820
- "moor_file_list",
1821
- {
1822
- title: "List Project Files",
1823
- description:
1824
- "List the declarative files configured for a project. Each entry shows the in-container path, octal mode, and how content is sourced (inline or env). Raw inline content is never returned (it may be large, and env-sourced content lives in the env store).",
1825
- inputSchema: z.object({
1826
- project: z.string().describe("Project name or ID"),
1827
- }),
1828
- },
1829
- async ({ project }) => {
1830
- const p = await resolveProject(project);
1831
- const res = await apiGet(`/api/projects/${p.id}/files`);
1832
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1833
- const rows = (await res.json()) as Array<{
1834
- id: number;
1835
- path: string;
1836
- mode: string;
1837
- source: string;
1838
- env_ref: string | null;
1839
- }>;
1840
- if (rows.length === 0) {
1841
- return { content: [{ type: "text", text: `No files configured for ${p.name}.` }] };
1842
- }
1843
- const lines = rows.map(
1844
- (f) =>
1845
- `id=${f.id} path=${f.path} mode=${f.mode} source=${f.source}${f.env_ref ? ` env_ref=${f.env_ref}` : ""}`,
1846
- );
1847
- return { content: [{ type: "text", text: lines.join("\n") }] };
1848
- },
1849
- );
1850
-
1851
- server.registerTool(
1852
- "moor_file_remove",
1853
- {
1854
- title: "Remove Project File",
1855
- description:
1856
- "Remove a declared file from a project's injection set. The file stops being written on future container recreates; a copy already present in a running container is not deleted until the next recreate. Takes effect on next container recreate.",
1857
- inputSchema: z.object({
1858
- project: z.string().describe("Project name or ID"),
1859
- file_id: z.number().int().positive().describe("File ID from moor_file_list"),
1860
- }),
1861
- },
1862
- async ({ project, file_id }) => {
1863
- const p = await resolveProject(project);
1864
- const res = await apiDelete(`/api/projects/${p.id}/files/${file_id}`);
1865
- if (res.status === 404) throw new Error(`File ${file_id} not found on project ${p.name}`);
1866
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1867
- return {
1868
- content: [
1869
- {
1870
- type: "text",
1871
- text: `Removed file ${file_id} from ${p.name}. Applies on next container recreate.`,
1872
- },
1873
- ],
1874
- };
1875
- },
1876
- );
1877
-
1878
- // --- Runs history (#37) ---
1879
-
1880
- // A runs row can be a cron run, a build/manual run, OR a cron run whose cron
1881
- // was deleted (cron_id was SET NULL by the FK). The list alone can't tell the
1882
- // latter two apart, so labels are honest about ambiguity instead of confidently
1883
- // calling NULL cron_id "build."
1884
- function deriveRunStatus(row: {
1885
- finished_at: string | null;
1886
- exit_code: number | null;
1887
- }): "running" | "success" | "failed" {
1888
- if (!row.finished_at) return "running";
1889
- return row.exit_code === 0 ? "success" : "failed";
1890
- }
1891
-
1892
- function deriveRunType(row: { cron_id: number | null; cron_name: string | null }): string {
1893
- if (row.cron_name) return `cron(${row.cron_name})`;
1894
- // cron_id IS NULL — could be a genuine build/manual run, or a cron run
1895
- // whose cron has since been deleted (ON DELETE SET NULL on the FK).
1896
- return "build_or_manual";
1897
- }
1898
-
1899
- function formatMsShort(ms: number | null | undefined): string {
1900
- if (ms == null) return "—";
1901
- if (ms < 1000) return `${ms}ms`;
1902
- const s = Math.floor(ms / 1000);
1903
- if (s < 60) return `${s}s`;
1904
- const m = Math.floor(s / 60);
1905
- return `${m}m${s % 60}s`;
1906
- }
1907
-
1908
- server.registerTool(
1909
- "moor_runs",
1910
- {
1911
- title: "List Project Run History",
1912
- description:
1913
- "Paginated list of cron runs and build runs for a project. Returns one compact line per run (id, type, status, exit code, duration, output byte counts, timestamps) — stdout/stderr bodies are NOT included to avoid blowing token budgets on large build outputs. Use moor_run_get(run_id) to fetch the stored output for a single run (cron rows store full output; build/manual rows store at most a 64 KiB tail with the original total bytes recorded separately).",
1914
- inputSchema: z.object({
1915
- project: z.string().describe("Project name or ID"),
1916
- page: z
1917
- .number()
1918
- .int()
1919
- .positive()
1920
- .optional()
1921
- .default(1)
1922
- .describe("Page number (20 runs per page). Default 1."),
1923
- }),
1924
- },
1925
- async ({ project, page }) => {
1926
- const p = await resolveProject(project);
1927
- const res = await apiGet(`/api/projects/${p.id}/runs?include_output=false&page=${page}`);
1928
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1929
- const data = (await res.json()) as {
1930
- runs: Array<{
1931
- id: number;
1932
- cron_id: number | null;
1933
- cron_name: string | null;
1934
- cron_command: string | null;
1935
- started_at: string;
1936
- finished_at: string | null;
1937
- exit_code: number | null;
1938
- duration_ms: number | null;
1939
- stdout_bytes: number;
1940
- stderr_bytes: number;
1941
- stdout_total_bytes?: number;
1942
- stderr_total_bytes?: number;
1943
- }>;
1944
- total: number;
1945
- };
1946
- if (data.runs.length === 0) {
1947
- return {
1948
- content: [{ type: "text", text: `No runs recorded for ${p.name}.` }],
1949
- };
1950
- }
1951
- const lines: string[] = [];
1952
- lines.push(
1953
- `${p.name}: ${data.runs.length} run(s) on page ${page}, ${data.total} total. Use moor_run_get(run_id) for stored output (build/manual rows are tail-truncated; total bytes shown below).`,
1954
- );
1955
- for (const r of data.runs) {
1956
- const type = deriveRunType(r);
1957
- const status = deriveRunStatus(r);
1958
- const exit = r.exit_code != null ? ` exit=${r.exit_code}` : "";
1959
- const cmd = r.cron_command ? ` cmd="${r.cron_command}"` : "";
1960
- // #65: surface "what was emitted" (total) per byte field. For live or
1961
- // already-truncated build runs total > stored; for crons and historical
1962
- // build rows they're equal. Showing total is the operationally useful
1963
- // number — "what did Docker actually produce" — and stays accurate as a
1964
- // build streams in. Fall back to stdout_bytes if the API is old.
1965
- const outTotal = r.stdout_total_bytes ?? r.stdout_bytes;
1966
- const errTotal = r.stderr_total_bytes ?? r.stderr_bytes;
1967
- lines.push(
1968
- `id=${r.id} ${type} ${status}${exit} dur=${formatMsShort(r.duration_ms)} stdout=${outTotal}B stderr=${errTotal}B started=${r.started_at}${cmd}`,
1969
- );
1970
- }
1971
- return { content: [{ type: "text", text: lines.join("\n") }] };
1972
- },
1973
- );
1974
-
1975
- server.registerTool(
1976
- "moor_run_get",
1977
- {
1978
- title: "Get Run Detail",
1979
- description:
1980
- "Fetch one cron or build run with its stdout and stderr. Output is tail-truncated (default 8 KiB per stream; max 65536) to keep responses under typical agent token limits. Use tail_bytes=0 for metadata-only.",
1981
- inputSchema: z.object({
1982
- run_id: z.number().int().positive().describe("Run ID returned by moor_runs"),
1983
- tail_bytes: z
1984
- .number()
1985
- .int()
1986
- .min(0)
1987
- .max(65_536)
1988
- .optional()
1989
- .describe(
1990
- "Max bytes of each stream returned inline. Default 8192. Max 65536. Set to 0 for metadata-only.",
1991
- ),
1992
- }),
1993
- },
1994
- async ({ run_id, tail_bytes }) => {
1995
- const cap = tail_bytes ?? 8192;
1996
- const res = await apiGet(`/api/runs/${run_id}`);
1997
- if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1998
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1999
- const r = (await res.json()) as {
2000
- id: number;
2001
- cron_id: number | null;
2002
- cron_name: string | null;
2003
- cron_command: string | null;
2004
- started_at: string;
2005
- finished_at: string | null;
2006
- exit_code: number | null;
2007
- duration_ms: number | null;
2008
- stdout: string | null;
2009
- stderr: string | null;
2010
- stdout_total_bytes?: number | null;
2011
- stderr_total_bytes?: number | null;
2012
- };
2013
- const lines: string[] = [];
2014
- const type = deriveRunType(r);
2015
- const status = deriveRunStatus(r);
2016
- const exit = r.exit_code != null ? ` exit_code=${r.exit_code}` : "";
2017
- lines.push(`run_id=${r.id} ${type} ${status}${exit} duration=${formatMsShort(r.duration_ms)}`);
2018
- if (r.cron_command) lines.push(`cron_command: ${r.cron_command}`);
2019
- lines.push(`started_at: ${r.started_at}`);
2020
- if (r.finished_at) lines.push(`finished_at: ${r.finished_at}`);
2021
- // #65: runs.stdout/stderr for build runs is a server-side 64 KiB tail
2022
- // (TAIL_CAP_BYTES). Use stdout_total_bytes / stderr_total_bytes when the
2023
- // API provides them so appendStream can honestly report "last X of Y".
2024
- // For cron rows the stored payload IS the full output, and total == stored.
2025
- // Fall back to encoded length for older APIs that don't return the totals.
2026
- const stdoutStr = r.stdout ?? "";
2027
- const stderrStr = r.stderr ?? "";
2028
- const enc = new TextEncoder();
2029
- const stdoutTotal = r.stdout_total_bytes ?? enc.encode(stdoutStr).length;
2030
- const stderrTotal = r.stderr_total_bytes ?? enc.encode(stderrStr).length;
2031
- appendStream(lines, "stdout", stdoutStr, stdoutTotal, cap);
2032
- appendStream(lines, "stderr", stderrStr, stderrTotal, cap);
2033
- return { content: [{ type: "text", text: lines.join("\n") }] };
2034
- },
2035
- );
2036
-
2037
- server.registerTool(
2038
- "moor_run_stop",
2039
- {
2040
- title: "Stop or Cancel a Run",
2041
- description:
2042
- "Stops an active cron run or cancels an active build/pull run (from moor_rebuild / moor_deploy). Closing the connection to the Docker build/pull endpoint aborts the daemon-side job. Cancellation is only valid during the build/pull streaming phase — once the build finishes and container start has begun, the call returns not_cancellable. Returns one of: cancelled, cancelled_cron, not_cancellable, already_finished, not_active, not_found. These are all expected outcomes, not errors — the tool throws only on unexpected server failures.",
2043
- inputSchema: z.object({
2044
- run_id: z.number().int().positive().describe("Run ID from moor_runs"),
2045
- }),
2046
- },
2047
- async ({ run_id }) => {
2048
- const res = await apiPost(`/api/runs/${run_id}/stop`);
2049
- // The /stop route returns 200 for cancelled/cancelled_cron and 4xx
2050
- // for the rest of the known result categories (with a result field
2051
- // either way). All of those are expected outcomes — render them as
2052
- // content so the agent can react without try/catch. Only surface as
2053
- // an error if the response doesn't fit the documented shape (server
2054
- // error, parse failure, etc).
2055
- let data: { ok?: boolean; result?: string; error?: string };
2056
- try {
2057
- data = (await res.json()) as { ok?: boolean; result?: string; error?: string };
2058
- } catch {
2059
- throw new Error(`run_id=${run_id} server error: ${res.status} ${res.statusText}`);
2060
- }
2061
- if (typeof data.result === "string") {
2062
- return { content: [{ type: "text", text: `run_id=${run_id} ${data.result}` }] };
2063
- }
2064
- throw new Error(
2065
- `run_id=${run_id} unexpected response: status=${res.status} body=${JSON.stringify(data)}`,
2066
- );
2067
- },
2068
- );
2069
-
2070
- server.registerTool(
2071
- "moor_deploy",
2072
- {
2073
- title: "Deploy Project",
2074
- description:
2075
- "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.",
2076
- inputSchema: z.object({
2077
- name: z
2078
- .string()
2079
- .regex(
2080
- /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
2081
- "name must start alphanumeric; allowed chars: a-z A-Z 0-9 _ -",
2082
- )
2083
- .describe("Project name (also the container suffix: moor-<name>)"),
2084
- github_url: z
2085
- .string()
2086
- .optional()
2087
- .describe(
2088
- "GitHub repo URL: host must be github.com or www.github.com, path must be /owner/repo (optional .git). Mutually exclusive with docker_image.",
2089
- ),
2090
- docker_image: z
2091
- .string()
2092
- .optional()
2093
- .describe(
2094
- "Docker image reference (e.g. nginx:latest). Mutually exclusive with github_url.",
2095
- ),
2096
- branch: z.string().optional().describe("Git branch (API default: main)"),
2097
- dockerfile: z
2098
- .string()
2099
- .optional()
2100
- .describe("Dockerfile path in the repo (API default: Dockerfile)"),
2101
- domain: z.string().optional().describe("Public domain to route via Caddy"),
2102
- domain_port: z
2103
- .number()
2104
- .int()
2105
- .positive()
2106
- .optional()
2107
- .describe("Container port Caddy should forward to"),
2108
- restart_policy: z
2109
- .enum(["no", "on-failure", "always", "unless-stopped"])
2110
- .optional()
2111
- .describe("Docker restart policy (API default: unless-stopped)"),
2112
- memory_limit_mb: z
2113
- .number()
2114
- .int()
2115
- .min(6)
2116
- .nullable()
2117
- .optional()
2118
- .describe(
2119
- "Max RAM in MB (also caps swap to the same value). Min 6, max host total memory. Pass null on update to clear. Limits apply on container recreate, which deploy always does when run: true.",
2120
- ),
2121
- cpus: z
2122
- .number()
2123
- .min(0.001)
2124
- .nullable()
2125
- .optional()
2126
- .describe(
2127
- "Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear.",
2128
- ),
2129
- volumes: z
2130
- .array(
2131
- z.object({
2132
- name: z.string().min(1),
2133
- target: z.string().min(1),
2134
- }),
2135
- )
2136
- .optional()
2137
- .describe(
2138
- "Named Docker volumes to attach. Each entry becomes a per-project volume (stored as moor-<project>-<name>) and mounts at the given target on container recreate. On update_existing, additions only — no removals. Data survives container/project rebuilds unless explicitly purged via moor_project_delete with confirm_name (purge_volumes is a separate flag).",
2139
- ),
2140
- env: z
2141
- .record(z.string(), z.string())
2142
- .optional()
2143
- .describe(
2144
- "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.",
2145
- ),
2146
- source_credential_id: z
2147
- .number()
2148
- .int()
2149
- .positive()
2150
- .nullable()
2151
- .optional()
2152
- .describe(
2153
- "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.",
2154
- ),
2155
- command: z
2156
- .array(z.string())
2157
- .nullable()
2158
- .optional()
2159
- .describe(
2160
- 'Override the image default command (Docker Cmd) as an argv array, e.g. ["tunnel","run"]. Lets a stock image (e.g. cloudflare/cloudflared) run a custom command with no throwaway Dockerfile. Omit to keep the image default; pass [] or null to clear. Applies on the recreate the run step performs.',
2161
- ),
2162
- entrypoint: z
2163
- .array(z.string())
2164
- .nullable()
2165
- .optional()
2166
- .describe(
2167
- "Override the image ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.",
2168
- ),
2169
- files: z
2170
- .array(
2171
- z.object({
2172
- path: z
2173
- .string()
2174
- .min(1)
2175
- .describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"),
2176
- content: z
2177
- .string()
2178
- .optional()
2179
- .describe("Inline file contents. Provide exactly one of content or env_ref."),
2180
- env_ref: z
2181
- .string()
2182
- .optional()
2183
- .describe(
2184
- "Name of a project env var to source the contents from at create time, so a secret (TLS key, token) lives in the env store rather than in plaintext here. Provide exactly one of content or env_ref.",
2185
- ),
2186
- mode: z
2187
- .string()
2188
- .optional()
2189
- .describe("Octal permission string for the tar header, e.g. '0600'. Default '0644'."),
2190
- }),
2191
- )
2192
- .optional()
2193
- .describe(
2194
- "Declarative files to inject into the container before it starts, written on every recreate (additions/updates only — moor_deploy never removes files; use moor_file_remove). Each file's path identifies it; re-deploying the same path updates its content. Honors the octal mode in the tar header (e.g. 0600 for a key).",
2195
- ),
2196
- run: z
2197
- .boolean()
2198
- .optional()
2199
- .default(true)
2200
- .describe(
2201
- "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.",
2202
- ),
2203
- update_existing: z
2204
- .boolean()
2205
- .optional()
2206
- .default(false)
2207
- .describe("Allow updating a project that already exists. Default false (create-only)."),
2208
- }),
2209
- },
2210
- async (input) => {
2211
- // Up-front validation: do strict checks before any side effects.
2212
- if (input.github_url) validateGithubRepoUrl(input.github_url);
2213
- if (input.github_url && input.docker_image) {
2214
- throw new Error("Cannot set both github_url and docker_image");
2215
- }
2216
-
2217
- // #79: drain-mode preflight. moor_deploy is a composition: by the
2218
- // time the run step (Step 3) hits the drain 503 from /api/projects/
2219
- // :id/run, the create/update/volume/env side effects have already
2220
- // landed. Check drain server-side BEFORE any writes so a drained
2221
- // deploy fails cleanly without leaving partial state.
2222
- //
2223
- // Skipped when run: false because the no-run mode is metadata-only —
2224
- // no container work, so drain doesn't apply.
2225
- if (input.run !== false) {
2226
- const drainRes = await apiGet("/api/server/drain");
2227
- if (drainRes.ok) {
2228
- const { state } = (await drainRes.json()) as {
2229
- state: { enabled: boolean; reason: string | null; expires_at: string | null };
2230
- };
2231
- if (state.enabled) {
2232
- throw new Error(
2233
- `[drain] moor is draining (reason: ${state.reason ?? "(none)"}; expires_at: ${state.expires_at}). Refusing deploy before any project create/update side effects. Use moor_drain_disable to re-enable, or retry after expiry. Pass run: false if you only need metadata changes.`,
2234
- );
2235
- }
2236
- }
2237
- // If the drain endpoint is unreachable (older moor or transient
2238
- // failure), don't block the deploy — the per-route gate inside
2239
- // /api/projects/:id/run will still catch it before container work
2240
- // starts. Preflight is an optimization, not the guarantee.
2241
- }
2242
-
2243
- // Resolve existence and check domain conflicts from a single project list.
2244
- const listRes = await apiGet("/api/projects");
2245
- if (!listRes.ok) throw new Error(`Failed to list projects: ${listRes.status}`);
2246
- const projects = (await listRes.json()) as Project[];
2247
- const existing = projects.find((p) => p.name === input.name);
2248
-
2249
- if (existing && !input.update_existing) {
2250
- throw new Error(
2251
- `Project "${input.name}" already exists. Pass update_existing: true to update it.`,
2252
- );
2253
- }
2254
-
2255
- if (!existing) {
2256
- const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
2257
- if (sources !== 1) {
2258
- throw new Error("Provide exactly one of github_url or docker_image");
2259
- }
2260
- }
2261
-
2262
- // Normalize once for both the conflict check and the write. The API trims but
2263
- // does not lowercase, so " Example.com " vs an existing "example.com" would
2264
- // slip past the raw-string pre-check and only surface as a Caddy collision.
2265
- const normalizedDomain =
2266
- input.domain === undefined ? undefined : input.domain.trim().toLowerCase() || null;
2267
-
2268
- if (normalizedDomain) {
2269
- const conflict = projects.find(
2270
- (p) =>
2271
- p.domain && p.domain.trim().toLowerCase() === normalizedDomain && p.id !== existing?.id,
2272
- );
2273
- if (conflict) {
2274
- throw new Error(
2275
- `Domain "${normalizedDomain}" is already used by project "${conflict.name}" (id=${conflict.id}). Refusing before Caddy reload.`,
2276
- );
2277
- }
2278
- }
2279
-
2280
- // Step 1: create or update project metadata.
2281
- let projectId: number;
2282
- let projectName: string;
2283
- if (!existing) {
2284
- const createBody: Record<string, unknown> = {
2285
- name: input.name,
2286
- github_url: input.github_url,
2287
- docker_image: input.docker_image,
2288
- branch: input.branch,
2289
- dockerfile: input.dockerfile,
2290
- domain: normalizedDomain,
2291
- domain_port: input.domain_port,
2292
- restart_policy: input.restart_policy,
2293
- memory_limit_mb: input.memory_limit_mb,
2294
- cpus: input.cpus,
2295
- source_credential_id: input.source_credential_id,
2296
- command: input.command,
2297
- entrypoint: input.entrypoint,
2298
- };
2299
- const res = await apiPost("/api/projects", createBody);
2300
- if (!res.ok) throw new Error(`[create] ${await res.text()}`);
2301
- const created = (await res.json()) as Project;
2302
- projectId = created.id;
2303
- projectName = created.name;
2304
- } else {
2305
- // Update only fields explicitly provided. `name` is the lookup key here,
2306
- // not a rename target; use moor_project_update for renames.
2307
- const updateBody: Record<string, unknown> = {};
2308
- if (input.github_url !== undefined) updateBody.github_url = input.github_url;
2309
- if (input.docker_image !== undefined) updateBody.docker_image = input.docker_image;
2310
- if (input.branch !== undefined) updateBody.branch = input.branch;
2311
- if (input.dockerfile !== undefined) updateBody.dockerfile = input.dockerfile;
2312
- if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain;
2313
- if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port;
2314
- if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy;
2315
- if (input.memory_limit_mb !== undefined) updateBody.memory_limit_mb = input.memory_limit_mb;
2316
- if (input.cpus !== undefined) updateBody.cpus = input.cpus;
2317
- if (input.source_credential_id !== undefined)
2318
- updateBody.source_credential_id = input.source_credential_id;
2319
- if (input.command !== undefined) updateBody.command = input.command;
2320
- if (input.entrypoint !== undefined) updateBody.entrypoint = input.entrypoint;
2321
-
2322
- if (Object.keys(updateBody).length > 0) {
2323
- const res = await apiPut(`/api/projects/${existing.id}`, updateBody);
2324
- if (!res.ok) throw new Error(`[update] ${await res.text()}`);
2325
- }
2326
- projectId = existing.id;
2327
- projectName = existing.name;
2328
- }
2329
-
2330
- // Step 1.5: add named volumes (additions only — moor_deploy never removes
2331
- // volumes, even on update_existing). Mounts apply on next container
2332
- // recreate, which the run step below triggers by default.
2333
- if (input.volumes && input.volumes.length > 0) {
2334
- // Cache the existing list once so we can resolve 409s without re-fetching
2335
- // per conflict. Only fetched if a 409 actually occurs.
2336
- let existingVolumes: Array<{ name: string; target: string }> | null = null;
2337
- for (const v of input.volumes) {
2338
- const vRes = await apiPost(`/api/projects/${projectId}/volumes`, v);
2339
- if (vRes.ok) continue;
2340
- const text = await vRes.text();
2341
- if (vRes.status !== 409) {
2342
- throw new Error(`[volumes] failed to add ${v.name}: ${text}`);
2343
- }
2344
- // 409 is tolerable ONLY if the existing volume matches the requested
2345
- // spec exactly (same name, same target). A 409 with a drifted target
2346
- // means the operator changed the desired mount and we'd silently
2347
- // ignore the change — fail loudly instead.
2348
- if (existingVolumes === null) {
2349
- const listRes = await apiGet(`/api/projects/${projectId}/volumes`);
2350
- if (!listRes.ok) {
2351
- throw new Error(
2352
- `[volumes] could not resolve 409 on ${v.name}: failed to list existing volumes: ${await listRes.text()}`,
2353
- );
2354
- }
2355
- existingVolumes = (await listRes.json()) as Array<{ name: string; target: string }>;
2356
- }
2357
- const match = existingVolumes.find((e) => e.name === v.name);
2358
- if (!match) {
2359
- // 409 was for some other reason (target collision under a different
2360
- // name, or cross-project docker_name collision). Operator must
2361
- // intervene.
2362
- throw new Error(
2363
- `[volumes] conflict adding ${v.name}: ${text} (no existing volume by that name; check for target collision)`,
2364
- );
2365
- }
2366
- if (match.target !== v.target) {
2367
- throw new Error(
2368
- `[volumes] conflict adding ${v.name}: existing target "${match.target}" differs from requested "${v.target}". moor_deploy does not change mount targets; use moor_volume_remove + moor_volume_add explicitly.`,
2369
- );
2370
- }
2371
- // Same name, same target — idempotent re-run, tolerable.
2372
- }
2373
- }
2374
-
2375
- // Step 1.6: inject declarative files (additions/updates only — deploy never
2376
- // removes files; use moor_file_remove for that). The route upserts by path,
2377
- // so re-deploying the same path updates its content. Files are written into
2378
- // the container right before start on the recreate the run step triggers.
2379
- if (input.files && input.files.length > 0) {
2380
- for (const f of input.files) {
2381
- const fRes = await apiPost(`/api/projects/${projectId}/files`, f);
2382
- if (!fRes.ok) {
2383
- throw new Error(`[files] failed to set ${f.path}: ${await fRes.text()}`);
2384
- }
2385
- }
2386
- }
2387
-
2388
- // Step 2: merge envs. Omitted env leaves existing untouched; {} is a no-op.
2389
- const envEntries = input.env ? Object.entries(input.env) : [];
2390
- const envProvided = envEntries.length > 0;
2391
- if (envProvided) {
2392
- const existingRes = await apiGet(`/api/projects/${projectId}/envs`);
2393
- if (!existingRes.ok) {
2394
- throw new Error(`[set_env] Failed to read envs: ${existingRes.status}`);
2395
- }
2396
- const existingEnvs = (await existingRes.json()) as { key: string; value: string }[];
2397
- const merged = new Map(existingEnvs.map((v) => [v.key, v.value]));
2398
- for (const [k, v] of envEntries) merged.set(k, v);
2399
- const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
2400
- const putRes = await apiPut(`/api/projects/${projectId}/envs`, allVars);
2401
- if (!putRes.ok) throw new Error(`[set_env] ${await putRes.text()}`);
2402
- }
2403
-
2404
- // Step 3: run, default true. Wait for the full SSE stream like moor_rebuild.
2405
- let runLogs = "";
2406
- let runStructuredError: { code: string; message: string } | undefined;
2407
- if (input.run) {
2408
- const runRes = await apiPost(`/api/projects/${projectId}/run`);
2409
- if (!runRes.ok) throw new Error(`[run] ${await runRes.text()}`);
2410
- const { logs, error, structuredError } = await readSSE(runRes);
2411
- runLogs = logs;
2412
- // #119: classified failure (today: source_credential_required) is
2413
- // returned as isError below so the agent can branch on the code
2414
- // instead of parsing a thrown message.
2415
- if (structuredError) {
2416
- runStructuredError = structuredError;
2417
- } else if (error) {
2418
- throw new Error(`[run] ${error}`);
2419
- }
2420
- }
2421
-
2422
- const lines: string[] = [];
2423
- lines.push(
2424
- existing
2425
- ? `Updated project ${projectName} (id=${projectId}).`
2426
- : `Created project ${projectName} (id=${projectId}).`,
2427
- );
2428
- if (envProvided) {
2429
- lines.push(
2430
- `Merged ${envEntries.length} env var(s): ${envEntries.map(([k]) => k).join(", ")}.`,
2431
- );
2432
- }
2433
- if (!input.run) {
2434
- if (envProvided && existing?.status === "running") {
2435
- lines.push(
2436
- "Note: project is running; env changes will not take effect until the next run or restart.",
2437
- );
2438
- }
2439
- } else {
2440
- lines.push("");
2441
- lines.push("Build/run output:");
2442
- lines.push(runLogs || "(no output)");
2443
- }
2444
- // #119: if the build was classified as auth-failure, return isError
2445
- // with the structured payload so the agent can call _check + add a
2446
- // credential and retry. The project row exists (create/update already
2447
- // committed) so the agent just needs to fix the credential and run
2448
- // deploy again with the pinned id.
2449
- if (runStructuredError) {
2450
- lines.push("");
2451
- lines.push(`Failed: code=${runStructuredError.code} message=${runStructuredError.message}`);
2452
- return {
2453
- content: [{ type: "text", text: lines.join("\n") }],
2454
- structuredContent: { ...runStructuredError, project_id: projectId },
2455
- isError: true,
2456
- };
2457
- }
2458
- return { content: [{ type: "text", text: lines.join("\n") }] };
2459
- },
2460
- );
2461
-
2462
- // --- Async exec (#34 Phase B) ---
2463
-
2464
- server.registerTool(
2465
- "moor_exec_async",
2466
- {
2467
- title: "Start Async Exec",
2468
- description:
2469
- "Run a long-lived command inside a project's container, returning immediately with a run_id. Use moor_exec_status to poll for output and exit code; moor_exec_stop to terminate. Bounded by an optional timeout_ms (default 86400000 = 24h; min 60000 = 1 min; max 86400000). The recorded output is tail-truncated to the last 64 KiB per stream; stdout_total_bytes and stderr_total_bytes report the full pre-truncation byte count.",
2470
- inputSchema: z.object({
2471
- project: z.string().describe("Project name or ID"),
2472
- command: z.string().min(1).describe("Shell command to execute"),
2473
- timeout_ms: z
2474
- .number()
2475
- .int()
2476
- .min(60_000)
2477
- .max(86_400_000)
2478
- .optional()
2479
- .describe(
2480
- "Safety timeout in milliseconds. When exceeded, the process tree is terminated and the run is marked timed_out. Default 86400000 (24h). Min 60000. Max 86400000.",
2481
- ),
2482
- }),
2483
- },
2484
- async ({ project, command, timeout_ms }) => {
2485
- const p = await resolveProject(project);
2486
- const body: Record<string, unknown> = { command };
2487
- if (timeout_ms !== undefined) body.timeout_ms = timeout_ms;
2488
- const res = await apiPost(`/api/projects/${p.id}/exec/async`, body);
2489
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
2490
- const data = (await res.json()) as { run_id: number };
2491
- return {
2492
- content: [
2493
- {
2494
- type: "text",
2495
- text: `Started async exec on ${p.name}. run_id=${data.run_id}. Use moor_exec_status to poll; moor_exec_stop to terminate.`,
2496
- },
2497
- ],
2498
- };
2499
- },
2500
- );
2501
-
2502
- server.registerTool(
2503
- "moor_exec_status",
2504
- {
2505
- title: "Get Async Exec Status",
2506
- description:
2507
- "Return the current state of an async exec run: state, exit code (when finished), running tail of stdout/stderr (default 8 KiB each inline; the API stores up to 64 KiB), total bytes seen, duration, and any error message. State is one of: running, exited, stopped, timed_out, error. Pass tail_bytes to control how many bytes of each stream are returned inline (0 to 65536; default 8192). The API's 64 KiB-per-stream storage cap is unchanged — tail_bytes only controls what the MCP tool returns to keep responses under typical agent token limits.",
2508
- inputSchema: z.object({
2509
- run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
2510
- tail_bytes: z
2511
- .number()
2512
- .int()
2513
- .min(0)
2514
- .max(65_536)
2515
- .optional()
2516
- .describe(
2517
- "Max bytes of each stream (stdout, stderr) returned inline. Default 8192. Max 65536 (the API storage cap). Set to 0 for metadata-only.",
2518
- ),
2519
- }),
2520
- },
2521
- async ({ run_id, tail_bytes }) => {
2522
- const cap = tail_bytes ?? 8192;
2523
- const res = await apiGet(`/api/exec/${run_id}`);
2524
- if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
2525
- if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
2526
- const data = (await res.json()) as {
2527
- id: number;
2528
- state: string;
2529
- exit_code: number | null;
2530
- stdout: string;
2531
- stderr: string;
2532
- stdout_total_bytes: number;
2533
- stderr_total_bytes: number;
2534
- duration_ms: number;
2535
- command: string;
2536
- killed_pid: string | null;
2537
- error_message: string | null;
2538
- started_at: string;
2539
- finished_at: string | null;
2540
- };
2541
- const lines: string[] = [];
2542
- lines.push(
2543
- `run_id=${data.id} state=${data.state} duration=${formatMs(data.duration_ms)}` +
2544
- (data.exit_code !== null ? ` exit_code=${data.exit_code}` : ""),
2545
- );
2546
- lines.push(`command: ${data.command}`);
2547
- if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`);
2548
- if (data.error_message) lines.push(`error: ${data.error_message}`);
2549
- appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap);
2550
- appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap);
2551
- return { content: [{ type: "text", text: lines.join("\n") }] };
2552
- },
2553
- );
2554
-
2555
- function appendStream(
2556
- lines: string[],
2557
- name: string,
2558
- raw: string,
2559
- totalBytes: number,
2560
- cap: number,
2561
- ): void {
2562
- if (!raw && totalBytes === 0) return;
2563
- if (!raw) {
2564
- // API returned an empty string but the stream did emit data (totalBytes > 0
2565
- // is possible when no bytes survived API-side tail cap, though unlikely).
2566
- lines.push(`${name}_total_bytes=${totalBytes}`);
2567
- return;
2568
- }
2569
- const { tail, storedBytes, trimmed: mcpTrimmed } = tailUtf8(raw, cap);
2570
- const apiTrimmed = totalBytes > storedBytes;
2571
- let header: string;
2572
- if (mcpTrimmed && apiTrimmed) {
2573
- header = `${name} (showing last ${tail.length} chars of ${storedBytes} stored bytes; ${totalBytes} total bytes seen):`;
2574
- } else if (mcpTrimmed) {
2575
- header = `${name} (showing last ${tail.length} chars of ${storedBytes} total bytes):`;
2576
- } else if (apiTrimmed) {
2577
- header = `${name} (tail of ${storedBytes} stored from ${totalBytes} total bytes seen):`;
2578
- } else {
2579
- header = `${name}:`;
2580
- }
2581
- lines.push(header);
2582
- if (cap > 0) lines.push(tail);
2583
- }
2584
-
2585
- server.registerTool(
2586
- "moor_exec_stop",
2587
- {
2588
- title: "Stop Async Exec",
2589
- description:
2590
- "Terminate a running async exec by run_id. Walks the descendant process tree inside the container and sends SIGTERM then SIGKILL. Always transitions the run to a terminal state: state=stopped on clean termination (all descendants gone), state=error if any descendant survived OR if the kill handle was lost (moor restart, missing pidfile). Stop is NOT retry-safe — the kill script removes the pidfile after every attempt, and reparented survivors are unreachable from the original PID.",
2591
- inputSchema: z.object({
2592
- run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
2593
- }),
2594
- },
2595
- async ({ run_id }) => {
2596
- const res = await apiPost(`/api/exec/${run_id}/stop`);
2597
- if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
2598
- const data = (await res.json()) as {
2599
- ok: boolean;
2600
- state: string;
2601
- killed_pid: string | null;
2602
- live_remaining: number;
2603
- message: string;
2604
- };
2605
- return {
2606
- content: [
2607
- {
2608
- type: "text",
2609
- text: `run_id=${run_id} state=${data.state} ${data.message}`,
2610
- },
2611
- ],
2612
- };
2613
- },
2614
- );
2615
-
2616
- // --- Registry credentials ---
2617
- //
2618
- // Server-wide credentials used by the pull path to attach
2619
- // X-Registry-Auth on /images/create for private images. Read shape
2620
- // is write-only: the raw secret never leaves the API. Tool descriptions
2621
- // flag that *inputs* (add/update) carry the secret over the tool-call
2622
- // path and are visible to the MCP client - same security model as
2623
- // moor_env_set. structuredContent mirrors the API metadata shape so
2624
- // agents can reason over it without scraping the human text.
2625
-
2626
- type RegistryCredentialMetadata = {
2627
- id: number;
2628
- hostname: string;
2629
- username: string;
2630
- secret: { configured: true; kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown" };
2631
- created_at: string;
2632
- updated_at: string;
2633
- };
2634
-
2635
- function renderCredentialLine(c: RegistryCredentialMetadata): string {
2636
- return `id=${c.id} ${c.hostname} user=${c.username} kind=${c.secret.kind} updated=${c.updated_at}`;
2637
- }
2638
-
2639
- server.registerTool(
2640
- "moor_registry_credentials_list",
2641
- {
2642
- title: "List Registry Credentials",
2643
- description:
2644
- "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'.",
2645
- },
2646
- async () => {
2647
- const res = await apiGet("/api/server/registry-credentials");
2648
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2649
- const data = (await res.json()) as { rows: RegistryCredentialMetadata[] };
2650
- const text =
2651
- data.rows.length === 0
2652
- ? "No registry credentials configured. The pull path falls back to anonymous for every registry."
2653
- : data.rows.map(renderCredentialLine).join("\n");
2654
- return {
2655
- content: [{ type: "text", text }],
2656
- structuredContent: { rows: data.rows },
2657
- };
2658
- },
2659
- );
2660
-
2661
- server.registerTool(
2662
- "moor_registry_credential_get",
2663
- {
2664
- title: "Get Registry Credential",
2665
- description:
2666
- "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.",
2667
- inputSchema: z.object({
2668
- id: z
2669
- .number()
2670
- .int()
2671
- .positive()
2672
- .describe("Credential id (from moor_registry_credentials_list)"),
2673
- }),
2674
- },
2675
- async ({ id }) => {
2676
- const res = await apiGet(`/api/server/registry-credentials/${id}`);
2677
- if (res.status === 404) throw new Error(`credential id=${id} not found`);
2678
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2679
- const row = (await res.json()) as RegistryCredentialMetadata;
2680
- return {
2681
- content: [{ type: "text", text: renderCredentialLine(row) }],
2682
- structuredContent: row as unknown as Record<string, unknown>,
2683
- };
2684
- },
2685
- );
2686
-
2687
- server.registerTool(
2688
- "moor_registry_credential_add",
2689
- {
2690
- title: "Add Registry Credential",
2691
- description:
2692
- "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.",
2693
- inputSchema: z.object({
2694
- hostname: z
2695
- .string()
2696
- .describe(
2697
- "Bare registry host as parsed from an image ref. Examples: ghcr.io, docker.io, localhost:5000, registry.example.com:5000. No scheme, no path.",
2698
- ),
2699
- username: z
2700
- .string()
2701
- .describe("Registry username. For GHCR with a classic PAT, use your GitHub username."),
2702
- secret: z
2703
- .string()
2704
- .describe(
2705
- "Registry password or token. For GHCR, a classic PAT with read:packages is the documented path. Visible to the MCP client on input.",
2706
- ),
2707
- }),
2708
- },
2709
- async ({ hostname, username, secret }) => {
2710
- const res = await apiPost("/api/server/registry-credentials", { hostname, username, secret });
2711
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2712
- const row = (await res.json()) as RegistryCredentialMetadata;
2713
- return {
2714
- content: [
2715
- {
2716
- type: "text",
2717
- text: `Added credential for ${row.hostname} (id=${row.id}, kind=${row.secret.kind}).`,
2718
- },
2719
- ],
2720
- structuredContent: row as unknown as Record<string, unknown>,
2721
- };
2722
- },
2723
- );
2724
-
2725
- server.registerTool(
2726
- "moor_registry_credential_update",
2727
- {
2728
- title: "Update Registry Credential",
2729
- description:
2730
- "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.",
2731
- inputSchema: z.object({
2732
- id: z.number().int().positive().describe("Credential id to update"),
2733
- username: z.string().optional().describe("New username (optional)"),
2734
- secret: z
2735
- .string()
2736
- .optional()
2737
- .describe("New secret (optional). Visible to the MCP client on input."),
2738
- }),
2739
- },
2740
- async ({ id, username, secret }) => {
2741
- if (username === undefined && secret === undefined) {
2742
- throw new Error("must provide at least one of username or secret to update");
2743
- }
2744
- const patch: Record<string, string> = {};
2745
- if (username !== undefined) patch.username = username;
2746
- if (secret !== undefined) patch.secret = secret;
2747
- const res = await apiPut(`/api/server/registry-credentials/${id}`, patch);
2748
- if (res.status === 404) throw new Error(`credential id=${id} not found`);
2749
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2750
- const row = (await res.json()) as RegistryCredentialMetadata;
2751
- const rotated: string[] = [];
2752
- if (username !== undefined) rotated.push("username");
2753
- if (secret !== undefined) rotated.push("secret");
2754
- return {
2755
- content: [
2756
- {
2757
- type: "text",
2758
- text: `Updated credential id=${id} (${row.hostname}): rotated ${rotated.join(" + ")}. kind=${row.secret.kind}.`,
2759
- },
2760
- ],
2761
- structuredContent: row as unknown as Record<string, unknown>,
2762
- };
2763
- },
2764
- );
2765
-
2766
- server.registerTool(
2767
- "moor_registry_credential_delete",
2768
- {
2769
- title: "Delete Registry Credential",
2770
- description:
2771
- "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.",
2772
- inputSchema: z.object({
2773
- id: z.number().int().positive().describe("Credential id to delete"),
2774
- confirm_hostname: z
2775
- .string()
2776
- .describe(
2777
- "Must equal the credential row's hostname exactly. Resolved via moor_registry_credential_get and compared before deletion.",
2778
- ),
2779
- }),
2780
- },
2781
- async ({ id, confirm_hostname }) => {
2782
- const getRes = await apiGet(`/api/server/registry-credentials/${id}`);
2783
- if (getRes.status === 404) throw new Error(`credential id=${id} not found`);
2784
- if (!getRes.ok)
2785
- throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`);
2786
- const row = (await getRes.json()) as RegistryCredentialMetadata;
2787
- if (confirm_hostname !== row.hostname) {
2788
- throw new Error(
2789
- `confirm_hostname "${confirm_hostname}" does not match resolved hostname "${row.hostname}". Refusing to delete.`,
2790
- );
2791
- }
2792
- const delRes = await apiDelete(`/api/server/registry-credentials/${id}`);
2793
- if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`);
2794
- return {
2795
- content: [{ type: "text", text: `Deleted credential for ${row.hostname} (id=${id}).` }],
2796
- structuredContent: { deleted: { id, hostname: row.hostname } },
2797
- };
2798
- },
2799
- );
2800
-
2801
- // --- Source credentials (HTTPS PATs for private Git repos) ---
2802
- //
2803
- // v1 ships HTTPS PATs only. Read paths return metadata + secret.kind;
2804
- // the raw token only crosses MCP on `add` and `update`. Delete uses
2805
- // confirm_label since (hostname, label) is the disambiguation key
2806
- // (multiple github.com rows coexist by design). The check tool runs a
2807
- // real `git ls-remote` inside moor to validate access before any
2808
- // deploy commits state.
2809
-
2810
- type SourceCredentialMetadata = {
2811
- id: number;
2812
- hostname: string;
2813
- label: string;
2814
- username: string;
2815
- secret: {
2816
- configured: true;
2817
- kind: "github_classic_pat" | "github_fine_grained_pat" | "unknown";
2818
- };
2819
- state: "active" | "failed";
2820
- expires_at: string | null;
2821
- last_checked_at: string | null;
2822
- last_check_status: string | null;
2823
- created_at: string;
2824
- updated_at: string;
2825
- };
2826
-
2827
- function renderSourceCredentialLine(c: SourceCredentialMetadata): string {
2828
- const checked = c.last_check_status ? ` last_check=${c.last_check_status}` : "";
2829
- return `id=${c.id} ${c.hostname} label=${c.label} user=${c.username} kind=${c.secret.kind} state=${c.state}${checked}`;
2830
- }
2831
-
2832
- server.registerTool(
2833
- "moor_source_credentials_list",
2834
- {
2835
- title: "List Source Credentials",
2836
- description:
2837
- "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.",
2838
- },
2839
- async () => {
2840
- const res = await apiGet("/api/server/source-credentials");
2841
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2842
- const data = (await res.json()) as { rows: SourceCredentialMetadata[] };
2843
- const text =
2844
- data.rows.length === 0
2845
- ? "No source credentials configured. Public repos work anonymously."
2846
- : data.rows.map(renderSourceCredentialLine).join("\n");
2847
- return {
2848
- content: [{ type: "text", text }],
2849
- structuredContent: { rows: data.rows },
2850
- };
2851
- },
2852
- );
2853
-
2854
- server.registerTool(
2855
- "moor_source_credential_get",
2856
- {
2857
- title: "Get Source Credential",
2858
- description:
2859
- "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.",
2860
- inputSchema: z.object({
2861
- id: z.number().int().positive().describe("Credential id (from moor_source_credentials_list)"),
2862
- }),
2863
- },
2864
- async ({ id }) => {
2865
- const res = await apiGet(`/api/server/source-credentials/${id}`);
2866
- if (res.status === 404) throw new Error(`source credential id=${id} not found`);
2867
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2868
- const row = (await res.json()) as SourceCredentialMetadata;
2869
- return {
2870
- content: [{ type: "text", text: renderSourceCredentialLine(row) }],
2871
- structuredContent: row as unknown as Record<string, unknown>,
2872
- };
2873
- },
2874
- );
2875
-
2876
- server.registerTool(
2877
- "moor_source_credential_add",
2878
- {
2879
- title: "Add Source Credential",
2880
- description:
2881
- "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.",
2882
- inputSchema: z.object({
2883
- hostname: z
2884
- .string()
2885
- .describe("Bare Git host: github.com, gitlab.com, etc. No scheme, no path."),
2886
- label: z
2887
- .string()
2888
- .describe(
2889
- "Operator-supplied label for disambiguation when multiple credentials share a host (e.g. 'personal', 'work-org', 'acme-clients'). Trimmed at storage.",
2890
- ),
2891
- username: z
2892
- .string()
2893
- .describe(
2894
- "Git username. For GitHub fine-grained PATs, use 'x-access-token'. For classic PATs, your GitHub username works too.",
2895
- ),
2896
- secret: z
2897
- .string()
2898
- .describe(
2899
- "Git token (PAT). Visible to the MCP client on input; rotate via moor_source_credential_update if exposed.",
2900
- ),
2901
- expires_at: z
2902
- .string()
2903
- .nullable()
2904
- .optional()
2905
- .describe(
2906
- "Operator-supplied expiry timestamp (PAT expiry from GitHub). Optional; helps rotation reminders.",
2907
- ),
2908
- }),
2909
- },
2910
- async ({ hostname, label, username, secret, expires_at }) => {
2911
- const body: Record<string, unknown> = { hostname, label, username, secret };
2912
- if (expires_at !== undefined) body.expires_at = expires_at;
2913
- const res = await apiPost("/api/server/source-credentials", body);
2914
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2915
- const row = (await res.json()) as SourceCredentialMetadata;
2916
- return {
2917
- content: [
2918
- {
2919
- type: "text",
2920
- text: `Added source credential for ${row.hostname} label="${row.label}" (id=${row.id}, kind=${row.secret.kind}).`,
2921
- },
2922
- ],
2923
- structuredContent: row as unknown as Record<string, unknown>,
2924
- };
2925
- },
2926
- );
2927
-
2928
- server.registerTool(
2929
- "moor_source_credential_update",
2930
- {
2931
- title: "Update Source Credential",
2932
- description:
2933
- "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.",
2934
- inputSchema: z.object({
2935
- id: z.number().int().positive().describe("Credential id to update"),
2936
- username: z.string().optional().describe("New username (optional)"),
2937
- secret: z
2938
- .string()
2939
- .optional()
2940
- .describe("New secret (optional). Visible to the MCP client on input."),
2941
- label: z.string().optional().describe("New label (optional). Trimmed at storage."),
2942
- expires_at: z.string().nullable().optional().describe("New expiry; null to clear"),
2943
- }),
2944
- },
2945
- async ({ id, username, secret, label, expires_at }) => {
2946
- if (
2947
- username === undefined &&
2948
- secret === undefined &&
2949
- label === undefined &&
2950
- expires_at === undefined
2951
- ) {
2952
- throw new Error("must provide at least one of username, secret, label, or expires_at");
2953
- }
2954
- const patch: Record<string, unknown> = {};
2955
- if (username !== undefined) patch.username = username;
2956
- if (secret !== undefined) patch.secret = secret;
2957
- if (label !== undefined) patch.label = label;
2958
- if (expires_at !== undefined) patch.expires_at = expires_at;
2959
- const res = await apiPut(`/api/server/source-credentials/${id}`, patch);
2960
- if (res.status === 404) throw new Error(`source credential id=${id} not found`);
2961
- if (!res.ok) throw new Error(`Failed: ${res.status} ${await res.text()}`);
2962
- const row = (await res.json()) as SourceCredentialMetadata;
2963
- const fields: string[] = [];
2964
- if (username !== undefined) fields.push("username");
2965
- if (secret !== undefined) fields.push("secret");
2966
- if (label !== undefined) fields.push("label");
2967
- if (expires_at !== undefined) fields.push("expires_at");
2968
- return {
2969
- content: [
2970
- {
2971
- type: "text",
2972
- text: `Updated source credential id=${id} (${row.hostname}, ${row.label}): rotated ${fields.join(" + ")}. kind=${row.secret.kind}.`,
2973
- },
2974
- ],
2975
- structuredContent: row as unknown as Record<string, unknown>,
2976
- };
2977
- },
2978
- );
2979
-
2980
- server.registerTool(
2981
- "moor_source_credential_delete",
2982
- {
2983
- title: "Delete Source Credential",
2984
- description:
2985
- "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.",
2986
- inputSchema: z.object({
2987
- id: z.number().int().positive().describe("Credential id to delete"),
2988
- confirm_label: z
2989
- .string()
2990
- .describe(
2991
- "Must equal the credential row's label exactly. Resolved via moor_source_credential_get and compared before deletion.",
2992
- ),
2993
- }),
2994
- },
2995
- async ({ id, confirm_label }) => {
2996
- const getRes = await apiGet(`/api/server/source-credentials/${id}`);
2997
- if (getRes.status === 404) throw new Error(`source credential id=${id} not found`);
2998
- if (!getRes.ok)
2999
- throw new Error(`Failed to fetch credential: ${getRes.status} ${await getRes.text()}`);
3000
- const row = (await getRes.json()) as SourceCredentialMetadata;
3001
- if (confirm_label !== row.label) {
3002
- throw new Error(
3003
- `confirm_label "${confirm_label}" does not match resolved label "${row.label}". Refusing to delete.`,
3004
- );
3005
- }
3006
- const delRes = await apiDelete(
3007
- `/api/server/source-credentials/${id}?confirm_label=${encodeURIComponent(row.label)}`,
3008
- );
3009
- if (delRes.status === 409) {
3010
- const body = (await delRes.json()) as { projects: string[] };
3011
- throw new Error(
3012
- `credential_in_use: ${body.projects.length} project(s) still reference id=${id}: ${body.projects.join(", ")}`,
3013
- );
3014
- }
3015
- if (!delRes.ok) throw new Error(`Failed to delete: ${delRes.status} ${await delRes.text()}`);
3016
- return {
3017
- content: [
3018
- {
3019
- type: "text",
3020
- text: `Deleted source credential ${row.hostname}/${row.label} (id=${id}).`,
3021
- },
3022
- ],
3023
- structuredContent: { deleted: { id, hostname: row.hostname, label: row.label } },
3024
- };
3025
- },
3026
- );
3027
-
3028
- server.registerTool(
3029
- "moor_source_credential_check",
3030
- {
3031
- title: "Check Source Credential Access",
3032
- description:
3033
- "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.",
3034
- inputSchema: z.object({
3035
- github_url: z
3036
- .string()
3037
- .describe(
3038
- "HTTPS Git repo URL: https://host/owner/repo (optional .git). No query, no fragment, no embedded credentials.",
3039
- ),
3040
- branch: z
3041
- .string()
3042
- .optional()
3043
- .describe("Specific branch to verify. Omit to discover the default branch."),
3044
- source_credential_id: z
3045
- .number()
3046
- .int()
3047
- .positive()
3048
- .optional()
3049
- .describe("Pin a specific credential. Required when multiple credentials match the host."),
3050
- }),
3051
- },
3052
- async ({ github_url, branch, source_credential_id }) => {
3053
- const body: Record<string, unknown> = { github_url };
3054
- if (branch !== undefined) body.branch = branch;
3055
- if (source_credential_id !== undefined) body.source_credential_id = source_credential_id;
3056
- const res = await apiPost("/api/server/source-credentials/check", body);
3057
- const json = (await res.json()) as Record<string, unknown>;
3058
- if (res.ok) {
3059
- const def = json.default_branch ? ` default_branch=${json.default_branch}` : "";
3060
- const head = json.head_sha ? ` head_sha=${String(json.head_sha).slice(0, 12)}` : "";
3061
- const auto = json.auto_selected_credential_id
3062
- ? ` auto_selected=${json.auto_selected_credential_id}`
3063
- : "";
3064
- return {
3065
- content: [{ type: "text", text: `reachable${def}${head}${auto}` }],
3066
- structuredContent: json,
3067
- };
3068
- }
3069
- // Non-OK: surface the structured failure shape directly. The agent
3070
- // can branch on `code` to decide what to do next (ask for a PAT,
3071
- // pick from candidates, etc.).
3072
- return {
3073
- content: [
3074
- {
3075
- type: "text",
3076
- text: `check failed: code=${json.code}${json.reason ? ` reason=${json.reason}` : ""}`,
3077
- },
3078
- ],
3079
- structuredContent: json,
3080
- isError: true,
3081
- };
3082
- },
3083
- );
3084
-
3085
- function formatMs(ms: number): string {
3086
- if (ms < 1000) return `${ms}ms`;
3087
- const s = Math.floor(ms / 1000);
3088
- if (s < 60) return `${s}s`;
3089
- const m = Math.floor(s / 60);
3090
- const rs = s % 60;
3091
- if (m < 60) return `${m}m${rs}s`;
3092
- const h = Math.floor(m / 60);
3093
- const rm = m % 60;
3094
- return `${h}h${rm}m${rs}s`;
3095
- }
3096
-
3097
- // --- Start ---
3098
-
3099
- const transport = new StdioServerTransport();
3100
- await server.connect(transport);