@moor-sh/mcp 0.1.0 → 0.2.1
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 -0
- package/package.json +3 -3
- package/src/index.ts +435 -0
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# @moor-sh/mcp
|
|
2
|
+
|
|
3
|
+
MCP server for [moor](https://github.com/caiopizzol/moor) - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects through standard MCP tools. Talks to moor's HTTP API; no repo clone needed.
|
|
4
|
+
|
|
5
|
+
Requires [Bun](https://bun.sh) on the machine running the MCP client. `bunx` fetches and runs `@moor-sh/mcp` directly as the client's MCP subprocess.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
The easiest path is the `moor mcp config` subcommand from [`@moor-sh/cli`](https://www.npmjs.com/package/@moor-sh/cli):
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bunx @moor-sh/cli mcp config --client claude # or claude-code, codex
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
It prints a config snippet you paste into your MCP client. Or configure manually:
|
|
16
|
+
|
|
17
|
+
### Claude Code (`~/.claude.json`)
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"moor": {
|
|
23
|
+
"command": "bunx",
|
|
24
|
+
"args": ["@moor-sh/mcp"],
|
|
25
|
+
"env": {
|
|
26
|
+
"MOOR_URL": "http://127.0.0.1:8080",
|
|
27
|
+
"MOOR_API_KEY": "your-api-key"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Codex (`~/.codex/config.toml`)
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
[mcp_servers.moor]
|
|
38
|
+
command = "bunx"
|
|
39
|
+
args = ["@moor-sh/mcp"]
|
|
40
|
+
|
|
41
|
+
[mcp_servers.moor.env]
|
|
42
|
+
MOOR_URL = "http://127.0.0.1:8080"
|
|
43
|
+
MOOR_API_KEY = "your-api-key"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
For a moor on the same machine as the client, change `MOOR_URL` to `http://localhost:3000`.
|
|
47
|
+
|
|
48
|
+
## SSH tunnel for remote moor
|
|
49
|
+
|
|
50
|
+
By default moor's admin is bound to `127.0.0.1:3000` on the server. The MCP client connects to whatever `MOOR_URL` resolves to on the client machine, so for a remote moor, open a tunnel from your laptop:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
ssh -fNL 8080:127.0.0.1:3000 your-server
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`MOOR_URL=http://127.0.0.1:8080` matches the laptop side of that tunnel. The tunnel must stay up while the MCP client is in use. For a tunnel that survives sleep and reboots, see the [self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md#persistent-tunnel).
|
|
57
|
+
|
|
58
|
+
## API key
|
|
59
|
+
|
|
60
|
+
`MOOR_API_KEY` grants admin-equivalent control of the moor host. See the [self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md#api-keys) for how to generate, verify, and rotate it.
|
|
61
|
+
|
|
62
|
+
## Smoke test
|
|
63
|
+
|
|
64
|
+
Before relying on the integration:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
MOOR_URL=http://127.0.0.1:8080 MOOR_API_KEY=your-api-key bunx @moor-sh/mcp < /dev/null
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Exit 0 with no output means the MCP connected, authenticated, and shut down cleanly when stdin closed. Any stderr line plus non-zero exit tells you what's wrong:
|
|
71
|
+
|
|
72
|
+
- `Cannot reach moor at ...` - URL unreachable or tunnel is down.
|
|
73
|
+
- `Authentication failed` - `MOOR_API_KEY` doesn't match the server.
|
|
74
|
+
- `moor at ... returned 503` - admin password not configured on the moor server.
|
|
75
|
+
|
|
76
|
+
## Tools
|
|
77
|
+
|
|
78
|
+
The MCP server exposes:
|
|
79
|
+
|
|
80
|
+
- `moor_status` - list all projects with status, source, and domain
|
|
81
|
+
- `moor_logs` - get recent container logs for a project (with tail length)
|
|
82
|
+
- `moor_rebuild` - rebuild a project from source
|
|
83
|
+
- `moor_restart` - stop and start a project's container
|
|
84
|
+
- `moor_exec` - run a command inside a project's container
|
|
85
|
+
- `moor_env_list` - list environment variables for a project
|
|
86
|
+
- `moor_env_set` - set environment variables and restart
|
|
87
|
+
- `moor_stats` - host CPU / memory / disk / container counts
|
|
88
|
+
|
|
89
|
+
## Transport
|
|
90
|
+
|
|
91
|
+
Stdio only. The MCP client launches `bunx @moor-sh/mcp` as a subprocess and talks over stdin/stdout. HTTP transport is [tracked](https://github.com/caiopizzol/moor/issues/17) but not yet shipped.
|
|
92
|
+
|
|
93
|
+
## Links
|
|
94
|
+
|
|
95
|
+
- [moor repo](https://github.com/caiopizzol/moor) - main project
|
|
96
|
+
- [Self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md) - first boot, API keys, admin domain
|
|
97
|
+
- [`@moor-sh/cli`](https://www.npmjs.com/package/@moor-sh/cli) - command-line interface with `moor mcp config` helper
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT.
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moor-sh/mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "MCP server for moor - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects via the moor HTTP API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "https://github.com/caiopizzol/moor.git",
|
|
8
|
+
"url": "git+https://github.com/caiopizzol/moor.git",
|
|
9
9
|
"directory": "packages/mcp"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/caiopizzol/moor
|
|
11
|
+
"homepage": "https://github.com/caiopizzol/moor/tree/main/packages/mcp",
|
|
12
12
|
"bugs": "https://github.com/caiopizzol/moor/issues",
|
|
13
13
|
"keywords": [
|
|
14
14
|
"moor",
|
package/src/index.ts
CHANGED
|
@@ -73,6 +73,13 @@ async function apiPut(path: string, body: unknown) {
|
|
|
73
73
|
});
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
async function apiDelete(path: string) {
|
|
77
|
+
return fetch(`${baseUrl}${path}`, {
|
|
78
|
+
method: "DELETE",
|
|
79
|
+
headers: headers(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
type Project = {
|
|
77
84
|
id: number;
|
|
78
85
|
name: string;
|
|
@@ -127,6 +134,106 @@ async function readSSE(res: Response): Promise<{ logs: string; error?: string }>
|
|
|
127
134
|
return { logs, error };
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
// --- Validators ---
|
|
138
|
+
|
|
139
|
+
/** Validate that a string is a github.com URL. Throws with a clear message on failure.
|
|
140
|
+
* Stricter than apps/api/routes/docker.ts:validateGithubUrl, which accepts any host
|
|
141
|
+
* ending in "github.com" (so "evilgithub.com" slips through). MCP rejects that and
|
|
142
|
+
* surfaces the error at create/update time, not at first build/run. */
|
|
143
|
+
function validateGithubUrl(url: string): void {
|
|
144
|
+
let host: string;
|
|
145
|
+
try {
|
|
146
|
+
host = new URL(url).hostname;
|
|
147
|
+
} catch {
|
|
148
|
+
throw new Error(`github_url is not a valid URL: ${url}`);
|
|
149
|
+
}
|
|
150
|
+
if (host !== "github.com" && !host.endsWith(".github.com")) {
|
|
151
|
+
throw new Error(`github_url must be a github.com URL (got hostname "${host}")`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Validate a 5-field crontab schedule against what apps/api/cron.ts can actually execute.
|
|
156
|
+
* Stricter than the scheduler's permissive parser: the scheduler silently never fires
|
|
157
|
+
* on bad input, so MCP rejects up-front. Returns an error string or null. */
|
|
158
|
+
const CRON_FIELDS: ReadonlyArray<{ name: string; min: number; max: number }> = [
|
|
159
|
+
{ name: "minute", min: 0, max: 59 },
|
|
160
|
+
{ name: "hour", min: 0, max: 23 },
|
|
161
|
+
{ name: "day-of-month", min: 1, max: 31 },
|
|
162
|
+
{ name: "month", min: 1, max: 12 },
|
|
163
|
+
{ name: "day-of-week", min: 0, max: 6 }, // 0=Sunday; scheduler does not translate 7
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
// Whitelist each comma-separated part against one of the canonical forms below.
|
|
167
|
+
// Anything else (empty parts, leading "-", bare "N/S", "/S", stray characters) is
|
|
168
|
+
// rejected. The scheduler at apps/api/cron.ts silently ignores or mis-parses these
|
|
169
|
+
// inputs, so the validator must be strict where the scheduler is loose.
|
|
170
|
+
const CRON_PART_PATTERNS = [
|
|
171
|
+
/^\*$/, // *
|
|
172
|
+
/^(\d+)$/, // N
|
|
173
|
+
/^(\d+)-(\d+)$/, // A-B
|
|
174
|
+
/^\*\/(\d+)$/, // */S
|
|
175
|
+
/^(\d+)-(\d+)\/(\d+)$/, // A-B/S
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
function validateCronField(field: string, min: number, max: number, name: string): string | null {
|
|
179
|
+
if (field === "*") return null;
|
|
180
|
+
if (/[?LW#]/i.test(field)) return `${name}: ?, L, W, # are not supported`;
|
|
181
|
+
if (/[a-zA-Z]/.test(field))
|
|
182
|
+
return `${name}: month/day names are not supported, use numeric values`;
|
|
183
|
+
|
|
184
|
+
for (const part of field.split(",")) {
|
|
185
|
+
if (part === "") return `${name}: empty list element`;
|
|
186
|
+
|
|
187
|
+
const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null);
|
|
188
|
+
if (!match) return `${name}: invalid expression "${part}"`;
|
|
189
|
+
|
|
190
|
+
// Validate captured numbers against per-field bounds and step positivity.
|
|
191
|
+
// Capture layout depends on which pattern matched, identified by length.
|
|
192
|
+
const groups = match.slice(1);
|
|
193
|
+
if (groups.length === 1 && match[0].startsWith("*/")) {
|
|
194
|
+
// */S
|
|
195
|
+
const step = Number(groups[0]);
|
|
196
|
+
if (step <= 0) return `${name}: step must be a positive integer (got "${groups[0]}")`;
|
|
197
|
+
} else if (groups.length === 1) {
|
|
198
|
+
// N
|
|
199
|
+
const n = Number(groups[0]);
|
|
200
|
+
if (n < min || n > max) return `${name}: ${n} out of bounds [${min}-${max}]`;
|
|
201
|
+
} else if (groups.length === 2) {
|
|
202
|
+
// A-B
|
|
203
|
+
const a = Number(groups[0]);
|
|
204
|
+
const b = Number(groups[1]);
|
|
205
|
+
if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
|
|
206
|
+
if (a > b) return `${name}: range ${a}-${b} is descending`;
|
|
207
|
+
} else if (groups.length === 3) {
|
|
208
|
+
// A-B/S
|
|
209
|
+
const a = Number(groups[0]);
|
|
210
|
+
const b = Number(groups[1]);
|
|
211
|
+
const step = Number(groups[2]);
|
|
212
|
+
if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
|
|
213
|
+
if (a > b) return `${name}: range ${a}-${b} is descending`;
|
|
214
|
+
if (step <= 0) return `${name}: step must be a positive integer (got "${groups[2]}")`;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function validateCronSchedule(schedule: string): string | null {
|
|
221
|
+
const parts = schedule.trim().split(/\s+/);
|
|
222
|
+
if (parts.length !== 5) {
|
|
223
|
+
return `schedule must have exactly 5 space-separated fields (got ${parts.length})`;
|
|
224
|
+
}
|
|
225
|
+
for (let i = 0; i < 5; i++) {
|
|
226
|
+
const err = validateCronField(
|
|
227
|
+
parts[i],
|
|
228
|
+
CRON_FIELDS[i].min,
|
|
229
|
+
CRON_FIELDS[i].max,
|
|
230
|
+
CRON_FIELDS[i].name,
|
|
231
|
+
);
|
|
232
|
+
if (err) return err;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
130
237
|
// --- MCP Server ---
|
|
131
238
|
|
|
132
239
|
const server = new McpServer({
|
|
@@ -340,6 +447,334 @@ server.registerTool(
|
|
|
340
447
|
},
|
|
341
448
|
);
|
|
342
449
|
|
|
450
|
+
server.registerTool(
|
|
451
|
+
"moor_project_get",
|
|
452
|
+
{
|
|
453
|
+
title: "Get Project",
|
|
454
|
+
description:
|
|
455
|
+
"Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).",
|
|
456
|
+
inputSchema: z.object({
|
|
457
|
+
project: z.string().describe("Project name or ID"),
|
|
458
|
+
}),
|
|
459
|
+
},
|
|
460
|
+
async ({ project }) => {
|
|
461
|
+
const p = await resolveProject(project);
|
|
462
|
+
return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] };
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
server.registerTool(
|
|
467
|
+
"moor_project_create",
|
|
468
|
+
{
|
|
469
|
+
title: "Create Project",
|
|
470
|
+
description:
|
|
471
|
+
"Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild (or moor_deploy in a future release) to bring it up.",
|
|
472
|
+
inputSchema: z.object({
|
|
473
|
+
name: z
|
|
474
|
+
.string()
|
|
475
|
+
.regex(
|
|
476
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
|
477
|
+
"name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -",
|
|
478
|
+
)
|
|
479
|
+
.describe("Project name (used as the container name suffix: moor-<name>)"),
|
|
480
|
+
github_url: z
|
|
481
|
+
.string()
|
|
482
|
+
.optional()
|
|
483
|
+
.describe("github.com URL; mutually exclusive with docker_image"),
|
|
484
|
+
docker_image: z
|
|
485
|
+
.string()
|
|
486
|
+
.optional()
|
|
487
|
+
.describe("Docker image reference (e.g. nginx:latest); mutually exclusive with github_url"),
|
|
488
|
+
branch: z.string().optional().describe("Git branch (default: main, for github_url projects)"),
|
|
489
|
+
dockerfile: z
|
|
490
|
+
.string()
|
|
491
|
+
.optional()
|
|
492
|
+
.describe("Dockerfile path within the repo (default: Dockerfile)"),
|
|
493
|
+
domain: z.string().optional().describe("Public domain to route to this container via Caddy"),
|
|
494
|
+
domain_port: z
|
|
495
|
+
.number()
|
|
496
|
+
.int()
|
|
497
|
+
.positive()
|
|
498
|
+
.optional()
|
|
499
|
+
.describe("Container port Caddy should forward to (required if domain is set)"),
|
|
500
|
+
restart_policy: z
|
|
501
|
+
.enum(["no", "on-failure", "always", "unless-stopped"])
|
|
502
|
+
.optional()
|
|
503
|
+
.describe("Docker restart policy (default: unless-stopped)"),
|
|
504
|
+
}),
|
|
505
|
+
},
|
|
506
|
+
async (input) => {
|
|
507
|
+
const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
|
|
508
|
+
if (sources !== 1) {
|
|
509
|
+
throw new Error("Provide exactly one of github_url or docker_image");
|
|
510
|
+
}
|
|
511
|
+
if (input.github_url) validateGithubUrl(input.github_url);
|
|
512
|
+
|
|
513
|
+
const res = await apiPost("/api/projects", input);
|
|
514
|
+
if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`);
|
|
515
|
+
const project = await res.json();
|
|
516
|
+
return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] };
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
server.registerTool(
|
|
521
|
+
"moor_project_update",
|
|
522
|
+
{
|
|
523
|
+
title: "Update Project",
|
|
524
|
+
description:
|
|
525
|
+
"Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately.",
|
|
526
|
+
inputSchema: z.object({
|
|
527
|
+
project: z.string().describe("Project name or ID to update"),
|
|
528
|
+
name: z
|
|
529
|
+
.string()
|
|
530
|
+
.regex(
|
|
531
|
+
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
|
532
|
+
"name must start alphanumeric; allowed: a-z A-Z 0-9 _ -",
|
|
533
|
+
)
|
|
534
|
+
.optional(),
|
|
535
|
+
github_url: z.string().optional(),
|
|
536
|
+
docker_image: z.string().optional(),
|
|
537
|
+
branch: z.string().optional(),
|
|
538
|
+
dockerfile: z.string().optional(),
|
|
539
|
+
domain: z.string().optional(),
|
|
540
|
+
domain_port: z.number().int().positive().optional(),
|
|
541
|
+
restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
|
|
542
|
+
}),
|
|
543
|
+
},
|
|
544
|
+
async (input) => {
|
|
545
|
+
const { project, ...updates } = input;
|
|
546
|
+
if (Object.keys(updates).length === 0) {
|
|
547
|
+
throw new Error("Provide at least one field to update");
|
|
548
|
+
}
|
|
549
|
+
if (updates.github_url && updates.docker_image) {
|
|
550
|
+
throw new Error("Cannot set both github_url and docker_image in the same update");
|
|
551
|
+
}
|
|
552
|
+
if (updates.github_url) validateGithubUrl(updates.github_url);
|
|
553
|
+
|
|
554
|
+
const p = await resolveProject(project);
|
|
555
|
+
const res = await apiPut(`/api/projects/${p.id}`, updates);
|
|
556
|
+
if (!res.ok) throw new Error(`Failed to update project: ${await res.text()}`);
|
|
557
|
+
const updated = await res.json();
|
|
558
|
+
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
server.registerTool(
|
|
563
|
+
"moor_project_delete",
|
|
564
|
+
{
|
|
565
|
+
title: "Delete Project",
|
|
566
|
+
description:
|
|
567
|
+
"Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible.",
|
|
568
|
+
inputSchema: z.object({
|
|
569
|
+
project: z.string().describe("Project name or ID to delete"),
|
|
570
|
+
confirm_name: z
|
|
571
|
+
.string()
|
|
572
|
+
.describe(
|
|
573
|
+
"Must equal the resolved project's name. Guards against deleting the wrong project.",
|
|
574
|
+
),
|
|
575
|
+
}),
|
|
576
|
+
},
|
|
577
|
+
async ({ project, confirm_name }) => {
|
|
578
|
+
const p = await resolveProject(project);
|
|
579
|
+
if (confirm_name !== p.name) {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
const res = await apiDelete(`/api/projects/${p.id}`);
|
|
585
|
+
if (!res.ok) throw new Error(`Failed to delete project: ${await res.text()}`);
|
|
586
|
+
return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
|
|
587
|
+
},
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
server.registerTool(
|
|
591
|
+
"moor_cron_create",
|
|
592
|
+
{
|
|
593
|
+
title: "Create Cron",
|
|
594
|
+
description:
|
|
595
|
+
"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.",
|
|
596
|
+
inputSchema: z.object({
|
|
597
|
+
project: z.string().describe("Project name or ID"),
|
|
598
|
+
name: z.string().min(1).describe("Human-readable name for the cron"),
|
|
599
|
+
schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'),
|
|
600
|
+
command: z.string().min(1).describe("Shell command to run inside the project's container"),
|
|
601
|
+
}),
|
|
602
|
+
},
|
|
603
|
+
async ({ project, name, schedule, command }) => {
|
|
604
|
+
const err = validateCronSchedule(schedule);
|
|
605
|
+
if (err) throw new Error(`Invalid schedule: ${err}`);
|
|
606
|
+
const p = await resolveProject(project);
|
|
607
|
+
const res = await apiPost(`/api/projects/${p.id}/crons`, { name, schedule, command });
|
|
608
|
+
if (!res.ok) throw new Error(`Failed to create cron: ${await res.text()}`);
|
|
609
|
+
const cron = await res.json();
|
|
610
|
+
return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
|
|
611
|
+
},
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
server.registerTool(
|
|
615
|
+
"moor_cron_update",
|
|
616
|
+
{
|
|
617
|
+
title: "Update Cron",
|
|
618
|
+
description: "Updates a cron's fields by id. Schedule is validated if provided.",
|
|
619
|
+
inputSchema: z.object({
|
|
620
|
+
cron_id: z.number().int().positive().describe("Cron ID"),
|
|
621
|
+
name: z.string().min(1).optional(),
|
|
622
|
+
schedule: z.string().optional(),
|
|
623
|
+
command: z.string().min(1).optional(),
|
|
624
|
+
enabled: z.boolean().optional().describe("Enable or disable the cron"),
|
|
625
|
+
}),
|
|
626
|
+
},
|
|
627
|
+
async ({ cron_id, name, schedule, command, enabled }) => {
|
|
628
|
+
if (schedule !== undefined) {
|
|
629
|
+
const err = validateCronSchedule(schedule);
|
|
630
|
+
if (err) throw new Error(`Invalid schedule: ${err}`);
|
|
631
|
+
}
|
|
632
|
+
const body: Record<string, unknown> = {};
|
|
633
|
+
if (name !== undefined) body.name = name;
|
|
634
|
+
if (schedule !== undefined) body.schedule = schedule;
|
|
635
|
+
if (command !== undefined) body.command = command;
|
|
636
|
+
if (enabled !== undefined) body.enabled = enabled ? 1 : 0;
|
|
637
|
+
if (Object.keys(body).length === 0) {
|
|
638
|
+
throw new Error("Provide at least one field to update");
|
|
639
|
+
}
|
|
640
|
+
const res = await apiPut(`/api/crons/${cron_id}`, body);
|
|
641
|
+
if (!res.ok) throw new Error(`Failed to update cron: ${await res.text()}`);
|
|
642
|
+
const cron = await res.json();
|
|
643
|
+
return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
|
|
644
|
+
},
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
server.registerTool(
|
|
648
|
+
"moor_cron_delete",
|
|
649
|
+
{
|
|
650
|
+
title: "Delete Cron",
|
|
651
|
+
description: "Deletes a cron by id.",
|
|
652
|
+
inputSchema: z.object({
|
|
653
|
+
cron_id: z.number().int().positive().describe("Cron ID"),
|
|
654
|
+
}),
|
|
655
|
+
},
|
|
656
|
+
async ({ cron_id }) => {
|
|
657
|
+
const res = await apiDelete(`/api/crons/${cron_id}`);
|
|
658
|
+
if (!res.ok) throw new Error(`Failed to delete cron: ${await res.text()}`);
|
|
659
|
+
// API returns 204 whether or not the row existed; phrase the response so it
|
|
660
|
+
// doesn't claim a row was removed when it might already have been gone.
|
|
661
|
+
return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] };
|
|
662
|
+
},
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
server.registerTool(
|
|
666
|
+
"moor_cron_run",
|
|
667
|
+
{
|
|
668
|
+
title: "Run Cron Now",
|
|
669
|
+
description:
|
|
670
|
+
"Triggers a cron to run immediately. Requires the project's container to be running.",
|
|
671
|
+
inputSchema: z.object({
|
|
672
|
+
cron_id: z.number().int().positive().describe("Cron ID"),
|
|
673
|
+
}),
|
|
674
|
+
},
|
|
675
|
+
async ({ cron_id }) => {
|
|
676
|
+
const res = await apiPost(`/api/crons/${cron_id}/run`);
|
|
677
|
+
if (!res.ok) {
|
|
678
|
+
const text = await res.text();
|
|
679
|
+
let message = text;
|
|
680
|
+
try {
|
|
681
|
+
const parsed = JSON.parse(text);
|
|
682
|
+
if (parsed?.error) message = parsed.error;
|
|
683
|
+
} catch {
|
|
684
|
+
// Not JSON; use raw text
|
|
685
|
+
}
|
|
686
|
+
throw new Error(message);
|
|
687
|
+
}
|
|
688
|
+
return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] };
|
|
689
|
+
},
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
server.registerTool(
|
|
693
|
+
"moor_env_delete",
|
|
694
|
+
{
|
|
695
|
+
title: "Delete Environment Variables",
|
|
696
|
+
description:
|
|
697
|
+
"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.",
|
|
698
|
+
inputSchema: z.object({
|
|
699
|
+
project: z.string().describe("Project name or ID"),
|
|
700
|
+
keys: z.array(z.string().min(1)).min(1).describe("Env var keys to remove"),
|
|
701
|
+
}),
|
|
702
|
+
},
|
|
703
|
+
async ({ project, keys }) => {
|
|
704
|
+
const p = await resolveProject(project);
|
|
705
|
+
|
|
706
|
+
const existingRes = await apiGet(`/api/projects/${p.id}/envs`);
|
|
707
|
+
if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`);
|
|
708
|
+
const existing = (await existingRes.json()) as { key: string; value: string }[];
|
|
709
|
+
const existingKeys = new Set(existing.map((v) => v.key));
|
|
710
|
+
|
|
711
|
+
const toDelete = keys.filter((k) => existingKeys.has(k));
|
|
712
|
+
const missing = keys.filter((k) => !existingKeys.has(k));
|
|
713
|
+
|
|
714
|
+
if (toDelete.length === 0) {
|
|
715
|
+
const existingList = [...existingKeys].sort().join(", ") || "(none)";
|
|
716
|
+
return {
|
|
717
|
+
content: [
|
|
718
|
+
{
|
|
719
|
+
type: "text",
|
|
720
|
+
text: `No matching keys on ${p.name}. Existing keys: ${existingList}`,
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
for (const key of toDelete) {
|
|
727
|
+
const res = await apiDelete(`/api/projects/${p.id}/envs/${encodeURIComponent(key)}`);
|
|
728
|
+
if (!res.ok) throw new Error(`Failed to delete ${key}: ${await res.text()}`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`;
|
|
732
|
+
if (missing.length > 0) text += ` (Not present: ${missing.join(", ")}.)`;
|
|
733
|
+
|
|
734
|
+
if (p.status === "running") {
|
|
735
|
+
await apiPost(`/api/projects/${p.id}/stop`);
|
|
736
|
+
const startRes = await apiPost(`/api/projects/${p.id}/start`);
|
|
737
|
+
if (!startRes.ok) {
|
|
738
|
+
throw new Error(`Deleted vars but failed to restart: ${await startRes.text()}`);
|
|
739
|
+
}
|
|
740
|
+
text += " Container restarted.";
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return { content: [{ type: "text", text }] };
|
|
744
|
+
},
|
|
745
|
+
);
|
|
746
|
+
|
|
747
|
+
server.registerTool(
|
|
748
|
+
"moor_dns_check",
|
|
749
|
+
{
|
|
750
|
+
title: "Check Domain DNS",
|
|
751
|
+
description:
|
|
752
|
+
"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.",
|
|
753
|
+
inputSchema: z.object({
|
|
754
|
+
domain: z.string().min(1).describe("Domain to check, e.g. app.example.com"),
|
|
755
|
+
}),
|
|
756
|
+
},
|
|
757
|
+
async ({ domain }) => {
|
|
758
|
+
const res = await apiPost("/api/dns-check", { domain });
|
|
759
|
+
if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
|
|
760
|
+
const data = (await res.json()) as {
|
|
761
|
+
resolves: boolean;
|
|
762
|
+
ip: string | null;
|
|
763
|
+
serverIp: string | null;
|
|
764
|
+
};
|
|
765
|
+
const lines = [
|
|
766
|
+
`Domain: ${domain}`,
|
|
767
|
+
`Resolves: ${data.resolves ? "yes" : "no"}`,
|
|
768
|
+
`Resolved IP: ${data.ip ?? "(none)"}`,
|
|
769
|
+
`Server IP: ${data.serverIp ?? "(unknown)"}`,
|
|
770
|
+
];
|
|
771
|
+
if (data.ip && data.serverIp) {
|
|
772
|
+
lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`);
|
|
773
|
+
}
|
|
774
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
775
|
+
},
|
|
776
|
+
);
|
|
777
|
+
|
|
343
778
|
// --- Start ---
|
|
344
779
|
|
|
345
780
|
const transport = new StdioServerTransport();
|