@moor-sh/mcp 0.27.0 → 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/README.md +101 -10
- package/bin/moor-mcp.js +2 -0
- package/dist/index.js +2157 -0
- package/package.json +9 -5
- package/src/index.ts +0 -3100
- package/src/tail-utf8.test.ts +0 -69
- package/src/tail-utf8.ts +0 -25
- package/src/update-audit-render.test.ts +0 -171
- package/src/update-audit-render.ts +0 -94
package/dist/index.js
ADDED
|
@@ -0,0 +1,2157 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
|
|
6
|
+
|
|
7
|
+
// ../contract/src/client.ts
|
|
8
|
+
class MoorApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
body;
|
|
11
|
+
constructor(status, message, body = "") {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "MoorApiError";
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.body = body;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function createMoorApiClient(options) {
|
|
19
|
+
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
20
|
+
const apiKey = options.apiKey;
|
|
21
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
22
|
+
async function request(method, path, body, init) {
|
|
23
|
+
const hasBody = body !== undefined;
|
|
24
|
+
const headers = new Headers(init?.headers);
|
|
25
|
+
headers.set("Authorization", `Bearer ${apiKey}`);
|
|
26
|
+
if (!headers.has("Accept"))
|
|
27
|
+
headers.set("Accept", "application/json");
|
|
28
|
+
if (hasBody && !headers.has("Content-Type"))
|
|
29
|
+
headers.set("Content-Type", "application/json");
|
|
30
|
+
const res = await fetchImpl(joinUrl(baseUrl, path), {
|
|
31
|
+
...init,
|
|
32
|
+
method,
|
|
33
|
+
headers,
|
|
34
|
+
body: hasBody ? JSON.stringify(body) : init?.body
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
throw new MoorApiError(res.status, parseErrorMessage(text, res.status), text);
|
|
39
|
+
}
|
|
40
|
+
if (res.status === 204)
|
|
41
|
+
return;
|
|
42
|
+
return await res.json();
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
get: (path, init) => request("GET", path, undefined, init),
|
|
46
|
+
post: (path, body, init) => request("POST", path, body, init),
|
|
47
|
+
put: (path, body, init) => request("PUT", path, body, init),
|
|
48
|
+
delete: (path, init) => request("DELETE", path, undefined, init)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function normalizeBaseUrl(baseUrl) {
|
|
52
|
+
return baseUrl.replace(/\/+$/, "");
|
|
53
|
+
}
|
|
54
|
+
function joinUrl(baseUrl, path) {
|
|
55
|
+
if (path.startsWith("/"))
|
|
56
|
+
return `${baseUrl}${path}`;
|
|
57
|
+
return `${baseUrl}/${path}`;
|
|
58
|
+
}
|
|
59
|
+
function parseErrorMessage(body, status) {
|
|
60
|
+
if (body) {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(body);
|
|
63
|
+
if (isJsonObject(parsed) && "error" in parsed) {
|
|
64
|
+
const error = parsed.error;
|
|
65
|
+
if (typeof error === "string")
|
|
66
|
+
return error;
|
|
67
|
+
return JSON.stringify(error);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
return body;
|
|
71
|
+
}
|
|
72
|
+
return body;
|
|
73
|
+
}
|
|
74
|
+
return `HTTP ${status}`;
|
|
75
|
+
}
|
|
76
|
+
function isJsonObject(value) {
|
|
77
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
// ../contract/src/validators.ts
|
|
80
|
+
function validateGithubUrl(url) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(url);
|
|
84
|
+
} catch {
|
|
85
|
+
throw new Error(`github_url is not a valid URL: ${url}`);
|
|
86
|
+
}
|
|
87
|
+
if (parsed.protocol !== "https:") {
|
|
88
|
+
throw new Error(`github_url must use https (got protocol "${parsed.protocol}")`);
|
|
89
|
+
}
|
|
90
|
+
const host = parsed.hostname;
|
|
91
|
+
if (host !== "github.com" && host !== "www.github.com") {
|
|
92
|
+
throw new Error(`github_url must use github.com or www.github.com (got "${host}")`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function validateGithubRepoUrl(url) {
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = new URL(url);
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error(`github_url is not a valid URL: ${url}`);
|
|
101
|
+
}
|
|
102
|
+
if (parsed.protocol !== "https:") {
|
|
103
|
+
throw new Error(`github_url must use https (got protocol "${parsed.protocol}")`);
|
|
104
|
+
}
|
|
105
|
+
if (parsed.search) {
|
|
106
|
+
throw new Error(`github_url must not contain query parameters (got "${parsed.search}")`);
|
|
107
|
+
}
|
|
108
|
+
if (parsed.hash) {
|
|
109
|
+
throw new Error(`github_url must not contain a URL fragment (got "${parsed.hash}")`);
|
|
110
|
+
}
|
|
111
|
+
const host = parsed.hostname;
|
|
112
|
+
if (host !== "github.com" && host !== "www.github.com") {
|
|
113
|
+
throw new Error(`github_url must use github.com or www.github.com (got "${host}")`);
|
|
114
|
+
}
|
|
115
|
+
if (!/^\/[^/]+\/[^/]+?(\.git)?\/?$/.test(parsed.pathname)) {
|
|
116
|
+
throw new Error(`github_url must point to /owner/repo (with optional .git); got "${parsed.pathname}"`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
var CRON_FIELDS = [
|
|
120
|
+
{ name: "minute", min: 0, max: 59 },
|
|
121
|
+
{ name: "hour", min: 0, max: 23 },
|
|
122
|
+
{ name: "day-of-month", min: 1, max: 31 },
|
|
123
|
+
{ name: "month", min: 1, max: 12 },
|
|
124
|
+
{ name: "day-of-week", min: 0, max: 6 }
|
|
125
|
+
];
|
|
126
|
+
var CRON_PART_PATTERNS = [
|
|
127
|
+
/^\*$/,
|
|
128
|
+
/^(\d+)$/,
|
|
129
|
+
/^(\d+)-(\d+)$/,
|
|
130
|
+
/^\*\/(\d+)$/,
|
|
131
|
+
/^(\d+)-(\d+)\/(\d+)$/
|
|
132
|
+
];
|
|
133
|
+
function validateCronSchedule(schedule) {
|
|
134
|
+
const parts = schedule.trim().split(/\s+/);
|
|
135
|
+
if (parts.length !== 5) {
|
|
136
|
+
return `schedule must have exactly 5 space-separated fields (got ${parts.length})`;
|
|
137
|
+
}
|
|
138
|
+
for (let i = 0;i < 5; i++) {
|
|
139
|
+
const field = CRON_FIELDS[i];
|
|
140
|
+
const err = validateCronField(parts[i], field.min, field.max, field.name);
|
|
141
|
+
if (err)
|
|
142
|
+
return err;
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
function validateCronField(field, min, max, name) {
|
|
147
|
+
if (field === "*")
|
|
148
|
+
return null;
|
|
149
|
+
if (/[?LW#]/i.test(field))
|
|
150
|
+
return `${name}: ?, L, W, # are not supported`;
|
|
151
|
+
if (/[a-zA-Z]/.test(field)) {
|
|
152
|
+
return `${name}: month/day names are not supported, use numeric values`;
|
|
153
|
+
}
|
|
154
|
+
for (const part of field.split(",")) {
|
|
155
|
+
if (part === "")
|
|
156
|
+
return `${name}: empty list element`;
|
|
157
|
+
const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null);
|
|
158
|
+
if (!match)
|
|
159
|
+
return `${name}: invalid expression "${part}"`;
|
|
160
|
+
const groups = match.slice(1);
|
|
161
|
+
if (groups.length === 1 && match[0].startsWith("*/")) {
|
|
162
|
+
const step = Number(groups[0]);
|
|
163
|
+
if (step <= 0)
|
|
164
|
+
return `${name}: step must be a positive integer (got "${groups[0]}")`;
|
|
165
|
+
} else if (groups.length === 1) {
|
|
166
|
+
const n = Number(groups[0]);
|
|
167
|
+
if (n < min || n > max)
|
|
168
|
+
return `${name}: ${n} out of bounds [${min}-${max}]`;
|
|
169
|
+
} else if (groups.length === 2) {
|
|
170
|
+
const a = Number(groups[0]);
|
|
171
|
+
const b = Number(groups[1]);
|
|
172
|
+
if (a < min || b > max)
|
|
173
|
+
return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
|
|
174
|
+
if (a > b)
|
|
175
|
+
return `${name}: range ${a}-${b} is descending`;
|
|
176
|
+
} else if (groups.length === 3) {
|
|
177
|
+
const a = Number(groups[0]);
|
|
178
|
+
const b = Number(groups[1]);
|
|
179
|
+
const step = Number(groups[2]);
|
|
180
|
+
if (a < min || b > max)
|
|
181
|
+
return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
|
|
182
|
+
if (a > b)
|
|
183
|
+
return `${name}: range ${a}-${b} is descending`;
|
|
184
|
+
if (step <= 0)
|
|
185
|
+
return `${name}: step must be a positive integer (got "${groups[2]}")`;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
// src/tools/cleanup.ts
|
|
191
|
+
import { z } from "zod";
|
|
192
|
+
|
|
193
|
+
// src/tail-utf8.ts
|
|
194
|
+
function tailUtf8(s, maxBytes) {
|
|
195
|
+
const bytes = new TextEncoder().encode(s);
|
|
196
|
+
const storedBytes = bytes.length;
|
|
197
|
+
if (storedBytes <= maxBytes)
|
|
198
|
+
return { tail: s, storedBytes, trimmed: false };
|
|
199
|
+
if (maxBytes === 0)
|
|
200
|
+
return { tail: "", storedBytes, trimmed: true };
|
|
201
|
+
let start = storedBytes - maxBytes;
|
|
202
|
+
while (start < storedBytes && (bytes[start] & 192) === 128)
|
|
203
|
+
start++;
|
|
204
|
+
return {
|
|
205
|
+
tail: new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(start)),
|
|
206
|
+
storedBytes,
|
|
207
|
+
trimmed: true
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// src/format.ts
|
|
212
|
+
function formatBytes(bytes) {
|
|
213
|
+
if (!Number.isFinite(bytes) || bytes <= 0)
|
|
214
|
+
return "0 B";
|
|
215
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
216
|
+
const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
|
217
|
+
const val = bytes / 1024 ** i;
|
|
218
|
+
return `${val.toFixed(val < 10 ? 1 : 0)} ${units[i]}`;
|
|
219
|
+
}
|
|
220
|
+
function renderDrainState(s) {
|
|
221
|
+
if (!s.enabled)
|
|
222
|
+
return ["drain: OFF"];
|
|
223
|
+
const lines = [`drain: ON (reason: ${s.reason ?? "(none)"})`];
|
|
224
|
+
if (s.started_at)
|
|
225
|
+
lines.push(` started_at: ${s.started_at}`);
|
|
226
|
+
if (s.expires_at)
|
|
227
|
+
lines.push(` expires_at: ${s.expires_at} (auto-clear)`);
|
|
228
|
+
if (s.clear_after_version) {
|
|
229
|
+
lines.push(` clear_after_version: ${s.clear_after_version} (auto-clear on matching boot version)`);
|
|
230
|
+
}
|
|
231
|
+
return lines;
|
|
232
|
+
}
|
|
233
|
+
function deriveRunStatus(row) {
|
|
234
|
+
if (!row.finished_at)
|
|
235
|
+
return "running";
|
|
236
|
+
return row.exit_code === 0 ? "success" : "failed";
|
|
237
|
+
}
|
|
238
|
+
function deriveRunType(row) {
|
|
239
|
+
if (row.cron_name)
|
|
240
|
+
return `cron(${row.cron_name})`;
|
|
241
|
+
return "build_or_manual";
|
|
242
|
+
}
|
|
243
|
+
function formatMsShort(ms) {
|
|
244
|
+
if (ms == null)
|
|
245
|
+
return "\u2014";
|
|
246
|
+
if (ms < 1000)
|
|
247
|
+
return `${ms}ms`;
|
|
248
|
+
const s = Math.floor(ms / 1000);
|
|
249
|
+
if (s < 60)
|
|
250
|
+
return `${s}s`;
|
|
251
|
+
const m = Math.floor(s / 60);
|
|
252
|
+
return `${m}m${s % 60}s`;
|
|
253
|
+
}
|
|
254
|
+
function appendStream(lines, name, raw, totalBytes, cap) {
|
|
255
|
+
if (!raw && totalBytes === 0)
|
|
256
|
+
return;
|
|
257
|
+
if (!raw) {
|
|
258
|
+
lines.push(`${name}_total_bytes=${totalBytes}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const { tail, storedBytes, trimmed: mcpTrimmed } = tailUtf8(raw, cap);
|
|
262
|
+
const apiTrimmed = totalBytes > storedBytes;
|
|
263
|
+
let header;
|
|
264
|
+
if (mcpTrimmed && apiTrimmed) {
|
|
265
|
+
header = `${name} (showing last ${tail.length} chars of ${storedBytes} stored bytes; ${totalBytes} total bytes seen):`;
|
|
266
|
+
} else if (mcpTrimmed) {
|
|
267
|
+
header = `${name} (showing last ${tail.length} chars of ${storedBytes} total bytes):`;
|
|
268
|
+
} else if (apiTrimmed) {
|
|
269
|
+
header = `${name} (tail of ${storedBytes} stored from ${totalBytes} total bytes seen):`;
|
|
270
|
+
} else {
|
|
271
|
+
header = `${name}:`;
|
|
272
|
+
}
|
|
273
|
+
lines.push(header);
|
|
274
|
+
if (cap > 0)
|
|
275
|
+
lines.push(tail);
|
|
276
|
+
}
|
|
277
|
+
function formatMs(ms) {
|
|
278
|
+
if (ms < 1000)
|
|
279
|
+
return `${ms}ms`;
|
|
280
|
+
const s = Math.floor(ms / 1000);
|
|
281
|
+
if (s < 60)
|
|
282
|
+
return `${s}s`;
|
|
283
|
+
const m = Math.floor(s / 60);
|
|
284
|
+
const rs = s % 60;
|
|
285
|
+
if (m < 60)
|
|
286
|
+
return `${m}m${rs}s`;
|
|
287
|
+
const h = Math.floor(m / 60);
|
|
288
|
+
const rm = m % 60;
|
|
289
|
+
return `${h}h${rm}m${rs}s`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// src/tools/cleanup.ts
|
|
293
|
+
function registerCleanupTools(server, client2) {
|
|
294
|
+
const { apiResponse, readErrorMessage } = client2;
|
|
295
|
+
server.registerTool("moor_cleanup_plan", {
|
|
296
|
+
title: "Cleanup Plan (dry-run)",
|
|
297
|
+
description: "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 \u2014 execute re-validates eligibility against current Docker state.",
|
|
298
|
+
inputSchema: z.object({
|
|
299
|
+
scope: z.array(z.enum(["build_cache", "dangling_image"])).optional().describe("Subset of categories to plan. Defaults to all v1 categories.")
|
|
300
|
+
})
|
|
301
|
+
}, async ({ scope }) => {
|
|
302
|
+
const res = await apiResponse.post("/api/server/cleanup/plan", { scope });
|
|
303
|
+
if (!res.ok)
|
|
304
|
+
throw new Error(`plan failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
305
|
+
const data = await res.json();
|
|
306
|
+
if (data.candidates.length === 0) {
|
|
307
|
+
return { content: [{ type: "text", text: "Nothing to clean up." }] };
|
|
308
|
+
}
|
|
309
|
+
const lines = [
|
|
310
|
+
`${data.candidates.length} candidate(s), total reclaimable: ${formatBytes(data.total_reclaimable_bytes)}.`,
|
|
311
|
+
"Pass the candidates_json block below back to moor_cleanup_execute to delete.",
|
|
312
|
+
""
|
|
313
|
+
];
|
|
314
|
+
for (const c of data.candidates) {
|
|
315
|
+
if (c.category === "build_cache") {
|
|
316
|
+
lines.push(`build_cache [${c.label}] \u2014 ${formatBytes(c.reclaimable_bytes)} reclaimable (host-wide prune)`);
|
|
317
|
+
} else {
|
|
318
|
+
const tags = c.repo_tags.length > 0 ? ` tags=${c.repo_tags.join(",")}` : "";
|
|
319
|
+
lines.push(`dangling_image [${c.label}] id=${c.id} ${formatBytes(c.reclaimable_bytes)}${tags}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
lines.push("", "candidates_json:", JSON.stringify(data.candidates, null, 2));
|
|
323
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
324
|
+
`) }] };
|
|
325
|
+
});
|
|
326
|
+
server.registerTool("moor_cleanup_execute", {
|
|
327
|
+
title: "Cleanup Execute",
|
|
328
|
+
description: "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 \u2014 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.",
|
|
329
|
+
inputSchema: z.object({
|
|
330
|
+
candidates: z.array(z.union([
|
|
331
|
+
z.object({ category: z.literal("build_cache") }).passthrough(),
|
|
332
|
+
z.object({ category: z.literal("dangling_image"), id: z.string().min(1) }).passthrough()
|
|
333
|
+
])).min(1).describe("Candidates from moor_cleanup_plan. Extra fields are ignored server-side.")
|
|
334
|
+
})
|
|
335
|
+
}, async ({ candidates }) => {
|
|
336
|
+
const res = await apiResponse.post("/api/server/cleanup/execute", { candidates });
|
|
337
|
+
if (!res.ok)
|
|
338
|
+
throw new Error(`execute failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
339
|
+
const data = await res.json();
|
|
340
|
+
const lines = [
|
|
341
|
+
`audit_id=${data.audit_id} total_reclaimed=${formatBytes(data.total_reclaimed_bytes)}`,
|
|
342
|
+
""
|
|
343
|
+
];
|
|
344
|
+
for (const r of data.results) {
|
|
345
|
+
const status = r.error ? `ERROR: ${r.error}` : "ok";
|
|
346
|
+
if (r.category === "build_cache") {
|
|
347
|
+
lines.push(`build_cache: reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`);
|
|
348
|
+
} else {
|
|
349
|
+
lines.push(`dangling_image id=${r.id} reclaimed=${formatBytes(r.reclaimed_bytes)} ${status}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
353
|
+
`) }] };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/tools/credentials.ts
|
|
358
|
+
import { z as z2 } from "zod";
|
|
359
|
+
function registerCredentialTools(server, client2) {
|
|
360
|
+
const { apiResponse, readErrorMessage } = client2;
|
|
361
|
+
server.registerTool("moor_dns_check", {
|
|
362
|
+
title: "Check Domain DNS",
|
|
363
|
+
description: "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.",
|
|
364
|
+
inputSchema: z2.object({
|
|
365
|
+
domain: z2.string().min(1).describe("Domain to check, e.g. app.example.com")
|
|
366
|
+
})
|
|
367
|
+
}, async ({ domain }) => {
|
|
368
|
+
const res = await apiResponse.post("/api/dns-check", { domain });
|
|
369
|
+
if (!res.ok)
|
|
370
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
371
|
+
const data = await res.json();
|
|
372
|
+
const lines = [
|
|
373
|
+
`Domain: ${domain}`,
|
|
374
|
+
`Resolves: ${data.resolves ? "yes" : "no"}`,
|
|
375
|
+
`Resolved IP: ${data.ip ?? "(none)"}`,
|
|
376
|
+
`Server IP: ${data.serverIp ?? "(unknown)"}`
|
|
377
|
+
];
|
|
378
|
+
if (data.ip && data.serverIp) {
|
|
379
|
+
lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`);
|
|
380
|
+
}
|
|
381
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
382
|
+
`) }] };
|
|
383
|
+
});
|
|
384
|
+
function renderCredentialLine(c) {
|
|
385
|
+
return `id=${c.id} ${c.hostname} user=${c.username} kind=${c.secret.kind} updated=${c.updated_at}`;
|
|
386
|
+
}
|
|
387
|
+
server.registerTool("moor_registry_credentials_list", {
|
|
388
|
+
title: "List Registry Credentials",
|
|
389
|
+
description: "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'."
|
|
390
|
+
}, async () => {
|
|
391
|
+
const res = await apiResponse.get("/api/server/registry-credentials");
|
|
392
|
+
if (!res.ok)
|
|
393
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
394
|
+
const data = await res.json();
|
|
395
|
+
const text = data.rows.length === 0 ? "No registry credentials configured. The pull path falls back to anonymous for every registry." : data.rows.map(renderCredentialLine).join(`
|
|
396
|
+
`);
|
|
397
|
+
return {
|
|
398
|
+
content: [{ type: "text", text }],
|
|
399
|
+
structuredContent: { rows: data.rows }
|
|
400
|
+
};
|
|
401
|
+
});
|
|
402
|
+
server.registerTool("moor_registry_credential_get", {
|
|
403
|
+
title: "Get Registry Credential",
|
|
404
|
+
description: "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.",
|
|
405
|
+
inputSchema: z2.object({
|
|
406
|
+
id: z2.number().int().positive().describe("Credential id (from moor_registry_credentials_list)")
|
|
407
|
+
})
|
|
408
|
+
}, async ({ id }) => {
|
|
409
|
+
const res = await apiResponse.get(`/api/server/registry-credentials/${id}`);
|
|
410
|
+
if (res.status === 404)
|
|
411
|
+
throw new Error(`credential id=${id} not found`);
|
|
412
|
+
if (!res.ok)
|
|
413
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
414
|
+
const row = await res.json();
|
|
415
|
+
return {
|
|
416
|
+
content: [{ type: "text", text: renderCredentialLine(row) }],
|
|
417
|
+
structuredContent: row
|
|
418
|
+
};
|
|
419
|
+
});
|
|
420
|
+
server.registerTool("moor_registry_credential_add", {
|
|
421
|
+
title: "Add Registry Credential",
|
|
422
|
+
description: "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.",
|
|
423
|
+
inputSchema: z2.object({
|
|
424
|
+
hostname: z2.string().describe("Bare registry host as parsed from an image ref. Examples: ghcr.io, docker.io, localhost:5000, registry.example.com:5000. No scheme, no path."),
|
|
425
|
+
username: z2.string().describe("Registry username. For GHCR with a classic PAT, use your GitHub username."),
|
|
426
|
+
secret: z2.string().describe("Registry password or token. For GHCR, a classic PAT with read:packages is the documented path. Visible to the MCP client on input.")
|
|
427
|
+
})
|
|
428
|
+
}, async ({ hostname, username, secret }) => {
|
|
429
|
+
const res = await apiResponse.post("/api/server/registry-credentials", {
|
|
430
|
+
hostname,
|
|
431
|
+
username,
|
|
432
|
+
secret
|
|
433
|
+
});
|
|
434
|
+
if (!res.ok)
|
|
435
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
436
|
+
const row = await res.json();
|
|
437
|
+
return {
|
|
438
|
+
content: [
|
|
439
|
+
{
|
|
440
|
+
type: "text",
|
|
441
|
+
text: `Added credential for ${row.hostname} (id=${row.id}, kind=${row.secret.kind}).`
|
|
442
|
+
}
|
|
443
|
+
],
|
|
444
|
+
structuredContent: row
|
|
445
|
+
};
|
|
446
|
+
});
|
|
447
|
+
server.registerTool("moor_registry_credential_update", {
|
|
448
|
+
title: "Update Registry Credential",
|
|
449
|
+
description: "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.",
|
|
450
|
+
inputSchema: z2.object({
|
|
451
|
+
id: z2.number().int().positive().describe("Credential id to update"),
|
|
452
|
+
username: z2.string().optional().describe("New username (optional)"),
|
|
453
|
+
secret: z2.string().optional().describe("New secret (optional). Visible to the MCP client on input.")
|
|
454
|
+
})
|
|
455
|
+
}, async ({ id, username, secret }) => {
|
|
456
|
+
if (username === undefined && secret === undefined) {
|
|
457
|
+
throw new Error("must provide at least one of username or secret to update");
|
|
458
|
+
}
|
|
459
|
+
const patch = {};
|
|
460
|
+
if (username !== undefined)
|
|
461
|
+
patch.username = username;
|
|
462
|
+
if (secret !== undefined)
|
|
463
|
+
patch.secret = secret;
|
|
464
|
+
const res = await apiResponse.put(`/api/server/registry-credentials/${id}`, patch);
|
|
465
|
+
if (res.status === 404)
|
|
466
|
+
throw new Error(`credential id=${id} not found`);
|
|
467
|
+
if (!res.ok)
|
|
468
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
469
|
+
const row = await res.json();
|
|
470
|
+
const rotated = [];
|
|
471
|
+
if (username !== undefined)
|
|
472
|
+
rotated.push("username");
|
|
473
|
+
if (secret !== undefined)
|
|
474
|
+
rotated.push("secret");
|
|
475
|
+
return {
|
|
476
|
+
content: [
|
|
477
|
+
{
|
|
478
|
+
type: "text",
|
|
479
|
+
text: `Updated credential id=${id} (${row.hostname}): rotated ${rotated.join(" + ")}. kind=${row.secret.kind}.`
|
|
480
|
+
}
|
|
481
|
+
],
|
|
482
|
+
structuredContent: row
|
|
483
|
+
};
|
|
484
|
+
});
|
|
485
|
+
server.registerTool("moor_registry_credential_delete", {
|
|
486
|
+
title: "Delete Registry Credential",
|
|
487
|
+
description: "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.",
|
|
488
|
+
inputSchema: z2.object({
|
|
489
|
+
id: z2.number().int().positive().describe("Credential id to delete"),
|
|
490
|
+
confirm_hostname: z2.string().describe("Must equal the credential row's hostname exactly. Resolved via moor_registry_credential_get and compared before deletion.")
|
|
491
|
+
})
|
|
492
|
+
}, async ({ id, confirm_hostname }) => {
|
|
493
|
+
const getRes = await apiResponse.get(`/api/server/registry-credentials/${id}`);
|
|
494
|
+
if (getRes.status === 404)
|
|
495
|
+
throw new Error(`credential id=${id} not found`);
|
|
496
|
+
if (!getRes.ok)
|
|
497
|
+
throw new Error(`Failed to fetch credential: ${getRes.status} ${await readErrorMessage(getRes)}`);
|
|
498
|
+
const row = await getRes.json();
|
|
499
|
+
if (confirm_hostname !== row.hostname) {
|
|
500
|
+
throw new Error(`confirm_hostname "${confirm_hostname}" does not match resolved hostname "${row.hostname}". Refusing to delete.`);
|
|
501
|
+
}
|
|
502
|
+
const delRes = await apiResponse.delete(`/api/server/registry-credentials/${id}`);
|
|
503
|
+
if (!delRes.ok)
|
|
504
|
+
throw new Error(`Failed to delete: ${delRes.status} ${await readErrorMessage(delRes)}`);
|
|
505
|
+
return {
|
|
506
|
+
content: [{ type: "text", text: `Deleted credential for ${row.hostname} (id=${id}).` }],
|
|
507
|
+
structuredContent: { deleted: { id, hostname: row.hostname } }
|
|
508
|
+
};
|
|
509
|
+
});
|
|
510
|
+
function renderSourceCredentialLine(c) {
|
|
511
|
+
const checked = c.last_check_status ? ` last_check=${c.last_check_status}` : "";
|
|
512
|
+
return `id=${c.id} ${c.hostname} label=${c.label} user=${c.username} kind=${c.secret.kind} state=${c.state}${checked}`;
|
|
513
|
+
}
|
|
514
|
+
server.registerTool("moor_source_credentials_list", {
|
|
515
|
+
title: "List Source Credentials",
|
|
516
|
+
description: "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."
|
|
517
|
+
}, async () => {
|
|
518
|
+
const res = await apiResponse.get("/api/server/source-credentials");
|
|
519
|
+
if (!res.ok)
|
|
520
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
521
|
+
const data = await res.json();
|
|
522
|
+
const text = data.rows.length === 0 ? "No source credentials configured. Public repos work anonymously." : data.rows.map(renderSourceCredentialLine).join(`
|
|
523
|
+
`);
|
|
524
|
+
return {
|
|
525
|
+
content: [{ type: "text", text }],
|
|
526
|
+
structuredContent: { rows: data.rows }
|
|
527
|
+
};
|
|
528
|
+
});
|
|
529
|
+
server.registerTool("moor_source_credential_get", {
|
|
530
|
+
title: "Get Source Credential",
|
|
531
|
+
description: "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.",
|
|
532
|
+
inputSchema: z2.object({
|
|
533
|
+
id: z2.number().int().positive().describe("Credential id (from moor_source_credentials_list)")
|
|
534
|
+
})
|
|
535
|
+
}, async ({ id }) => {
|
|
536
|
+
const res = await apiResponse.get(`/api/server/source-credentials/${id}`);
|
|
537
|
+
if (res.status === 404)
|
|
538
|
+
throw new Error(`source credential id=${id} not found`);
|
|
539
|
+
if (!res.ok)
|
|
540
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
541
|
+
const row = await res.json();
|
|
542
|
+
return {
|
|
543
|
+
content: [{ type: "text", text: renderSourceCredentialLine(row) }],
|
|
544
|
+
structuredContent: row
|
|
545
|
+
};
|
|
546
|
+
});
|
|
547
|
+
server.registerTool("moor_source_credential_add", {
|
|
548
|
+
title: "Add Source Credential",
|
|
549
|
+
description: "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.",
|
|
550
|
+
inputSchema: z2.object({
|
|
551
|
+
hostname: z2.string().describe("Bare Git host: github.com, gitlab.com, etc. No scheme, no path."),
|
|
552
|
+
label: z2.string().describe("Operator-supplied label for disambiguation when multiple credentials share a host (e.g. 'personal', 'work-org', 'acme-clients'). Trimmed at storage."),
|
|
553
|
+
username: z2.string().describe("Git username. For GitHub fine-grained PATs, use 'x-access-token'. For classic PATs, your GitHub username works too."),
|
|
554
|
+
secret: z2.string().describe("Git token (PAT). Visible to the MCP client on input; rotate via moor_source_credential_update if exposed."),
|
|
555
|
+
expires_at: z2.string().nullable().optional().describe("Operator-supplied expiry timestamp (PAT expiry from GitHub). Optional; helps rotation reminders.")
|
|
556
|
+
})
|
|
557
|
+
}, async ({ hostname, label, username, secret, expires_at }) => {
|
|
558
|
+
const body = { hostname, label, username, secret };
|
|
559
|
+
if (expires_at !== undefined)
|
|
560
|
+
body.expires_at = expires_at;
|
|
561
|
+
const res = await apiResponse.post("/api/server/source-credentials", body);
|
|
562
|
+
if (!res.ok)
|
|
563
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
564
|
+
const row = await res.json();
|
|
565
|
+
return {
|
|
566
|
+
content: [
|
|
567
|
+
{
|
|
568
|
+
type: "text",
|
|
569
|
+
text: `Added source credential for ${row.hostname} label="${row.label}" (id=${row.id}, kind=${row.secret.kind}).`
|
|
570
|
+
}
|
|
571
|
+
],
|
|
572
|
+
structuredContent: row
|
|
573
|
+
};
|
|
574
|
+
});
|
|
575
|
+
server.registerTool("moor_source_credential_update", {
|
|
576
|
+
title: "Update Source Credential",
|
|
577
|
+
description: "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.",
|
|
578
|
+
inputSchema: z2.object({
|
|
579
|
+
id: z2.number().int().positive().describe("Credential id to update"),
|
|
580
|
+
username: z2.string().optional().describe("New username (optional)"),
|
|
581
|
+
secret: z2.string().optional().describe("New secret (optional). Visible to the MCP client on input."),
|
|
582
|
+
label: z2.string().optional().describe("New label (optional). Trimmed at storage."),
|
|
583
|
+
expires_at: z2.string().nullable().optional().describe("New expiry; null to clear")
|
|
584
|
+
})
|
|
585
|
+
}, async ({ id, username, secret, label, expires_at }) => {
|
|
586
|
+
if (username === undefined && secret === undefined && label === undefined && expires_at === undefined) {
|
|
587
|
+
throw new Error("must provide at least one of username, secret, label, or expires_at");
|
|
588
|
+
}
|
|
589
|
+
const patch = {};
|
|
590
|
+
if (username !== undefined)
|
|
591
|
+
patch.username = username;
|
|
592
|
+
if (secret !== undefined)
|
|
593
|
+
patch.secret = secret;
|
|
594
|
+
if (label !== undefined)
|
|
595
|
+
patch.label = label;
|
|
596
|
+
if (expires_at !== undefined)
|
|
597
|
+
patch.expires_at = expires_at;
|
|
598
|
+
const res = await apiResponse.put(`/api/server/source-credentials/${id}`, patch);
|
|
599
|
+
if (res.status === 404)
|
|
600
|
+
throw new Error(`source credential id=${id} not found`);
|
|
601
|
+
if (!res.ok)
|
|
602
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
603
|
+
const row = await res.json();
|
|
604
|
+
const fields = [];
|
|
605
|
+
if (username !== undefined)
|
|
606
|
+
fields.push("username");
|
|
607
|
+
if (secret !== undefined)
|
|
608
|
+
fields.push("secret");
|
|
609
|
+
if (label !== undefined)
|
|
610
|
+
fields.push("label");
|
|
611
|
+
if (expires_at !== undefined)
|
|
612
|
+
fields.push("expires_at");
|
|
613
|
+
return {
|
|
614
|
+
content: [
|
|
615
|
+
{
|
|
616
|
+
type: "text",
|
|
617
|
+
text: `Updated source credential id=${id} (${row.hostname}, ${row.label}): rotated ${fields.join(" + ")}. kind=${row.secret.kind}.`
|
|
618
|
+
}
|
|
619
|
+
],
|
|
620
|
+
structuredContent: row
|
|
621
|
+
};
|
|
622
|
+
});
|
|
623
|
+
server.registerTool("moor_source_credential_delete", {
|
|
624
|
+
title: "Delete Source Credential",
|
|
625
|
+
description: "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.",
|
|
626
|
+
inputSchema: z2.object({
|
|
627
|
+
id: z2.number().int().positive().describe("Credential id to delete"),
|
|
628
|
+
confirm_label: z2.string().describe("Must equal the credential row's label exactly. Resolved via moor_source_credential_get and compared before deletion.")
|
|
629
|
+
})
|
|
630
|
+
}, async ({ id, confirm_label }) => {
|
|
631
|
+
const getRes = await apiResponse.get(`/api/server/source-credentials/${id}`);
|
|
632
|
+
if (getRes.status === 404)
|
|
633
|
+
throw new Error(`source credential id=${id} not found`);
|
|
634
|
+
if (!getRes.ok)
|
|
635
|
+
throw new Error(`Failed to fetch credential: ${getRes.status} ${await readErrorMessage(getRes)}`);
|
|
636
|
+
const row = await getRes.json();
|
|
637
|
+
if (confirm_label !== row.label) {
|
|
638
|
+
throw new Error(`confirm_label "${confirm_label}" does not match resolved label "${row.label}". Refusing to delete.`);
|
|
639
|
+
}
|
|
640
|
+
const delRes = await apiResponse.delete(`/api/server/source-credentials/${id}?confirm_label=${encodeURIComponent(row.label)}`);
|
|
641
|
+
if (delRes.status === 409) {
|
|
642
|
+
const body = await delRes.json();
|
|
643
|
+
throw new Error(`credential_in_use: ${body.projects.length} project(s) still reference id=${id}: ${body.projects.join(", ")}`);
|
|
644
|
+
}
|
|
645
|
+
if (!delRes.ok)
|
|
646
|
+
throw new Error(`Failed to delete: ${delRes.status} ${await readErrorMessage(delRes)}`);
|
|
647
|
+
return {
|
|
648
|
+
content: [
|
|
649
|
+
{
|
|
650
|
+
type: "text",
|
|
651
|
+
text: `Deleted source credential ${row.hostname}/${row.label} (id=${id}).`
|
|
652
|
+
}
|
|
653
|
+
],
|
|
654
|
+
structuredContent: { deleted: { id, hostname: row.hostname, label: row.label } }
|
|
655
|
+
};
|
|
656
|
+
});
|
|
657
|
+
server.registerTool("moor_source_credential_check", {
|
|
658
|
+
title: "Check Source Credential Access",
|
|
659
|
+
description: "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.",
|
|
660
|
+
inputSchema: z2.object({
|
|
661
|
+
github_url: z2.string().describe("HTTPS Git repo URL: https://host/owner/repo (optional .git). No query, no fragment, no embedded credentials."),
|
|
662
|
+
branch: z2.string().optional().describe("Specific branch to verify. Omit to discover the default branch."),
|
|
663
|
+
source_credential_id: z2.number().int().positive().optional().describe("Pin a specific credential. Required when multiple credentials match the host.")
|
|
664
|
+
})
|
|
665
|
+
}, async ({ github_url, branch, source_credential_id }) => {
|
|
666
|
+
const body = { github_url };
|
|
667
|
+
if (branch !== undefined)
|
|
668
|
+
body.branch = branch;
|
|
669
|
+
if (source_credential_id !== undefined)
|
|
670
|
+
body.source_credential_id = source_credential_id;
|
|
671
|
+
const res = await apiResponse.post("/api/server/source-credentials/check", body);
|
|
672
|
+
const json = await res.json();
|
|
673
|
+
if (res.ok) {
|
|
674
|
+
const def = json.default_branch ? ` default_branch=${json.default_branch}` : "";
|
|
675
|
+
const head = json.head_sha ? ` head_sha=${String(json.head_sha).slice(0, 12)}` : "";
|
|
676
|
+
const auto = json.auto_selected_credential_id ? ` auto_selected=${json.auto_selected_credential_id}` : "";
|
|
677
|
+
return {
|
|
678
|
+
content: [{ type: "text", text: `reachable${def}${head}${auto}` }],
|
|
679
|
+
structuredContent: json
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
return {
|
|
683
|
+
content: [
|
|
684
|
+
{
|
|
685
|
+
type: "text",
|
|
686
|
+
text: `check failed: code=${json.code}${json.reason ? ` reason=${json.reason}` : ""}`
|
|
687
|
+
}
|
|
688
|
+
],
|
|
689
|
+
structuredContent: json,
|
|
690
|
+
isError: true
|
|
691
|
+
};
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// src/tools/env.ts
|
|
696
|
+
import { z as z3 } from "zod";
|
|
697
|
+
function registerEnvTools(server, client2) {
|
|
698
|
+
const { apiResponse, resolveProject, readErrorMessage } = client2;
|
|
699
|
+
server.registerTool("moor_env_list", {
|
|
700
|
+
title: "List Environment Variables",
|
|
701
|
+
description: "List all environment variables set for a project.",
|
|
702
|
+
inputSchema: z3.object({
|
|
703
|
+
project: z3.string().describe("Project name or ID")
|
|
704
|
+
})
|
|
705
|
+
}, async ({ project }) => {
|
|
706
|
+
const p = await resolveProject(project);
|
|
707
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/envs`);
|
|
708
|
+
if (!res.ok)
|
|
709
|
+
throw new Error(`Failed: ${res.status}`);
|
|
710
|
+
const vars = await res.json();
|
|
711
|
+
if (vars.length === 0)
|
|
712
|
+
return { content: [{ type: "text", text: "No environment variables set." }] };
|
|
713
|
+
const text = vars.map((v) => `${v.key}=${v.value}`).join(`
|
|
714
|
+
`);
|
|
715
|
+
return { content: [{ type: "text", text }] };
|
|
716
|
+
});
|
|
717
|
+
server.registerTool("moor_env_set", {
|
|
718
|
+
title: "Set Environment Variables",
|
|
719
|
+
description: "Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running.",
|
|
720
|
+
inputSchema: z3.object({
|
|
721
|
+
project: z3.string().describe("Project name or ID"),
|
|
722
|
+
vars: z3.record(z3.string(), z3.string()).describe('Key-value pairs to set, e.g. { "DATABASE_URL": "postgres://..." }')
|
|
723
|
+
})
|
|
724
|
+
}, async ({ project, vars }) => {
|
|
725
|
+
const p = await resolveProject(project);
|
|
726
|
+
const existingRes = await apiResponse.get(`/api/projects/${p.id}/envs`);
|
|
727
|
+
if (!existingRes.ok)
|
|
728
|
+
throw new Error(`Failed to get envs: ${existingRes.status}`);
|
|
729
|
+
const existing = await existingRes.json();
|
|
730
|
+
const merged = new Map(existing.map((v) => [v.key, v.value]));
|
|
731
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
732
|
+
merged.set(key, value);
|
|
733
|
+
}
|
|
734
|
+
const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
|
|
735
|
+
const setRes = await apiResponse.put(`/api/projects/${p.id}/envs`, allVars);
|
|
736
|
+
if (!setRes.ok)
|
|
737
|
+
throw new Error(`Failed to set envs: ${await readErrorMessage(setRes)}`);
|
|
738
|
+
const keys = Object.keys(vars).join(", ");
|
|
739
|
+
let text = `Set ${keys} on ${p.name}.`;
|
|
740
|
+
if (p.status === "running") {
|
|
741
|
+
await apiResponse.post(`/api/projects/${p.id}/stop`);
|
|
742
|
+
const startRes = await apiResponse.post(`/api/projects/${p.id}/start`);
|
|
743
|
+
if (!startRes.ok)
|
|
744
|
+
throw new Error(`Set vars but failed to restart: ${await readErrorMessage(startRes)}`);
|
|
745
|
+
text += " Container restarted.";
|
|
746
|
+
}
|
|
747
|
+
return { content: [{ type: "text", text }] };
|
|
748
|
+
});
|
|
749
|
+
server.registerTool("moor_cron_create", {
|
|
750
|
+
title: "Create Cron",
|
|
751
|
+
description: "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.",
|
|
752
|
+
inputSchema: z3.object({
|
|
753
|
+
project: z3.string().describe("Project name or ID"),
|
|
754
|
+
name: z3.string().min(1).describe("Human-readable name for the cron"),
|
|
755
|
+
schedule: z3.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'),
|
|
756
|
+
command: z3.string().min(1).describe("Shell command to run inside the project's container")
|
|
757
|
+
})
|
|
758
|
+
}, async ({ project, name, schedule, command }) => {
|
|
759
|
+
const err = validateCronSchedule(schedule);
|
|
760
|
+
if (err)
|
|
761
|
+
throw new Error(`Invalid schedule: ${err}`);
|
|
762
|
+
const p = await resolveProject(project);
|
|
763
|
+
const res = await apiResponse.post(`/api/projects/${p.id}/crons`, {
|
|
764
|
+
name,
|
|
765
|
+
schedule,
|
|
766
|
+
command
|
|
767
|
+
});
|
|
768
|
+
if (!res.ok)
|
|
769
|
+
throw new Error(`Failed to create cron: ${await readErrorMessage(res)}`);
|
|
770
|
+
const cron = await res.json();
|
|
771
|
+
return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
|
|
772
|
+
});
|
|
773
|
+
server.registerTool("moor_cron_update", {
|
|
774
|
+
title: "Update Cron",
|
|
775
|
+
description: "Updates a cron's fields by id. Schedule is validated if provided.",
|
|
776
|
+
inputSchema: z3.object({
|
|
777
|
+
cron_id: z3.number().int().positive().describe("Cron ID"),
|
|
778
|
+
name: z3.string().min(1).optional(),
|
|
779
|
+
schedule: z3.string().optional(),
|
|
780
|
+
command: z3.string().min(1).optional(),
|
|
781
|
+
enabled: z3.boolean().optional().describe("Enable or disable the cron")
|
|
782
|
+
})
|
|
783
|
+
}, async ({ cron_id, name, schedule, command, enabled }) => {
|
|
784
|
+
if (schedule !== undefined) {
|
|
785
|
+
const err = validateCronSchedule(schedule);
|
|
786
|
+
if (err)
|
|
787
|
+
throw new Error(`Invalid schedule: ${err}`);
|
|
788
|
+
}
|
|
789
|
+
const body = {};
|
|
790
|
+
if (name !== undefined)
|
|
791
|
+
body.name = name;
|
|
792
|
+
if (schedule !== undefined)
|
|
793
|
+
body.schedule = schedule;
|
|
794
|
+
if (command !== undefined)
|
|
795
|
+
body.command = command;
|
|
796
|
+
if (enabled !== undefined)
|
|
797
|
+
body.enabled = enabled ? 1 : 0;
|
|
798
|
+
if (Object.keys(body).length === 0) {
|
|
799
|
+
throw new Error("Provide at least one field to update");
|
|
800
|
+
}
|
|
801
|
+
const res = await apiResponse.put(`/api/crons/${cron_id}`, body);
|
|
802
|
+
if (!res.ok)
|
|
803
|
+
throw new Error(`Failed to update cron: ${await readErrorMessage(res)}`);
|
|
804
|
+
const cron = await res.json();
|
|
805
|
+
return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
|
|
806
|
+
});
|
|
807
|
+
server.registerTool("moor_cron_delete", {
|
|
808
|
+
title: "Delete Cron",
|
|
809
|
+
description: "Deletes a cron by id.",
|
|
810
|
+
inputSchema: z3.object({
|
|
811
|
+
cron_id: z3.number().int().positive().describe("Cron ID")
|
|
812
|
+
})
|
|
813
|
+
}, async ({ cron_id }) => {
|
|
814
|
+
const res = await apiResponse.delete(`/api/crons/${cron_id}`);
|
|
815
|
+
if (!res.ok)
|
|
816
|
+
throw new Error(`Failed to delete cron: ${await readErrorMessage(res)}`);
|
|
817
|
+
return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] };
|
|
818
|
+
});
|
|
819
|
+
server.registerTool("moor_cron_run", {
|
|
820
|
+
title: "Run Cron Now",
|
|
821
|
+
description: "Triggers a cron to run immediately. Requires the project's container to be running.",
|
|
822
|
+
inputSchema: z3.object({
|
|
823
|
+
cron_id: z3.number().int().positive().describe("Cron ID")
|
|
824
|
+
})
|
|
825
|
+
}, async ({ cron_id }) => {
|
|
826
|
+
const res = await apiResponse.post(`/api/crons/${cron_id}/run`);
|
|
827
|
+
if (!res.ok) {
|
|
828
|
+
const text = await readErrorMessage(res);
|
|
829
|
+
let message = text;
|
|
830
|
+
try {
|
|
831
|
+
const parsed = JSON.parse(text);
|
|
832
|
+
if (isJsonObject(parsed) && typeof parsed.error === "string")
|
|
833
|
+
message = parsed.error;
|
|
834
|
+
} catch {}
|
|
835
|
+
throw new Error(message);
|
|
836
|
+
}
|
|
837
|
+
return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] };
|
|
838
|
+
});
|
|
839
|
+
server.registerTool("moor_env_delete", {
|
|
840
|
+
title: "Delete Environment Variables",
|
|
841
|
+
description: "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.",
|
|
842
|
+
inputSchema: z3.object({
|
|
843
|
+
project: z3.string().describe("Project name or ID"),
|
|
844
|
+
keys: z3.array(z3.string().min(1)).min(1).describe("Env var keys to remove")
|
|
845
|
+
})
|
|
846
|
+
}, async ({ project, keys }) => {
|
|
847
|
+
const p = await resolveProject(project);
|
|
848
|
+
const existingRes = await apiResponse.get(`/api/projects/${p.id}/envs`);
|
|
849
|
+
if (!existingRes.ok)
|
|
850
|
+
throw new Error(`Failed to get envs: ${existingRes.status}`);
|
|
851
|
+
const existing = await existingRes.json();
|
|
852
|
+
const existingKeys = new Set(existing.map((v) => v.key));
|
|
853
|
+
const toDelete = keys.filter((k) => existingKeys.has(k));
|
|
854
|
+
const missing = keys.filter((k) => !existingKeys.has(k));
|
|
855
|
+
if (toDelete.length === 0) {
|
|
856
|
+
const existingList = [...existingKeys].sort().join(", ") || "(none)";
|
|
857
|
+
return {
|
|
858
|
+
content: [
|
|
859
|
+
{
|
|
860
|
+
type: "text",
|
|
861
|
+
text: `No matching keys on ${p.name}. Existing keys: ${existingList}`
|
|
862
|
+
}
|
|
863
|
+
]
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
for (const key of toDelete) {
|
|
867
|
+
const res = await apiResponse.delete(`/api/projects/${p.id}/envs/${encodeURIComponent(key)}`);
|
|
868
|
+
if (!res.ok)
|
|
869
|
+
throw new Error(`Failed to delete ${key}: ${await readErrorMessage(res)}`);
|
|
870
|
+
}
|
|
871
|
+
let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`;
|
|
872
|
+
if (missing.length > 0)
|
|
873
|
+
text += ` (Not present: ${missing.join(", ")}.)`;
|
|
874
|
+
if (p.status === "running") {
|
|
875
|
+
await apiResponse.post(`/api/projects/${p.id}/stop`);
|
|
876
|
+
const startRes = await apiResponse.post(`/api/projects/${p.id}/start`);
|
|
877
|
+
if (!startRes.ok) {
|
|
878
|
+
throw new Error(`Deleted vars but failed to restart: ${await readErrorMessage(startRes)}`);
|
|
879
|
+
}
|
|
880
|
+
text += " Container restarted.";
|
|
881
|
+
}
|
|
882
|
+
return { content: [{ type: "text", text }] };
|
|
883
|
+
});
|
|
884
|
+
server.registerTool("moor_volume_list", {
|
|
885
|
+
title: "List Project Volumes",
|
|
886
|
+
description: "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).",
|
|
887
|
+
inputSchema: z3.object({
|
|
888
|
+
project: z3.string().describe("Project name or ID")
|
|
889
|
+
})
|
|
890
|
+
}, async ({ project }) => {
|
|
891
|
+
const p = await resolveProject(project);
|
|
892
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/volumes`);
|
|
893
|
+
if (!res.ok)
|
|
894
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
895
|
+
const rows = await res.json();
|
|
896
|
+
if (rows.length === 0) {
|
|
897
|
+
return { content: [{ type: "text", text: `No volumes attached to ${p.name}.` }] };
|
|
898
|
+
}
|
|
899
|
+
const lines = rows.map((v) => `id=${v.id} name=${v.name} target=${v.target} docker_name=${v.docker_name}`);
|
|
900
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
901
|
+
`) }] };
|
|
902
|
+
});
|
|
903
|
+
server.registerTool("moor_volume_add", {
|
|
904
|
+
title: "Add Project Volume",
|
|
905
|
+
description: "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) \u2014 already-running containers keep their existing mounts.",
|
|
906
|
+
inputSchema: z3.object({
|
|
907
|
+
project: z3.string().describe("Project name or ID"),
|
|
908
|
+
name: z3.string().min(1).describe("Logical volume name (unique per project; alphanumeric/_/-)"),
|
|
909
|
+
target: z3.string().min(1).describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)")
|
|
910
|
+
})
|
|
911
|
+
}, async ({ project, name, target }) => {
|
|
912
|
+
const p = await resolveProject(project);
|
|
913
|
+
const res = await apiResponse.post(`/api/projects/${p.id}/volumes`, { name, target });
|
|
914
|
+
if (!res.ok)
|
|
915
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
916
|
+
const created = await res.json();
|
|
917
|
+
return {
|
|
918
|
+
content: [
|
|
919
|
+
{
|
|
920
|
+
type: "text",
|
|
921
|
+
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.`
|
|
922
|
+
}
|
|
923
|
+
]
|
|
924
|
+
};
|
|
925
|
+
});
|
|
926
|
+
server.registerTool("moor_volume_remove", {
|
|
927
|
+
title: "Remove Project Volume Mount",
|
|
928
|
+
description: "Detach a named volume from a project's mount config. The underlying Docker volume (and its data) is intentionally preserved \u2014 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.",
|
|
929
|
+
inputSchema: z3.object({
|
|
930
|
+
project: z3.string().describe("Project name or ID"),
|
|
931
|
+
volume_id: z3.number().int().positive().describe("Volume ID from moor_volume_list")
|
|
932
|
+
})
|
|
933
|
+
}, async ({ project, volume_id }) => {
|
|
934
|
+
const p = await resolveProject(project);
|
|
935
|
+
const res = await apiResponse.delete(`/api/projects/${p.id}/volumes/${volume_id}`);
|
|
936
|
+
if (res.status === 404)
|
|
937
|
+
throw new Error(`Volume ${volume_id} not found on project ${p.name}`);
|
|
938
|
+
if (!res.ok)
|
|
939
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
940
|
+
const body = await res.json();
|
|
941
|
+
return { content: [{ type: "text", text: body.message }] };
|
|
942
|
+
});
|
|
943
|
+
server.registerTool("moor_file_set", {
|
|
944
|
+
title: "Set Project File",
|
|
945
|
+
description: "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 \u2014 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).",
|
|
946
|
+
inputSchema: z3.object({
|
|
947
|
+
project: z3.string().describe("Project name or ID"),
|
|
948
|
+
path: z3.string().min(1).describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"),
|
|
949
|
+
content: z3.string().optional().describe("Inline file contents. Provide exactly one of content or env_ref."),
|
|
950
|
+
env_ref: z3.string().optional().describe("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."),
|
|
951
|
+
mode: z3.string().optional().describe("Octal permission string applied in the tar header, e.g. '0600'. Default '0644'.")
|
|
952
|
+
})
|
|
953
|
+
}, async ({ project, path, content, env_ref, mode }) => {
|
|
954
|
+
const p = await resolveProject(project);
|
|
955
|
+
const body = { path };
|
|
956
|
+
if (content !== undefined)
|
|
957
|
+
body.content = content;
|
|
958
|
+
if (env_ref !== undefined)
|
|
959
|
+
body.env_ref = env_ref;
|
|
960
|
+
if (mode !== undefined)
|
|
961
|
+
body.mode = mode;
|
|
962
|
+
const res = await apiResponse.post(`/api/projects/${p.id}/files`, body);
|
|
963
|
+
if (!res.ok)
|
|
964
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
965
|
+
const saved = await res.json();
|
|
966
|
+
const verb = res.status === 201 ? "Added" : "Updated";
|
|
967
|
+
return {
|
|
968
|
+
content: [
|
|
969
|
+
{
|
|
970
|
+
type: "text",
|
|
971
|
+
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.`
|
|
972
|
+
}
|
|
973
|
+
]
|
|
974
|
+
};
|
|
975
|
+
});
|
|
976
|
+
server.registerTool("moor_file_list", {
|
|
977
|
+
title: "List Project Files",
|
|
978
|
+
description: "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).",
|
|
979
|
+
inputSchema: z3.object({
|
|
980
|
+
project: z3.string().describe("Project name or ID")
|
|
981
|
+
})
|
|
982
|
+
}, async ({ project }) => {
|
|
983
|
+
const p = await resolveProject(project);
|
|
984
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/files`);
|
|
985
|
+
if (!res.ok)
|
|
986
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
987
|
+
const rows = await res.json();
|
|
988
|
+
if (rows.length === 0) {
|
|
989
|
+
return { content: [{ type: "text", text: `No files configured for ${p.name}.` }] };
|
|
990
|
+
}
|
|
991
|
+
const lines = rows.map((f) => `id=${f.id} path=${f.path} mode=${f.mode} source=${f.source}${f.env_ref ? ` env_ref=${f.env_ref}` : ""}`);
|
|
992
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
993
|
+
`) }] };
|
|
994
|
+
});
|
|
995
|
+
server.registerTool("moor_file_remove", {
|
|
996
|
+
title: "Remove Project File",
|
|
997
|
+
description: "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.",
|
|
998
|
+
inputSchema: z3.object({
|
|
999
|
+
project: z3.string().describe("Project name or ID"),
|
|
1000
|
+
file_id: z3.number().int().positive().describe("File ID from moor_file_list")
|
|
1001
|
+
})
|
|
1002
|
+
}, async ({ project, file_id }) => {
|
|
1003
|
+
const p = await resolveProject(project);
|
|
1004
|
+
const res = await apiResponse.delete(`/api/projects/${p.id}/files/${file_id}`);
|
|
1005
|
+
if (res.status === 404)
|
|
1006
|
+
throw new Error(`File ${file_id} not found on project ${p.name}`);
|
|
1007
|
+
if (!res.ok)
|
|
1008
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
1009
|
+
return {
|
|
1010
|
+
content: [
|
|
1011
|
+
{
|
|
1012
|
+
type: "text",
|
|
1013
|
+
text: `Removed file ${file_id} from ${p.name}. Applies on next container recreate.`
|
|
1014
|
+
}
|
|
1015
|
+
]
|
|
1016
|
+
};
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/tools/exec.ts
|
|
1021
|
+
import { z as z4 } from "zod";
|
|
1022
|
+
function registerExecTools(server, client2) {
|
|
1023
|
+
const { apiResponse, resolveProject, readErrorMessage } = client2;
|
|
1024
|
+
server.registerTool("moor_exec", {
|
|
1025
|
+
title: "Execute Command",
|
|
1026
|
+
description: "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, use moor_exec_async.",
|
|
1027
|
+
inputSchema: z4.object({
|
|
1028
|
+
project: z4.string().describe("Project name or ID"),
|
|
1029
|
+
command: z4.string().describe("Shell command to execute"),
|
|
1030
|
+
timeout_ms: z4.number().int().min(1000).max(3600000).optional().describe("Max time in milliseconds before the exec is aborted. Default 600000 (10 min). Max 3600000 (1 h).")
|
|
1031
|
+
})
|
|
1032
|
+
}, async ({ project, command, timeout_ms }) => {
|
|
1033
|
+
const p = await resolveProject(project);
|
|
1034
|
+
const body = { command };
|
|
1035
|
+
if (timeout_ms !== undefined)
|
|
1036
|
+
body.timeout_ms = timeout_ms;
|
|
1037
|
+
const res = await apiResponse.post(`/api/projects/${p.id}/exec`, body);
|
|
1038
|
+
if (res.status === 504) {
|
|
1039
|
+
const t = await res.json();
|
|
1040
|
+
let detail;
|
|
1041
|
+
if (t.killed) {
|
|
1042
|
+
detail = `Process tree terminated (container pid ${t.killed_pid}).`;
|
|
1043
|
+
} else if (t.killed_pid !== null) {
|
|
1044
|
+
detail = `Kill attempted on container pid ${t.killed_pid} but ${t.live_remaining} descendant process(es) still running inside the container.`;
|
|
1045
|
+
} else {
|
|
1046
|
+
detail = "Process kill could not locate the running process \u2014 it may still be running inside the container.";
|
|
1047
|
+
}
|
|
1048
|
+
throw new Error(`Exec timed out after ${t.timeout_ms}ms. ${detail}`);
|
|
1049
|
+
}
|
|
1050
|
+
if (!res.ok)
|
|
1051
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
1052
|
+
const result = await res.json();
|
|
1053
|
+
let text = "";
|
|
1054
|
+
if (result.stdout)
|
|
1055
|
+
text += result.stdout;
|
|
1056
|
+
if (result.stderr)
|
|
1057
|
+
text += `
|
|
1058
|
+
[stderr] ${result.stderr}`;
|
|
1059
|
+
text += `
|
|
1060
|
+
[exit code: ${result.exitCode}]`;
|
|
1061
|
+
return { content: [{ type: "text", text }] };
|
|
1062
|
+
});
|
|
1063
|
+
server.registerTool("moor_exec_async", {
|
|
1064
|
+
title: "Start Async Exec",
|
|
1065
|
+
description: "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.",
|
|
1066
|
+
inputSchema: z4.object({
|
|
1067
|
+
project: z4.string().describe("Project name or ID"),
|
|
1068
|
+
command: z4.string().min(1).describe("Shell command to execute"),
|
|
1069
|
+
timeout_ms: z4.number().int().min(60000).max(86400000).optional().describe("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.")
|
|
1070
|
+
})
|
|
1071
|
+
}, async ({ project, command, timeout_ms }) => {
|
|
1072
|
+
const p = await resolveProject(project);
|
|
1073
|
+
const body = { command };
|
|
1074
|
+
if (timeout_ms !== undefined)
|
|
1075
|
+
body.timeout_ms = timeout_ms;
|
|
1076
|
+
const res = await apiResponse.post(`/api/projects/${p.id}/exec/async`, body);
|
|
1077
|
+
if (!res.ok)
|
|
1078
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
1079
|
+
const data = await res.json();
|
|
1080
|
+
return {
|
|
1081
|
+
content: [
|
|
1082
|
+
{
|
|
1083
|
+
type: "text",
|
|
1084
|
+
text: `Started async exec on ${p.name}. run_id=${data.run_id}. Use moor_exec_status to poll; moor_exec_stop to terminate.`
|
|
1085
|
+
}
|
|
1086
|
+
]
|
|
1087
|
+
};
|
|
1088
|
+
});
|
|
1089
|
+
server.registerTool("moor_exec_status", {
|
|
1090
|
+
title: "Get Async Exec Status",
|
|
1091
|
+
description: "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 \u2014 tail_bytes only controls what the MCP tool returns to keep responses under typical agent token limits.",
|
|
1092
|
+
inputSchema: z4.object({
|
|
1093
|
+
run_id: z4.number().int().positive().describe("Run ID returned by moor_exec_async"),
|
|
1094
|
+
tail_bytes: z4.number().int().min(0).max(65536).optional().describe("Max bytes of each stream (stdout, stderr) returned inline. Default 8192. Max 65536 (the API storage cap). Set to 0 for metadata-only.")
|
|
1095
|
+
})
|
|
1096
|
+
}, async ({ run_id, tail_bytes }) => {
|
|
1097
|
+
const cap = tail_bytes ?? 8192;
|
|
1098
|
+
const res = await apiResponse.get(`/api/exec/${run_id}`);
|
|
1099
|
+
if (res.status === 404)
|
|
1100
|
+
throw new Error(`run_id ${run_id} not found`);
|
|
1101
|
+
if (!res.ok)
|
|
1102
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
1103
|
+
const data = await res.json();
|
|
1104
|
+
const lines = [];
|
|
1105
|
+
lines.push(`run_id=${data.id} state=${data.state} duration=${formatMs(data.duration_ms)}` + (data.exit_code !== null ? ` exit_code=${data.exit_code}` : ""));
|
|
1106
|
+
lines.push(`command: ${data.command}`);
|
|
1107
|
+
if (data.killed_pid)
|
|
1108
|
+
lines.push(`killed_pid: ${data.killed_pid}`);
|
|
1109
|
+
if (data.error_message)
|
|
1110
|
+
lines.push(`error: ${data.error_message}`);
|
|
1111
|
+
appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap);
|
|
1112
|
+
appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap);
|
|
1113
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1114
|
+
`) }] };
|
|
1115
|
+
});
|
|
1116
|
+
server.registerTool("moor_exec_stop", {
|
|
1117
|
+
title: "Stop Async Exec",
|
|
1118
|
+
description: "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 \u2014 the kill script removes the pidfile after every attempt, and reparented survivors are unreachable from the original PID.",
|
|
1119
|
+
inputSchema: z4.object({
|
|
1120
|
+
run_id: z4.number().int().positive().describe("Run ID returned by moor_exec_async")
|
|
1121
|
+
})
|
|
1122
|
+
}, async ({ run_id }) => {
|
|
1123
|
+
const res = await apiResponse.post(`/api/exec/${run_id}/stop`);
|
|
1124
|
+
if (res.status === 404)
|
|
1125
|
+
throw new Error(`run_id ${run_id} not found`);
|
|
1126
|
+
const data = await res.json();
|
|
1127
|
+
return {
|
|
1128
|
+
content: [
|
|
1129
|
+
{
|
|
1130
|
+
type: "text",
|
|
1131
|
+
text: `run_id=${run_id} state=${data.state} ${data.message}`
|
|
1132
|
+
}
|
|
1133
|
+
]
|
|
1134
|
+
};
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// src/tools/projects.ts
|
|
1139
|
+
import { z as z5 } from "zod";
|
|
1140
|
+
function registerProjectTools(server, client2) {
|
|
1141
|
+
const { apiResponse, resolveProject, readErrorMessage, readSSE } = client2;
|
|
1142
|
+
server.registerTool("moor_status", {
|
|
1143
|
+
title: "List Projects",
|
|
1144
|
+
description: "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."
|
|
1145
|
+
}, async () => {
|
|
1146
|
+
const res = await apiResponse.get("/api/projects");
|
|
1147
|
+
if (!res.ok)
|
|
1148
|
+
throw new Error(`Failed: ${res.status}`);
|
|
1149
|
+
const projects = await res.json();
|
|
1150
|
+
const summary = projects.map((p) => ({
|
|
1151
|
+
name: p.name,
|
|
1152
|
+
status: p.status,
|
|
1153
|
+
live_status: p.live_status ?? null,
|
|
1154
|
+
live_exit_code: p.live_exit_code ?? null,
|
|
1155
|
+
live_checked_at: p.live_checked_at ?? null,
|
|
1156
|
+
live_error: p.live_error ?? null,
|
|
1157
|
+
source: p.docker_image || p.github_url || null,
|
|
1158
|
+
domain: p.domain
|
|
1159
|
+
}));
|
|
1160
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
1161
|
+
});
|
|
1162
|
+
server.registerTool("moor_project_get", {
|
|
1163
|
+
title: "Get Project",
|
|
1164
|
+
description: "Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).",
|
|
1165
|
+
inputSchema: z5.object({
|
|
1166
|
+
project: z5.string().describe("Project name or ID")
|
|
1167
|
+
})
|
|
1168
|
+
}, async ({ project }) => {
|
|
1169
|
+
const p = await resolveProject(project);
|
|
1170
|
+
return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] };
|
|
1171
|
+
});
|
|
1172
|
+
server.registerTool("moor_project_create", {
|
|
1173
|
+
title: "Create Project",
|
|
1174
|
+
description: "Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild to bring it up, or use moor_deploy to create and start in one step.",
|
|
1175
|
+
inputSchema: z5.object({
|
|
1176
|
+
name: z5.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, "name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -").describe("Project name (used as the container name suffix: moor-<name>)"),
|
|
1177
|
+
github_url: z5.string().optional().describe("github.com URL; mutually exclusive with docker_image"),
|
|
1178
|
+
docker_image: z5.string().optional().describe("Docker image reference (e.g. nginx:latest); mutually exclusive with github_url"),
|
|
1179
|
+
branch: z5.string().optional().describe("Git branch (default: main, for github_url projects)"),
|
|
1180
|
+
dockerfile: z5.string().optional().describe("Dockerfile path within the repo (default: Dockerfile)"),
|
|
1181
|
+
domain: z5.string().optional().describe("Public domain to route to this container via Caddy"),
|
|
1182
|
+
domain_port: z5.number().int().positive().optional().describe("Container port Caddy should forward to (required if domain is set)"),
|
|
1183
|
+
restart_policy: z5.enum(["no", "on-failure", "always", "unless-stopped"]).optional().describe("Docker restart policy (default: unless-stopped)"),
|
|
1184
|
+
memory_limit_mb: z5.number().int().min(6).optional().describe("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)."),
|
|
1185
|
+
cpus: z5.number().min(0.001).optional().describe("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 \u2014 use omit for that). Max host core count. Takes effect on container recreate."),
|
|
1186
|
+
volumes: z5.array(z5.object({
|
|
1187
|
+
name: z5.string().min(1).describe("Logical volume name (unique per project)"),
|
|
1188
|
+
target: z5.string().min(1).describe("Absolute in-container mount path (e.g. /var/lib/postgresql/data)")
|
|
1189
|
+
})).optional().describe("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."),
|
|
1190
|
+
source_credential_id: z5.number().int().positive().nullable().optional().describe("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)."),
|
|
1191
|
+
command: z5.array(z5.string()).nullable().optional().describe(`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.`),
|
|
1192
|
+
entrypoint: z5.array(z5.string()).nullable().optional().describe("Override the image's ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate.")
|
|
1193
|
+
})
|
|
1194
|
+
}, async (input) => {
|
|
1195
|
+
const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
|
|
1196
|
+
if (sources !== 1) {
|
|
1197
|
+
throw new Error("Provide exactly one of github_url or docker_image");
|
|
1198
|
+
}
|
|
1199
|
+
if (input.github_url)
|
|
1200
|
+
validateGithubUrl(input.github_url);
|
|
1201
|
+
const { volumes, ...createBody } = input;
|
|
1202
|
+
const res = await apiResponse.post("/api/projects", createBody);
|
|
1203
|
+
if (!res.ok)
|
|
1204
|
+
throw new Error(`Failed to create project: ${await readErrorMessage(res)}`);
|
|
1205
|
+
const project = await res.json();
|
|
1206
|
+
const volumeFailures = [];
|
|
1207
|
+
const volumeCreated = [];
|
|
1208
|
+
if (volumes && volumes.length > 0) {
|
|
1209
|
+
for (const v of volumes) {
|
|
1210
|
+
const vRes = await apiResponse.post(`/api/projects/${project.id}/volumes`, v);
|
|
1211
|
+
if (vRes.ok)
|
|
1212
|
+
volumeCreated.push(v.name);
|
|
1213
|
+
else
|
|
1214
|
+
volumeFailures.push({ name: v.name, error: await readErrorMessage(vRes) });
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const lines = [JSON.stringify(project, null, 2)];
|
|
1218
|
+
if (volumeCreated.length > 0) {
|
|
1219
|
+
lines.push(`
|
|
1220
|
+
Created volumes: ${volumeCreated.join(", ")}`);
|
|
1221
|
+
}
|
|
1222
|
+
if (volumeFailures.length > 0) {
|
|
1223
|
+
lines.push(`
|
|
1224
|
+
Volume failures (project was still created): ${volumeFailures.map((f) => `${f.name}: ${f.error}`).join("; ")}`);
|
|
1225
|
+
}
|
|
1226
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1227
|
+
`) }] };
|
|
1228
|
+
});
|
|
1229
|
+
server.registerTool("moor_project_update", {
|
|
1230
|
+
title: "Update Project",
|
|
1231
|
+
description: "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) \u2014 an already-running container keeps its existing limits.",
|
|
1232
|
+
inputSchema: z5.object({
|
|
1233
|
+
project: z5.string().describe("Project name or ID to update"),
|
|
1234
|
+
name: z5.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, "name must start alphanumeric; allowed: a-z A-Z 0-9 _ -").optional(),
|
|
1235
|
+
github_url: z5.string().optional(),
|
|
1236
|
+
docker_image: z5.string().optional(),
|
|
1237
|
+
branch: z5.string().optional(),
|
|
1238
|
+
dockerfile: z5.string().optional(),
|
|
1239
|
+
domain: z5.string().optional(),
|
|
1240
|
+
domain_port: z5.number().int().positive().optional(),
|
|
1241
|
+
restart_policy: z5.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
|
|
1242
|
+
memory_limit_mb: z5.number().int().min(6).nullable().optional().describe("Max RAM in MB. Pass null to clear (return to unbounded). Min 6, max host total memory. Takes effect on container recreate."),
|
|
1243
|
+
cpus: z5.number().min(0.001).nullable().optional().describe("Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate."),
|
|
1244
|
+
source_credential_id: z5.number().int().positive().nullable().optional().describe("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."),
|
|
1245
|
+
command: z5.array(z5.string()).nullable().optional().describe(`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.`),
|
|
1246
|
+
entrypoint: z5.array(z5.string()).nullable().optional().describe("Override the image's ENTRYPOINT as an argv array. Pass [] or null to clear. Takes effect on container recreate.")
|
|
1247
|
+
})
|
|
1248
|
+
}, async (input) => {
|
|
1249
|
+
const { project, ...updates } = input;
|
|
1250
|
+
if (Object.keys(updates).length === 0) {
|
|
1251
|
+
throw new Error("Provide at least one field to update");
|
|
1252
|
+
}
|
|
1253
|
+
if (updates.github_url && updates.docker_image) {
|
|
1254
|
+
throw new Error("Cannot set both github_url and docker_image in the same update");
|
|
1255
|
+
}
|
|
1256
|
+
if (updates.github_url)
|
|
1257
|
+
validateGithubUrl(updates.github_url);
|
|
1258
|
+
const p = await resolveProject(project);
|
|
1259
|
+
const res = await apiResponse.put(`/api/projects/${p.id}`, updates);
|
|
1260
|
+
if (!res.ok)
|
|
1261
|
+
throw new Error(`Failed to update project: ${await readErrorMessage(res)}`);
|
|
1262
|
+
const updated = await res.json();
|
|
1263
|
+
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
1264
|
+
});
|
|
1265
|
+
server.registerTool("moor_project_delete", {
|
|
1266
|
+
title: "Delete Project",
|
|
1267
|
+
description: "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 \u2014 that deletion is also irreversible.",
|
|
1268
|
+
inputSchema: z5.object({
|
|
1269
|
+
project: z5.string().describe("Project name or ID to delete"),
|
|
1270
|
+
confirm_name: z5.string().describe("Must equal the resolved project's name. Guards against deleting the wrong project."),
|
|
1271
|
+
purge_volumes: z5.boolean().optional().default(false).describe("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.")
|
|
1272
|
+
})
|
|
1273
|
+
}, async ({ project, confirm_name, purge_volumes }) => {
|
|
1274
|
+
const p = await resolveProject(project);
|
|
1275
|
+
if (confirm_name !== p.name) {
|
|
1276
|
+
throw new Error(`confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`);
|
|
1277
|
+
}
|
|
1278
|
+
const qs = purge_volumes ? "?purge_volumes=true" : "";
|
|
1279
|
+
const res = await apiResponse.delete(`/api/projects/${p.id}${qs}`);
|
|
1280
|
+
if (!res.ok) {
|
|
1281
|
+
const text = await readErrorMessage(res);
|
|
1282
|
+
let message = text;
|
|
1283
|
+
try {
|
|
1284
|
+
const parsed = JSON.parse(text);
|
|
1285
|
+
if (isJsonObject(parsed) && typeof parsed.message === "string") {
|
|
1286
|
+
message = parsed.message;
|
|
1287
|
+
}
|
|
1288
|
+
} catch {}
|
|
1289
|
+
throw new Error(`Failed to delete project: ${message}`);
|
|
1290
|
+
}
|
|
1291
|
+
if (res.status === 204) {
|
|
1292
|
+
return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
|
|
1293
|
+
}
|
|
1294
|
+
const body = await res.json();
|
|
1295
|
+
return {
|
|
1296
|
+
content: [
|
|
1297
|
+
{
|
|
1298
|
+
type: "text",
|
|
1299
|
+
text: `Deleted project ${p.name} (id=${p.id}). Purged ${body.volumes_purged ?? 0} Docker volume(s).`
|
|
1300
|
+
}
|
|
1301
|
+
]
|
|
1302
|
+
};
|
|
1303
|
+
});
|
|
1304
|
+
server.registerTool("moor_deploy", {
|
|
1305
|
+
title: "Deploy Project",
|
|
1306
|
+
description: "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.",
|
|
1307
|
+
inputSchema: z5.object({
|
|
1308
|
+
name: z5.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, "name must start alphanumeric; allowed chars: a-z A-Z 0-9 _ -").describe("Project name (also the container suffix: moor-<name>)"),
|
|
1309
|
+
github_url: z5.string().optional().describe("GitHub repo URL: host must be github.com or www.github.com, path must be /owner/repo (optional .git). Mutually exclusive with docker_image."),
|
|
1310
|
+
docker_image: z5.string().optional().describe("Docker image reference (e.g. nginx:latest). Mutually exclusive with github_url."),
|
|
1311
|
+
branch: z5.string().optional().describe("Git branch (API default: main)"),
|
|
1312
|
+
dockerfile: z5.string().optional().describe("Dockerfile path in the repo (API default: Dockerfile)"),
|
|
1313
|
+
domain: z5.string().optional().describe("Public domain to route via Caddy"),
|
|
1314
|
+
domain_port: z5.number().int().positive().optional().describe("Container port Caddy should forward to"),
|
|
1315
|
+
restart_policy: z5.enum(["no", "on-failure", "always", "unless-stopped"]).optional().describe("Docker restart policy (API default: unless-stopped)"),
|
|
1316
|
+
memory_limit_mb: z5.number().int().min(6).nullable().optional().describe("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."),
|
|
1317
|
+
cpus: z5.number().min(0.001).nullable().optional().describe("Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear."),
|
|
1318
|
+
volumes: z5.array(z5.object({
|
|
1319
|
+
name: z5.string().min(1),
|
|
1320
|
+
target: z5.string().min(1)
|
|
1321
|
+
})).optional().describe("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 \u2014 no removals. Data survives container/project rebuilds unless explicitly purged via moor_project_delete with confirm_name (purge_volumes is a separate flag)."),
|
|
1322
|
+
env: z5.record(z5.string(), z5.string()).optional().describe("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."),
|
|
1323
|
+
source_credential_id: z5.number().int().positive().nullable().optional().describe("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."),
|
|
1324
|
+
command: z5.array(z5.string()).nullable().optional().describe('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.'),
|
|
1325
|
+
entrypoint: z5.array(z5.string()).nullable().optional().describe("Override the image ENTRYPOINT as an argv array. Omit to keep the image default; pass [] or null to clear. Applies on container recreate."),
|
|
1326
|
+
files: z5.array(z5.object({
|
|
1327
|
+
path: z5.string().min(1).describe("Absolute in-container destination path, e.g. /etc/ssl/cert.pem"),
|
|
1328
|
+
content: z5.string().optional().describe("Inline file contents. Provide exactly one of content or env_ref."),
|
|
1329
|
+
env_ref: z5.string().optional().describe("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."),
|
|
1330
|
+
mode: z5.string().optional().describe("Octal permission string for the tar header, e.g. '0600'. Default '0644'.")
|
|
1331
|
+
})).optional().describe("Declarative files to inject into the container before it starts, written on every recreate (additions/updates only \u2014 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)."),
|
|
1332
|
+
run: z5.boolean().optional().default(true).describe("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."),
|
|
1333
|
+
update_existing: z5.boolean().optional().default(false).describe("Allow updating a project that already exists. Default false (create-only).")
|
|
1334
|
+
})
|
|
1335
|
+
}, async (input) => {
|
|
1336
|
+
if (input.github_url)
|
|
1337
|
+
validateGithubRepoUrl(input.github_url);
|
|
1338
|
+
if (input.github_url && input.docker_image) {
|
|
1339
|
+
throw new Error("Cannot set both github_url and docker_image");
|
|
1340
|
+
}
|
|
1341
|
+
if (input.run !== false) {
|
|
1342
|
+
const drainRes = await apiResponse.get("/api/server/drain");
|
|
1343
|
+
if (drainRes.ok) {
|
|
1344
|
+
const { state } = await drainRes.json();
|
|
1345
|
+
if (state.enabled) {
|
|
1346
|
+
throw new Error(`[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.`);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
const listRes = await apiResponse.get("/api/projects");
|
|
1351
|
+
if (!listRes.ok)
|
|
1352
|
+
throw new Error(`Failed to list projects: ${listRes.status}`);
|
|
1353
|
+
const projects = await listRes.json();
|
|
1354
|
+
const existing = projects.find((p) => p.name === input.name);
|
|
1355
|
+
if (existing && !input.update_existing) {
|
|
1356
|
+
throw new Error(`Project "${input.name}" already exists. Pass update_existing: true to update it.`);
|
|
1357
|
+
}
|
|
1358
|
+
if (!existing) {
|
|
1359
|
+
const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
|
|
1360
|
+
if (sources !== 1) {
|
|
1361
|
+
throw new Error("Provide exactly one of github_url or docker_image");
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const normalizedDomain = input.domain === undefined ? undefined : input.domain.trim().toLowerCase() || null;
|
|
1365
|
+
if (normalizedDomain) {
|
|
1366
|
+
const conflict = projects.find((p) => p.domain && p.domain.trim().toLowerCase() === normalizedDomain && p.id !== existing?.id);
|
|
1367
|
+
if (conflict) {
|
|
1368
|
+
throw new Error(`Domain "${normalizedDomain}" is already used by project "${conflict.name}" (id=${conflict.id}). Refusing before Caddy reload.`);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
let projectId;
|
|
1372
|
+
let projectName;
|
|
1373
|
+
if (!existing) {
|
|
1374
|
+
const createBody = {
|
|
1375
|
+
name: input.name,
|
|
1376
|
+
github_url: input.github_url,
|
|
1377
|
+
docker_image: input.docker_image,
|
|
1378
|
+
branch: input.branch,
|
|
1379
|
+
dockerfile: input.dockerfile,
|
|
1380
|
+
domain: normalizedDomain,
|
|
1381
|
+
domain_port: input.domain_port,
|
|
1382
|
+
restart_policy: input.restart_policy,
|
|
1383
|
+
memory_limit_mb: input.memory_limit_mb,
|
|
1384
|
+
cpus: input.cpus,
|
|
1385
|
+
source_credential_id: input.source_credential_id,
|
|
1386
|
+
command: input.command,
|
|
1387
|
+
entrypoint: input.entrypoint
|
|
1388
|
+
};
|
|
1389
|
+
const res = await apiResponse.post("/api/projects", createBody);
|
|
1390
|
+
if (!res.ok)
|
|
1391
|
+
throw new Error(`[create] ${await readErrorMessage(res)}`);
|
|
1392
|
+
const created = await res.json();
|
|
1393
|
+
projectId = created.id;
|
|
1394
|
+
projectName = created.name;
|
|
1395
|
+
} else {
|
|
1396
|
+
const updateBody = {};
|
|
1397
|
+
if (input.github_url !== undefined)
|
|
1398
|
+
updateBody.github_url = input.github_url;
|
|
1399
|
+
if (input.docker_image !== undefined)
|
|
1400
|
+
updateBody.docker_image = input.docker_image;
|
|
1401
|
+
if (input.branch !== undefined)
|
|
1402
|
+
updateBody.branch = input.branch;
|
|
1403
|
+
if (input.dockerfile !== undefined)
|
|
1404
|
+
updateBody.dockerfile = input.dockerfile;
|
|
1405
|
+
if (normalizedDomain !== undefined)
|
|
1406
|
+
updateBody.domain = normalizedDomain;
|
|
1407
|
+
if (input.domain_port !== undefined)
|
|
1408
|
+
updateBody.domain_port = input.domain_port;
|
|
1409
|
+
if (input.restart_policy !== undefined)
|
|
1410
|
+
updateBody.restart_policy = input.restart_policy;
|
|
1411
|
+
if (input.memory_limit_mb !== undefined)
|
|
1412
|
+
updateBody.memory_limit_mb = input.memory_limit_mb;
|
|
1413
|
+
if (input.cpus !== undefined)
|
|
1414
|
+
updateBody.cpus = input.cpus;
|
|
1415
|
+
if (input.source_credential_id !== undefined)
|
|
1416
|
+
updateBody.source_credential_id = input.source_credential_id;
|
|
1417
|
+
if (input.command !== undefined)
|
|
1418
|
+
updateBody.command = input.command;
|
|
1419
|
+
if (input.entrypoint !== undefined)
|
|
1420
|
+
updateBody.entrypoint = input.entrypoint;
|
|
1421
|
+
if (Object.keys(updateBody).length > 0) {
|
|
1422
|
+
const res = await apiResponse.put(`/api/projects/${existing.id}`, updateBody);
|
|
1423
|
+
if (!res.ok)
|
|
1424
|
+
throw new Error(`[update] ${await readErrorMessage(res)}`);
|
|
1425
|
+
}
|
|
1426
|
+
projectId = existing.id;
|
|
1427
|
+
projectName = existing.name;
|
|
1428
|
+
}
|
|
1429
|
+
if (input.volumes && input.volumes.length > 0) {
|
|
1430
|
+
let existingVolumes = null;
|
|
1431
|
+
for (const v of input.volumes) {
|
|
1432
|
+
const vRes = await apiResponse.post(`/api/projects/${projectId}/volumes`, v);
|
|
1433
|
+
if (vRes.ok)
|
|
1434
|
+
continue;
|
|
1435
|
+
const text = await readErrorMessage(vRes);
|
|
1436
|
+
if (vRes.status !== 409) {
|
|
1437
|
+
throw new Error(`[volumes] failed to add ${v.name}: ${text}`);
|
|
1438
|
+
}
|
|
1439
|
+
if (existingVolumes === null) {
|
|
1440
|
+
const listRes2 = await apiResponse.get(`/api/projects/${projectId}/volumes`);
|
|
1441
|
+
if (!listRes2.ok) {
|
|
1442
|
+
throw new Error(`[volumes] could not resolve 409 on ${v.name}: failed to list existing volumes: ${await readErrorMessage(listRes2)}`);
|
|
1443
|
+
}
|
|
1444
|
+
existingVolumes = await listRes2.json();
|
|
1445
|
+
}
|
|
1446
|
+
const match = existingVolumes.find((e) => e.name === v.name);
|
|
1447
|
+
if (!match) {
|
|
1448
|
+
throw new Error(`[volumes] conflict adding ${v.name}: ${text} (no existing volume by that name; check for target collision)`);
|
|
1449
|
+
}
|
|
1450
|
+
if (match.target !== v.target) {
|
|
1451
|
+
throw new Error(`[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.`);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
if (input.files && input.files.length > 0) {
|
|
1456
|
+
for (const f of input.files) {
|
|
1457
|
+
const fRes = await apiResponse.post(`/api/projects/${projectId}/files`, f);
|
|
1458
|
+
if (!fRes.ok) {
|
|
1459
|
+
throw new Error(`[files] failed to set ${f.path}: ${await readErrorMessage(fRes)}`);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
const envEntries = input.env ? Object.entries(input.env) : [];
|
|
1464
|
+
const envProvided = envEntries.length > 0;
|
|
1465
|
+
if (envProvided) {
|
|
1466
|
+
const existingRes = await apiResponse.get(`/api/projects/${projectId}/envs`);
|
|
1467
|
+
if (!existingRes.ok) {
|
|
1468
|
+
throw new Error(`[set_env] Failed to read envs: ${existingRes.status}`);
|
|
1469
|
+
}
|
|
1470
|
+
const existingEnvs = await existingRes.json();
|
|
1471
|
+
const merged = new Map(existingEnvs.map((v) => [v.key, v.value]));
|
|
1472
|
+
for (const [k, v] of envEntries)
|
|
1473
|
+
merged.set(k, v);
|
|
1474
|
+
const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
|
|
1475
|
+
const putRes = await apiResponse.put(`/api/projects/${projectId}/envs`, allVars);
|
|
1476
|
+
if (!putRes.ok)
|
|
1477
|
+
throw new Error(`[set_env] ${await readErrorMessage(putRes)}`);
|
|
1478
|
+
}
|
|
1479
|
+
let runLogs = "";
|
|
1480
|
+
let runStructuredError;
|
|
1481
|
+
if (input.run) {
|
|
1482
|
+
const runRes = await apiResponse.post(`/api/projects/${projectId}/run`);
|
|
1483
|
+
if (!runRes.ok)
|
|
1484
|
+
throw new Error(`[run] ${await readErrorMessage(runRes)}`);
|
|
1485
|
+
const { logs, error, structuredError } = await readSSE(runRes);
|
|
1486
|
+
runLogs = logs;
|
|
1487
|
+
if (structuredError) {
|
|
1488
|
+
runStructuredError = structuredError;
|
|
1489
|
+
} else if (error) {
|
|
1490
|
+
throw new Error(`[run] ${error}`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
const lines = [];
|
|
1494
|
+
lines.push(existing ? `Updated project ${projectName} (id=${projectId}).` : `Created project ${projectName} (id=${projectId}).`);
|
|
1495
|
+
if (envProvided) {
|
|
1496
|
+
lines.push(`Merged ${envEntries.length} env var(s): ${envEntries.map(([k]) => k).join(", ")}.`);
|
|
1497
|
+
}
|
|
1498
|
+
if (!input.run) {
|
|
1499
|
+
if (envProvided && existing?.status === "running") {
|
|
1500
|
+
lines.push("Note: project is running; env changes will not take effect until the next run or restart.");
|
|
1501
|
+
}
|
|
1502
|
+
} else {
|
|
1503
|
+
lines.push("");
|
|
1504
|
+
lines.push("Build/run output:");
|
|
1505
|
+
lines.push(runLogs || "(no output)");
|
|
1506
|
+
}
|
|
1507
|
+
if (runStructuredError) {
|
|
1508
|
+
lines.push("");
|
|
1509
|
+
lines.push(`Failed: code=${runStructuredError.code} message=${runStructuredError.message}`);
|
|
1510
|
+
return {
|
|
1511
|
+
content: [{ type: "text", text: lines.join(`
|
|
1512
|
+
`) }],
|
|
1513
|
+
structuredContent: { ...runStructuredError, project_id: projectId },
|
|
1514
|
+
isError: true
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1518
|
+
`) }] };
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
// src/tools/runs.ts
|
|
1523
|
+
import { z as z6 } from "zod";
|
|
1524
|
+
function registerRunTools(server, client2) {
|
|
1525
|
+
const { apiResponse, resolveProject, readErrorMessage, readSSE } = client2;
|
|
1526
|
+
server.registerTool("moor_logs", {
|
|
1527
|
+
title: "Get Container Logs",
|
|
1528
|
+
description: "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 \u2014 pre-#74 the tool returned empty logs for all of these.",
|
|
1529
|
+
inputSchema: z6.object({
|
|
1530
|
+
project: z6.string().describe("Project name or ID"),
|
|
1531
|
+
lines: z6.number().optional().default(100).describe("Number of log lines to retrieve")
|
|
1532
|
+
})
|
|
1533
|
+
}, async ({ project, lines }) => {
|
|
1534
|
+
const p = await resolveProject(project);
|
|
1535
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/logs?tail=${lines}`);
|
|
1536
|
+
if (res.status === 502) {
|
|
1537
|
+
const data2 = await res.json().catch(() => ({}));
|
|
1538
|
+
throw new Error(`Docker error: ${data2.error ?? "unknown"}`);
|
|
1539
|
+
}
|
|
1540
|
+
if (!res.ok)
|
|
1541
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1542
|
+
const data = await res.json();
|
|
1543
|
+
switch (data.state) {
|
|
1544
|
+
case "no_container":
|
|
1545
|
+
return {
|
|
1546
|
+
content: [{ type: "text", text: "(project hasn't been started yet \u2014 no container)" }]
|
|
1547
|
+
};
|
|
1548
|
+
case "missing":
|
|
1549
|
+
return {
|
|
1550
|
+
content: [
|
|
1551
|
+
{
|
|
1552
|
+
type: "text",
|
|
1553
|
+
text: "(container_id was recorded but Docker doesn't have it; moor may need to recreate the project)"
|
|
1554
|
+
}
|
|
1555
|
+
]
|
|
1556
|
+
};
|
|
1557
|
+
case "exited":
|
|
1558
|
+
return {
|
|
1559
|
+
content: [
|
|
1560
|
+
{
|
|
1561
|
+
type: "text",
|
|
1562
|
+
text: `${data.logs || "(no logs captured)"}
|
|
1563
|
+
|
|
1564
|
+
(container is exited; logs above are from before)`
|
|
1565
|
+
}
|
|
1566
|
+
]
|
|
1567
|
+
};
|
|
1568
|
+
default:
|
|
1569
|
+
return {
|
|
1570
|
+
content: [{ type: "text", text: data.logs || "(no logs)" }]
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
server.registerTool("moor_rebuild", {
|
|
1575
|
+
title: "Rebuild Project",
|
|
1576
|
+
description: "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 \u2014 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 \u2014 it skips the build and is much faster.",
|
|
1577
|
+
inputSchema: z6.object({
|
|
1578
|
+
project: z6.string().describe("Project name or ID"),
|
|
1579
|
+
no_cache: z6.boolean().optional().default(false).describe("Build without Docker cache")
|
|
1580
|
+
})
|
|
1581
|
+
}, async ({ project, no_cache }) => {
|
|
1582
|
+
const p = await resolveProject(project);
|
|
1583
|
+
const query = no_cache ? "?nocache=true" : "";
|
|
1584
|
+
const res = await apiResponse.post(`/api/projects/${p.id}/run${query}`);
|
|
1585
|
+
if (!res.ok)
|
|
1586
|
+
throw new Error(`[run] ${await readErrorMessage(res)}`);
|
|
1587
|
+
const { logs, error, structuredError } = await readSSE(res);
|
|
1588
|
+
if (structuredError) {
|
|
1589
|
+
return {
|
|
1590
|
+
content: [
|
|
1591
|
+
{
|
|
1592
|
+
type: "text",
|
|
1593
|
+
text: `rebuild failed: code=${structuredError.code} message=${structuredError.message}`
|
|
1594
|
+
}
|
|
1595
|
+
],
|
|
1596
|
+
structuredContent: structuredError,
|
|
1597
|
+
isError: true
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
if (error)
|
|
1601
|
+
throw new Error(error);
|
|
1602
|
+
return { content: [{ type: "text", text: logs || "Rebuild complete." }] };
|
|
1603
|
+
});
|
|
1604
|
+
server.registerTool("moor_restart", {
|
|
1605
|
+
title: "Restart Project",
|
|
1606
|
+
description: "Stop and recreate a project's container from its existing image. Does NOT pull from git or rebuild \u2014 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 \u2014 those need a new image).",
|
|
1607
|
+
inputSchema: z6.object({
|
|
1608
|
+
project: z6.string().describe("Project name or ID")
|
|
1609
|
+
})
|
|
1610
|
+
}, async ({ project }) => {
|
|
1611
|
+
const p = await resolveProject(project);
|
|
1612
|
+
const stopRes = await apiResponse.post(`/api/projects/${p.id}/stop`);
|
|
1613
|
+
if (!stopRes.ok)
|
|
1614
|
+
throw new Error(`Failed to stop: ${await readErrorMessage(stopRes)}`);
|
|
1615
|
+
const startRes = await apiResponse.post(`/api/projects/${p.id}/start`);
|
|
1616
|
+
if (!startRes.ok)
|
|
1617
|
+
throw new Error(`Failed to start: ${await readErrorMessage(startRes)}`);
|
|
1618
|
+
return { content: [{ type: "text", text: `${p.name} restarted.` }] };
|
|
1619
|
+
});
|
|
1620
|
+
server.registerTool("moor_runs", {
|
|
1621
|
+
title: "List Project Run History",
|
|
1622
|
+
description: "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) \u2014 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).",
|
|
1623
|
+
inputSchema: z6.object({
|
|
1624
|
+
project: z6.string().describe("Project name or ID"),
|
|
1625
|
+
page: z6.number().int().positive().optional().default(1).describe("Page number (20 runs per page). Default 1.")
|
|
1626
|
+
})
|
|
1627
|
+
}, async ({ project, page }) => {
|
|
1628
|
+
const p = await resolveProject(project);
|
|
1629
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/runs?include_output=false&page=${page}`);
|
|
1630
|
+
if (!res.ok)
|
|
1631
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
1632
|
+
const data = await res.json();
|
|
1633
|
+
if (data.runs.length === 0) {
|
|
1634
|
+
return {
|
|
1635
|
+
content: [{ type: "text", text: `No runs recorded for ${p.name}.` }]
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
const lines = [];
|
|
1639
|
+
lines.push(`${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).`);
|
|
1640
|
+
for (const r of data.runs) {
|
|
1641
|
+
const type = deriveRunType(r);
|
|
1642
|
+
const status = deriveRunStatus(r);
|
|
1643
|
+
const exit = r.exit_code != null ? ` exit=${r.exit_code}` : "";
|
|
1644
|
+
const cmd = r.cron_command ? ` cmd="${r.cron_command}"` : "";
|
|
1645
|
+
const outTotal = r.stdout_total_bytes ?? r.stdout_bytes;
|
|
1646
|
+
const errTotal = r.stderr_total_bytes ?? r.stderr_bytes;
|
|
1647
|
+
lines.push(`id=${r.id} ${type} ${status}${exit} dur=${formatMsShort(r.duration_ms)} stdout=${outTotal}B stderr=${errTotal}B started=${r.started_at}${cmd}`);
|
|
1648
|
+
}
|
|
1649
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1650
|
+
`) }] };
|
|
1651
|
+
});
|
|
1652
|
+
server.registerTool("moor_run_get", {
|
|
1653
|
+
title: "Get Run Detail",
|
|
1654
|
+
description: "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.",
|
|
1655
|
+
inputSchema: z6.object({
|
|
1656
|
+
run_id: z6.number().int().positive().describe("Run ID returned by moor_runs"),
|
|
1657
|
+
tail_bytes: z6.number().int().min(0).max(65536).optional().describe("Max bytes of each stream returned inline. Default 8192. Max 65536. Set to 0 for metadata-only.")
|
|
1658
|
+
})
|
|
1659
|
+
}, async ({ run_id, tail_bytes }) => {
|
|
1660
|
+
const cap = tail_bytes ?? 8192;
|
|
1661
|
+
const res = await apiResponse.get(`/api/runs/${run_id}`);
|
|
1662
|
+
if (res.status === 404)
|
|
1663
|
+
throw new Error(`run_id ${run_id} not found`);
|
|
1664
|
+
if (!res.ok)
|
|
1665
|
+
throw new Error(`Failed: ${await readErrorMessage(res)}`);
|
|
1666
|
+
const r = await res.json();
|
|
1667
|
+
const lines = [];
|
|
1668
|
+
const type = deriveRunType(r);
|
|
1669
|
+
const status = deriveRunStatus(r);
|
|
1670
|
+
const exit = r.exit_code != null ? ` exit_code=${r.exit_code}` : "";
|
|
1671
|
+
lines.push(`run_id=${r.id} ${type} ${status}${exit} duration=${formatMsShort(r.duration_ms)}`);
|
|
1672
|
+
if (r.cron_command)
|
|
1673
|
+
lines.push(`cron_command: ${r.cron_command}`);
|
|
1674
|
+
lines.push(`started_at: ${r.started_at}`);
|
|
1675
|
+
if (r.finished_at)
|
|
1676
|
+
lines.push(`finished_at: ${r.finished_at}`);
|
|
1677
|
+
const stdoutStr = r.stdout ?? "";
|
|
1678
|
+
const stderrStr = r.stderr ?? "";
|
|
1679
|
+
const enc = new TextEncoder;
|
|
1680
|
+
const stdoutTotal = r.stdout_total_bytes ?? enc.encode(stdoutStr).length;
|
|
1681
|
+
const stderrTotal = r.stderr_total_bytes ?? enc.encode(stderrStr).length;
|
|
1682
|
+
appendStream(lines, "stdout", stdoutStr, stdoutTotal, cap);
|
|
1683
|
+
appendStream(lines, "stderr", stderrStr, stderrTotal, cap);
|
|
1684
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1685
|
+
`) }] };
|
|
1686
|
+
});
|
|
1687
|
+
server.registerTool("moor_run_stop", {
|
|
1688
|
+
title: "Stop or Cancel a Run",
|
|
1689
|
+
description: "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 \u2014 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 \u2014 the tool throws only on unexpected server failures.",
|
|
1690
|
+
inputSchema: z6.object({
|
|
1691
|
+
run_id: z6.number().int().positive().describe("Run ID from moor_runs")
|
|
1692
|
+
})
|
|
1693
|
+
}, async ({ run_id }) => {
|
|
1694
|
+
const res = await apiResponse.post(`/api/runs/${run_id}/stop`);
|
|
1695
|
+
let data;
|
|
1696
|
+
try {
|
|
1697
|
+
data = await res.json();
|
|
1698
|
+
} catch {
|
|
1699
|
+
throw new Error(`run_id=${run_id} server error: ${res.status} ${res.statusText}`);
|
|
1700
|
+
}
|
|
1701
|
+
if (typeof data.result === "string") {
|
|
1702
|
+
return { content: [{ type: "text", text: `run_id=${run_id} ${data.result}` }] };
|
|
1703
|
+
}
|
|
1704
|
+
throw new Error(`run_id=${run_id} unexpected response: status=${res.status} body=${JSON.stringify(data)}`);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/tools/server.ts
|
|
1709
|
+
import { z as z7 } from "zod";
|
|
1710
|
+
function registerServerTools(server, client2) {
|
|
1711
|
+
const { apiResponse, resolveProject, readErrorMessage } = client2;
|
|
1712
|
+
server.registerTool("moor_stats", {
|
|
1713
|
+
title: "Server Stats",
|
|
1714
|
+
description: "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 \xF7 cores), not instantaneous CPU; use the `load` field for the same signal with explicit naming."
|
|
1715
|
+
}, async () => {
|
|
1716
|
+
const res = await apiResponse.get("/api/server/stats");
|
|
1717
|
+
if (!res.ok)
|
|
1718
|
+
throw new Error(`Failed: ${res.status}`);
|
|
1719
|
+
const s = await res.json();
|
|
1720
|
+
const lines = [
|
|
1721
|
+
`Host: ${s.hostname}`,
|
|
1722
|
+
`OS: ${s.os}`,
|
|
1723
|
+
`Uptime: ${s.uptime}`,
|
|
1724
|
+
`CPU: ${s.cpu.percent}% (${s.cpu.cores} cores) \u2014 load-derived, not instantaneous`
|
|
1725
|
+
];
|
|
1726
|
+
if (s.load) {
|
|
1727
|
+
lines.push(`Load (1m): ${s.load.one_min.toFixed(2)} on ${s.load.cores} cores (${s.load.normalized_percent}%)`);
|
|
1728
|
+
}
|
|
1729
|
+
lines.push(`Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`);
|
|
1730
|
+
const disks = s.disks?.length ? s.disks : [{ mount: "/", ...s.disk }];
|
|
1731
|
+
for (const d of disks) {
|
|
1732
|
+
const name = d.label ? `${d.label} (${d.mount})` : `Disk ${d.mount}`;
|
|
1733
|
+
lines.push(`${name}: ${d.used} / ${d.total} (${d.percent}%)`);
|
|
1734
|
+
}
|
|
1735
|
+
lines.push(`Containers: ${s.containers.running} running / ${s.containers.total} total`);
|
|
1736
|
+
if (s.docker) {
|
|
1737
|
+
const d = s.docker;
|
|
1738
|
+
lines.push("Docker disk:", ` Images: ${formatBytes(d.images.bytes)} (${formatBytes(d.images.reclaimable_bytes)} reclaimable, ${d.images.unused_count}/${d.images.count} unused)`, ` Containers: ${formatBytes(d.containers.bytes)} (${formatBytes(d.containers.reclaimable_bytes)} reclaimable, ${d.containers.stopped_count}/${d.containers.count} stopped)`, ` Volumes: ${formatBytes(d.volumes.bytes)} (${formatBytes(d.volumes.reclaimable_bytes)} reclaimable, ${d.volumes.unused_count}/${d.volumes.count} unused)`, ` Build cache: ${formatBytes(d.build_cache.bytes)} (${formatBytes(d.build_cache.reclaimable_bytes)} reclaimable, ${d.build_cache.count} entries)`);
|
|
1739
|
+
}
|
|
1740
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1741
|
+
`) }] };
|
|
1742
|
+
});
|
|
1743
|
+
server.registerTool("moor_drain_status", {
|
|
1744
|
+
title: "Drain Status",
|
|
1745
|
+
description: "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."
|
|
1746
|
+
}, async () => {
|
|
1747
|
+
const res = await apiResponse.get("/api/server/drain");
|
|
1748
|
+
if (!res.ok)
|
|
1749
|
+
throw new Error(`drain status failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1750
|
+
const s = await res.json();
|
|
1751
|
+
const lines = renderDrainState(s.state);
|
|
1752
|
+
lines.push(`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}`);
|
|
1753
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1754
|
+
`) }] };
|
|
1755
|
+
});
|
|
1756
|
+
server.registerTool("moor_drain_enable", {
|
|
1757
|
+
title: "Enable Drain Mode",
|
|
1758
|
+
description: "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 \u2014 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 \u2014 when set, the drain auto-clears on boot if the running moor version matches.",
|
|
1759
|
+
inputSchema: z7.object({
|
|
1760
|
+
reason: z7.string().optional().describe("Freeform reason shown in every refusal response (e.g. 'preparing for 0.34 upgrade')."),
|
|
1761
|
+
ttl_minutes: z7.number().optional().describe("Auto-clear after this many minutes. Default 30. Clamped to [0.05 min, 7 days]."),
|
|
1762
|
+
clear_after_version: z7.string().optional().describe("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.")
|
|
1763
|
+
})
|
|
1764
|
+
}, async ({ reason, ttl_minutes, clear_after_version }) => {
|
|
1765
|
+
const res = await apiResponse.post("/api/server/drain/enable", {
|
|
1766
|
+
reason,
|
|
1767
|
+
ttl_minutes,
|
|
1768
|
+
clear_after_version
|
|
1769
|
+
});
|
|
1770
|
+
if (!res.ok)
|
|
1771
|
+
throw new Error(`drain enable failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1772
|
+
const s = await res.json();
|
|
1773
|
+
return { content: [{ type: "text", text: renderDrainState(s.state).join(`
|
|
1774
|
+
`) }] };
|
|
1775
|
+
});
|
|
1776
|
+
server.registerTool("moor_drain_disable", {
|
|
1777
|
+
title: "Disable Drain Mode",
|
|
1778
|
+
description: "Explicit operator action to clear drain immediately. Does not kill or restart anything \u2014 just removes the gate so new builds/deploys/execs/cron triggers/terminal upgrades succeed again."
|
|
1779
|
+
}, async () => {
|
|
1780
|
+
const res = await apiResponse.post("/api/server/drain/disable", {});
|
|
1781
|
+
if (!res.ok)
|
|
1782
|
+
throw new Error(`drain disable failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1783
|
+
const s = await res.json();
|
|
1784
|
+
return { content: [{ type: "text", text: renderDrainState(s.state).join(`
|
|
1785
|
+
`) }] };
|
|
1786
|
+
});
|
|
1787
|
+
server.registerTool("moor_db_backup", {
|
|
1788
|
+
title: "DB Backup (snapshot)",
|
|
1789
|
+
description: "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."
|
|
1790
|
+
}, async () => {
|
|
1791
|
+
const res = await apiResponse.post("/api/server/backup", {});
|
|
1792
|
+
if (!res.ok)
|
|
1793
|
+
throw new Error(`db backup failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1794
|
+
const r = await res.json();
|
|
1795
|
+
const mb = (r.sizeBytes / (1024 * 1024)).toFixed(2);
|
|
1796
|
+
return {
|
|
1797
|
+
content: [
|
|
1798
|
+
{
|
|
1799
|
+
type: "text",
|
|
1800
|
+
text: `Snapshot written: ${r.path}
|
|
1801
|
+
size: ${r.sizeBytes}B (${mb} MB)
|
|
1802
|
+
duration: ${r.durationMs}ms`
|
|
1803
|
+
}
|
|
1804
|
+
]
|
|
1805
|
+
};
|
|
1806
|
+
});
|
|
1807
|
+
server.registerTool("moor_project_stats", {
|
|
1808
|
+
title: "Project Container Stats (live)",
|
|
1809
|
+
description: "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 \u2014 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).",
|
|
1810
|
+
inputSchema: z7.object({
|
|
1811
|
+
project: z7.string().describe("Project name or ID")
|
|
1812
|
+
})
|
|
1813
|
+
}, async ({ project }) => {
|
|
1814
|
+
const p = await resolveProject(project);
|
|
1815
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/container-stats`);
|
|
1816
|
+
if (!res.ok)
|
|
1817
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1818
|
+
const s = await res.json();
|
|
1819
|
+
if (!s.running) {
|
|
1820
|
+
return {
|
|
1821
|
+
content: [{ type: "text", text: `${p.name}: not running (zeroed counters returned).` }]
|
|
1822
|
+
};
|
|
1823
|
+
}
|
|
1824
|
+
const memLimit = s.memory_limit_bytes > 0 ? formatBytes(s.memory_limit_bytes) : "unlimited";
|
|
1825
|
+
const lines = [
|
|
1826
|
+
`${p.name}: CPU ${s.cpu_percent}% | Memory ${formatBytes(s.memory_bytes)} / ${memLimit} (${s.memory_percent}%) | PIDs ${s.pids}`,
|
|
1827
|
+
`Network: rx ${formatBytes(s.network_rx_bytes)} / tx ${formatBytes(s.network_tx_bytes)}`,
|
|
1828
|
+
`Block I/O: read ${formatBytes(s.block_read_bytes)} / write ${formatBytes(s.block_write_bytes)}`
|
|
1829
|
+
];
|
|
1830
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1831
|
+
`) }] };
|
|
1832
|
+
});
|
|
1833
|
+
server.registerTool("moor_project_history", {
|
|
1834
|
+
title: "Project History (stored)",
|
|
1835
|
+
description: "Stored resource history + lifecycle events for one project over a time window \u2014 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.",
|
|
1836
|
+
inputSchema: z7.object({
|
|
1837
|
+
project: z7.string().describe("Project name or ID"),
|
|
1838
|
+
hours: z7.number().optional().describe("Lookback window in hours (default 24). Ignored if from_ms/to_ms are given."),
|
|
1839
|
+
from_ms: z7.number().optional().describe("Window start, epoch milliseconds"),
|
|
1840
|
+
to_ms: z7.number().optional().describe("Window end, epoch milliseconds")
|
|
1841
|
+
})
|
|
1842
|
+
}, async ({ project, hours, from_ms, to_ms }) => {
|
|
1843
|
+
const p = await resolveProject(project);
|
|
1844
|
+
const to = to_ms ?? Date.now();
|
|
1845
|
+
const from = from_ms ?? to - (hours ?? 24) * 3600000;
|
|
1846
|
+
const res = await apiResponse.get(`/api/projects/${p.id}/stats/history?from=${from}&to=${to}`);
|
|
1847
|
+
if (!res.ok)
|
|
1848
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1849
|
+
const h = await res.json();
|
|
1850
|
+
const s = h.summary;
|
|
1851
|
+
const windowH = Math.round((h.to_ms - h.from_ms) / 3600000 * 10) / 10;
|
|
1852
|
+
const lines = [
|
|
1853
|
+
`${p.name} history \u2014 window ~${windowH}h${s.has_gap ? " [\u26A0 event gap recorded: events may be incomplete]" : ""}`,
|
|
1854
|
+
`Samples: ${s.sample_count} total, ${s.running_sample_count} running`,
|
|
1855
|
+
`CPU: avg ${s.cpu_percent_avg ?? "n/a"}% / max ${s.cpu_percent_max ?? "n/a"}%`,
|
|
1856
|
+
`Memory: max ${s.mem_bytes_max !== null ? formatBytes(s.mem_bytes_max) : "n/a"}`,
|
|
1857
|
+
`Network: in ${formatBytes(s.net_rx_bytes_total)} / out ${formatBytes(s.net_tx_bytes_total)}`
|
|
1858
|
+
];
|
|
1859
|
+
const counts = Object.entries(s.event_counts);
|
|
1860
|
+
if (counts.length > 0) {
|
|
1861
|
+
lines.push(`Events: ${counts.map(([a, n]) => `${a} ${n}`).join(", ")}`);
|
|
1862
|
+
}
|
|
1863
|
+
const recent = h.events.slice(-8);
|
|
1864
|
+
if (recent.length > 0) {
|
|
1865
|
+
lines.push("Recent events:");
|
|
1866
|
+
for (const e of recent) {
|
|
1867
|
+
lines.push(` ${new Date(e.occurred_at_ms).toISOString()} ${e.action} (${e.source})`);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
if (s.sample_count === 0 && h.events.length === 0) {
|
|
1871
|
+
lines.push("(no stored history in this window)");
|
|
1872
|
+
}
|
|
1873
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1874
|
+
`) }] };
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/tools/update.ts
|
|
1879
|
+
import { z as z8 } from "zod";
|
|
1880
|
+
|
|
1881
|
+
// src/update-audit-render.ts
|
|
1882
|
+
var DEFAULT_LOG_TAIL_BYTES = 4096;
|
|
1883
|
+
var MAX_LOG_TAIL_BYTES = 16384;
|
|
1884
|
+
function shortDigest(s) {
|
|
1885
|
+
if (!s)
|
|
1886
|
+
return "(none)";
|
|
1887
|
+
const m = s.match(/^(?:[^@]+@)?sha256:([0-9a-f]{64})$/);
|
|
1888
|
+
if (!m)
|
|
1889
|
+
return s;
|
|
1890
|
+
const hex = m[1];
|
|
1891
|
+
return `sha256:${hex.slice(0, 7)}\u2026${hex.slice(-7)}`;
|
|
1892
|
+
}
|
|
1893
|
+
function fmtDuration(ms) {
|
|
1894
|
+
if (ms == null)
|
|
1895
|
+
return "\u2014";
|
|
1896
|
+
if (ms < 1000)
|
|
1897
|
+
return `${ms}ms`;
|
|
1898
|
+
const s = Math.round(ms / 1000);
|
|
1899
|
+
if (s < 60)
|
|
1900
|
+
return `${s}s`;
|
|
1901
|
+
const m = Math.floor(s / 60);
|
|
1902
|
+
const rem = s % 60;
|
|
1903
|
+
return `${m}m${rem}s`;
|
|
1904
|
+
}
|
|
1905
|
+
function tailLog(s, maxBytes) {
|
|
1906
|
+
if (s == null)
|
|
1907
|
+
return "";
|
|
1908
|
+
if (maxBytes <= 0) {
|
|
1909
|
+
const enc2 = new TextEncoder;
|
|
1910
|
+
return `[...${enc2.encode(s).length} bytes elided (tail_bytes=0)]`;
|
|
1911
|
+
}
|
|
1912
|
+
const enc = new TextEncoder;
|
|
1913
|
+
const bytes = enc.encode(s);
|
|
1914
|
+
if (bytes.length <= maxBytes)
|
|
1915
|
+
return s;
|
|
1916
|
+
const dropped = bytes.length - maxBytes;
|
|
1917
|
+
const tail = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(dropped));
|
|
1918
|
+
return `[...${dropped} earlier bytes truncated]
|
|
1919
|
+
${tail}`;
|
|
1920
|
+
}
|
|
1921
|
+
function renderAuditRow(row, opts = {}) {
|
|
1922
|
+
const tail = Math.min(opts.tail_bytes ?? DEFAULT_LOG_TAIL_BYTES, MAX_LOG_TAIL_BYTES);
|
|
1923
|
+
const lines = [
|
|
1924
|
+
`audit_id=${row.id} state=${row.state} duration=${fmtDuration(row.duration_ms)} started=${row.started_at}`,
|
|
1925
|
+
` from: ${shortDigest(row.from_digest)} \u2192 to: ${shortDigest(row.to_digest)}`
|
|
1926
|
+
];
|
|
1927
|
+
if (row.prev_image_id)
|
|
1928
|
+
lines.push(` prev_image_id: ${shortDigest(row.prev_image_id)}`);
|
|
1929
|
+
if (row.backup_path)
|
|
1930
|
+
lines.push(` backup: ${row.backup_path}`);
|
|
1931
|
+
if (row.error_log)
|
|
1932
|
+
lines.push(` error_log: ${tailLog(row.error_log, tail)}`);
|
|
1933
|
+
if (row.rollback_error) {
|
|
1934
|
+
lines.push(` rollback_error: ${tailLog(row.rollback_error, tail)}`);
|
|
1935
|
+
}
|
|
1936
|
+
return lines.join(`
|
|
1937
|
+
`);
|
|
1938
|
+
}
|
|
1939
|
+
function renderAuditList(rows, opts = {}) {
|
|
1940
|
+
if (rows.length === 0) {
|
|
1941
|
+
return "no update attempts recorded yet. Run moor_update_apply to start one.";
|
|
1942
|
+
}
|
|
1943
|
+
return rows.map((r) => renderAuditRow(r, opts)).join(`
|
|
1944
|
+
|
|
1945
|
+
`);
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
// src/tools/update.ts
|
|
1949
|
+
function registerUpdateTools(server, client2) {
|
|
1950
|
+
const { apiResponse, readErrorMessage } = client2;
|
|
1951
|
+
server.registerTool("moor_update_status", {
|
|
1952
|
+
title: "Update status / preflight",
|
|
1953
|
+
description: "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 \u2014 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 \u2014 does NOT perform any update."
|
|
1954
|
+
}, async () => {
|
|
1955
|
+
const res = await apiResponse.get("/api/server/update-status");
|
|
1956
|
+
if (!res.ok)
|
|
1957
|
+
throw new Error(`Failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
1958
|
+
const s = await res.json();
|
|
1959
|
+
const lines = [];
|
|
1960
|
+
lines.push(`moor ${s.current.version} (image_id: ${s.current.image_id ?? "unknown"})`);
|
|
1961
|
+
lines.push(`repo_digest: ${s.current.repo_digest ?? "(none \u2014 locally built or stale inspect)"}`);
|
|
1962
|
+
if (s.available.update_available === true) {
|
|
1963
|
+
lines.push(`update AVAILABLE \u2192 latest: ${s.available.latest_digest}`);
|
|
1964
|
+
} else if (s.available.update_available === false) {
|
|
1965
|
+
lines.push(`up to date (latest: ${s.available.latest_digest})`);
|
|
1966
|
+
} else {
|
|
1967
|
+
const why = s.available.registry_error ? `registry unreachable: ${s.available.registry_error}` : s.current.repo_digest === null ? "no local repo_digest (built locally?)" : "comparison unavailable";
|
|
1968
|
+
lines.push(`update availability unknown \u2014 ${why}`);
|
|
1969
|
+
}
|
|
1970
|
+
lines.push(`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}`);
|
|
1971
|
+
if (s.safe_to_update) {
|
|
1972
|
+
lines.push("safe_to_update: YES");
|
|
1973
|
+
} else {
|
|
1974
|
+
lines.push("safe_to_update: NO");
|
|
1975
|
+
for (const r of s.unsafe_reasons)
|
|
1976
|
+
lines.push(` - ${r}`);
|
|
1977
|
+
}
|
|
1978
|
+
lines.push(`recommended: ${s.recommended_command}`);
|
|
1979
|
+
return { content: [{ type: "text", text: lines.join(`
|
|
1980
|
+
`) }] };
|
|
1981
|
+
});
|
|
1982
|
+
server.registerTool("moor_update_apply", {
|
|
1983
|
+
title: "Apply moor update (transient respawner)",
|
|
1984
|
+
description: "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 \u2014 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.",
|
|
1985
|
+
inputSchema: z8.object({
|
|
1986
|
+
target_digest: z8.string().regex(/^sha256:[0-9a-f]{64}$/, "target_digest must be sha256:<64 hex>").optional().describe("Pin the update to this exact image digest. Default: the registry's current `:latest` digest from moor_update_status."),
|
|
1987
|
+
bypass: z8.array(z8.enum(["active_work", "unknown_digest"])).optional().describe("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.")
|
|
1988
|
+
})
|
|
1989
|
+
}, async (input) => {
|
|
1990
|
+
const res = await apiResponse.post("/api/server/update/apply", input ?? {});
|
|
1991
|
+
if (res.status === 202) {
|
|
1992
|
+
const { audit_id } = await res.json();
|
|
1993
|
+
return {
|
|
1994
|
+
content: [
|
|
1995
|
+
{
|
|
1996
|
+
type: "text",
|
|
1997
|
+
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:
|
|
1998
|
+
- success (new image healthy)
|
|
1999
|
+
- failed (pull failed before moor was replaced)
|
|
2000
|
+
- rolled_back (up/health failed; automatic rollback succeeded; drain stays on)
|
|
2001
|
+
- rollback_failed (up/health failed AND rollback failed; manual recovery)
|
|
2002
|
+
- crashed (no marker after 30-min grace; respawner died)
|
|
2003
|
+
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).`
|
|
2004
|
+
}
|
|
2005
|
+
]
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
const body = await res.json().catch(() => ({}));
|
|
2009
|
+
const code = body.error?.code ?? `HTTP ${res.status}`;
|
|
2010
|
+
const reason = body.error?.reason ?? "no detail";
|
|
2011
|
+
const extra = body.error?.unsafe_reasons ? `
|
|
2012
|
+
unsafe_reasons:
|
|
2013
|
+
- ${body.error.unsafe_reasons.join(`
|
|
2014
|
+
- `)}` : "";
|
|
2015
|
+
throw new Error(`moor_update_apply refused [${code}]: ${reason}${extra}`);
|
|
2016
|
+
});
|
|
2017
|
+
server.registerTool("moor_update_audit", {
|
|
2018
|
+
title: "Update history (audit log)",
|
|
2019
|
+
description: "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.",
|
|
2020
|
+
inputSchema: z8.object({
|
|
2021
|
+
limit: z8.number().int().min(1).max(200).optional().describe("How many most-recent attempts to return. Default 20, max 200."),
|
|
2022
|
+
tail_bytes: z8.number().int().min(0).max(MAX_LOG_TAIL_BYTES).optional().describe("Max bytes of error_log and rollback_error returned inline per row. Default 4096 (4 KiB). 0 to omit log bodies entirely; 16384 max.")
|
|
2023
|
+
})
|
|
2024
|
+
}, async ({ limit, tail_bytes }) => {
|
|
2025
|
+
const qs = new URLSearchParams;
|
|
2026
|
+
if (limit !== undefined)
|
|
2027
|
+
qs.set("limit", String(limit));
|
|
2028
|
+
const path = qs.toString() ? `/api/server/update/audit?${qs.toString()}` : "/api/server/update/audit";
|
|
2029
|
+
const res = await apiResponse.get(path);
|
|
2030
|
+
if (!res.ok)
|
|
2031
|
+
throw new Error(`update audit failed: ${res.status} ${await readErrorMessage(res)}`);
|
|
2032
|
+
const { rows } = await res.json();
|
|
2033
|
+
return {
|
|
2034
|
+
content: [{ type: "text", text: renderAuditList(rows, { tail_bytes }) }]
|
|
2035
|
+
};
|
|
2036
|
+
});
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/index.ts
|
|
2040
|
+
var config = {
|
|
2041
|
+
baseUrl: (process.env.MOOR_URL || "").replace(/\/$/, ""),
|
|
2042
|
+
apiKey: process.env.MOOR_API_KEY || ""
|
|
2043
|
+
};
|
|
2044
|
+
if (!config.baseUrl || !config.apiKey) {
|
|
2045
|
+
console.error("MOOR_URL and MOOR_API_KEY environment variables are required");
|
|
2046
|
+
process.exit(1);
|
|
2047
|
+
}
|
|
2048
|
+
async function rawResponseRequest(callClient) {
|
|
2049
|
+
let rawResponse;
|
|
2050
|
+
const fetchRawResponse = async (input, init) => {
|
|
2051
|
+
rawResponse = await globalThis.fetch(input, init);
|
|
2052
|
+
return new Response(null, { status: 204 });
|
|
2053
|
+
};
|
|
2054
|
+
const client2 = createMoorApiClient({
|
|
2055
|
+
...config,
|
|
2056
|
+
fetch: fetchRawResponse
|
|
2057
|
+
});
|
|
2058
|
+
await callClient(client2);
|
|
2059
|
+
if (!rawResponse)
|
|
2060
|
+
throw new Error("No response received");
|
|
2061
|
+
return rawResponse;
|
|
2062
|
+
}
|
|
2063
|
+
{
|
|
2064
|
+
let probeRes;
|
|
2065
|
+
try {
|
|
2066
|
+
probeRes = await rawResponseRequest((client2) => client2.get("/api/projects", { signal: AbortSignal.timeout(5000) }));
|
|
2067
|
+
} catch (e) {
|
|
2068
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2069
|
+
console.error(`Cannot reach moor at ${config.baseUrl}: ${msg}`);
|
|
2070
|
+
console.error("Check MOOR_URL and that moor is running (and tunneled, if remote).");
|
|
2071
|
+
process.exit(1);
|
|
2072
|
+
}
|
|
2073
|
+
if (probeRes.status === 401) {
|
|
2074
|
+
console.error(`Authentication failed against ${config.baseUrl}.`);
|
|
2075
|
+
console.error("Check MOOR_API_KEY matches the value in moor's .env on the server.");
|
|
2076
|
+
process.exit(1);
|
|
2077
|
+
}
|
|
2078
|
+
if (probeRes.status === 503) {
|
|
2079
|
+
console.error(`moor at ${config.baseUrl} returned 503.`);
|
|
2080
|
+
console.error("Likely cause: MOOR_INITIAL_PASSWORD not configured. Set it and restart moor.");
|
|
2081
|
+
process.exit(1);
|
|
2082
|
+
}
|
|
2083
|
+
if (!probeRes.ok) {
|
|
2084
|
+
console.error(`moor at ${config.baseUrl} returned ${probeRes.status} on startup probe.`);
|
|
2085
|
+
process.exit(1);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
var apiResponse = {
|
|
2089
|
+
get: (path) => rawResponseRequest((client2) => client2.get(path)),
|
|
2090
|
+
post: (path, body) => rawResponseRequest((client2) => client2.post(path, body)),
|
|
2091
|
+
put: (path, body) => rawResponseRequest((client2) => client2.put(path, body)),
|
|
2092
|
+
delete: (path) => rawResponseRequest((client2) => client2.delete(path))
|
|
2093
|
+
};
|
|
2094
|
+
async function readErrorMessage(res) {
|
|
2095
|
+
const text = await res.text();
|
|
2096
|
+
return parseErrorMessage(text, res.status);
|
|
2097
|
+
}
|
|
2098
|
+
async function resolveProject(name) {
|
|
2099
|
+
const res = await apiResponse.get("/api/projects");
|
|
2100
|
+
if (!res.ok)
|
|
2101
|
+
throw new Error(`Failed to list projects: ${res.status}`);
|
|
2102
|
+
const projects = await res.json();
|
|
2103
|
+
const match = projects.find((p) => p.name === name || String(p.id) === name);
|
|
2104
|
+
if (!match)
|
|
2105
|
+
throw new Error(`Project "${name}" not found`);
|
|
2106
|
+
return match;
|
|
2107
|
+
}
|
|
2108
|
+
async function readSSE(res) {
|
|
2109
|
+
const reader = res.body?.getReader();
|
|
2110
|
+
if (!reader)
|
|
2111
|
+
return { logs: "" };
|
|
2112
|
+
const decoder = new TextDecoder;
|
|
2113
|
+
let buffer = "";
|
|
2114
|
+
let currentEvent = "";
|
|
2115
|
+
let logs = "";
|
|
2116
|
+
let error;
|
|
2117
|
+
let structuredError;
|
|
2118
|
+
while (true) {
|
|
2119
|
+
const { done, value } = await reader.read();
|
|
2120
|
+
if (done)
|
|
2121
|
+
break;
|
|
2122
|
+
buffer += decoder.decode(value, { stream: true });
|
|
2123
|
+
const lines = buffer.split(`
|
|
2124
|
+
`);
|
|
2125
|
+
buffer = lines.pop() || "";
|
|
2126
|
+
for (const line of lines) {
|
|
2127
|
+
if (line.startsWith("event: ")) {
|
|
2128
|
+
currentEvent = line.slice(7).trim();
|
|
2129
|
+
} else if (line.startsWith("data: ")) {
|
|
2130
|
+
const data = JSON.parse(line.slice(6));
|
|
2131
|
+
if (currentEvent === "log")
|
|
2132
|
+
logs += data;
|
|
2133
|
+
else if (currentEvent === "error")
|
|
2134
|
+
error = data;
|
|
2135
|
+
else if (currentEvent === "structured-error")
|
|
2136
|
+
structuredError = data;
|
|
2137
|
+
currentEvent = "";
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
return { logs, error, structuredError };
|
|
2142
|
+
}
|
|
2143
|
+
var server = new McpServer({
|
|
2144
|
+
name: "moor",
|
|
2145
|
+
version: "0.1.0"
|
|
2146
|
+
});
|
|
2147
|
+
var client2 = { apiResponse, resolveProject, readErrorMessage, readSSE };
|
|
2148
|
+
registerProjectTools(server, client2);
|
|
2149
|
+
registerRunTools(server, client2);
|
|
2150
|
+
registerExecTools(server, client2);
|
|
2151
|
+
registerEnvTools(server, client2);
|
|
2152
|
+
registerCredentialTools(server, client2);
|
|
2153
|
+
registerServerTools(server, client2);
|
|
2154
|
+
registerUpdateTools(server, client2);
|
|
2155
|
+
registerCleanupTools(server, client2);
|
|
2156
|
+
var transport = new StdioServerTransport;
|
|
2157
|
+
await server.connect(transport);
|