@moor-sh/mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +39 -0
  2. package/src/index.ts +346 -0
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@moor-sh/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for moor - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects via the moor HTTP API.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/caiopizzol/moor.git",
9
+ "directory": "packages/mcp"
10
+ },
11
+ "homepage": "https://github.com/caiopizzol/moor#mcp-server",
12
+ "bugs": "https://github.com/caiopizzol/moor/issues",
13
+ "keywords": [
14
+ "moor",
15
+ "mcp",
16
+ "docker",
17
+ "claude",
18
+ "ai-agent"
19
+ ],
20
+ "type": "module",
21
+ "bin": {
22
+ "moor-mcp": "src/index.ts"
23
+ },
24
+ "files": [
25
+ "src/"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "dev": "bun run src/index.ts",
32
+ "build": "bun build src/index.ts --compile --outfile moor-mcp"
33
+ },
34
+ "dependencies": {
35
+ "@cfworker/json-schema": "^4.1.1",
36
+ "@modelcontextprotocol/server": "^2.0.0-alpha.2",
37
+ "zod": "^4.3.6"
38
+ }
39
+ }
package/src/index.ts ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env bun
2
+ import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
3
+ import { z } from "zod";
4
+
5
+ // --- Config ---
6
+
7
+ const baseUrl = (process.env.MOOR_URL || "").replace(/\/$/, "");
8
+ const apiKey = process.env.MOOR_API_KEY || "";
9
+
10
+ if (!baseUrl || !apiKey) {
11
+ console.error("MOOR_URL and MOOR_API_KEY environment variables are required");
12
+ process.exit(1);
13
+ }
14
+
15
+ // --- Startup probe ---
16
+ // Fail closed: verify URL is reachable AND the bearer token authenticates before
17
+ // registering tools. Misconfigs surface here with a clear stderr message instead
18
+ // of later as opaque tool-call failures inside the MCP client.
19
+ {
20
+ let probeRes: Response;
21
+ try {
22
+ probeRes = await fetch(`${baseUrl}/api/projects`, {
23
+ headers: { Authorization: `Bearer ${apiKey}` },
24
+ signal: AbortSignal.timeout(5000),
25
+ });
26
+ } catch (e) {
27
+ const msg = e instanceof Error ? e.message : String(e);
28
+ console.error(`Cannot reach moor at ${baseUrl}: ${msg}`);
29
+ console.error("Check MOOR_URL and that moor is running (and tunneled, if remote).");
30
+ process.exit(1);
31
+ }
32
+ if (probeRes.status === 401) {
33
+ console.error(`Authentication failed against ${baseUrl}.`);
34
+ console.error("Check MOOR_API_KEY matches the value in moor's .env on the server.");
35
+ process.exit(1);
36
+ }
37
+ if (probeRes.status === 503) {
38
+ console.error(`moor at ${baseUrl} returned 503.`);
39
+ console.error("Likely cause: MOOR_INITIAL_PASSWORD not configured. Set it and restart moor.");
40
+ process.exit(1);
41
+ }
42
+ if (!probeRes.ok) {
43
+ console.error(`moor at ${baseUrl} returned ${probeRes.status} on startup probe.`);
44
+ process.exit(1);
45
+ }
46
+ }
47
+
48
+ // --- HTTP client ---
49
+
50
+ function headers(json = false): Record<string, string> {
51
+ const h: Record<string, string> = { Authorization: `Bearer ${apiKey}` };
52
+ if (json) h["Content-Type"] = "application/json";
53
+ return h;
54
+ }
55
+
56
+ async function apiGet(path: string) {
57
+ return fetch(`${baseUrl}${path}`, { headers: headers() });
58
+ }
59
+
60
+ async function apiPost(path: string, body?: unknown) {
61
+ return fetch(`${baseUrl}${path}`, {
62
+ method: "POST",
63
+ headers: headers(body !== undefined),
64
+ body: body !== undefined ? JSON.stringify(body) : undefined,
65
+ });
66
+ }
67
+
68
+ async function apiPut(path: string, body: unknown) {
69
+ return fetch(`${baseUrl}${path}`, {
70
+ method: "PUT",
71
+ headers: headers(true),
72
+ body: JSON.stringify(body),
73
+ });
74
+ }
75
+
76
+ type Project = {
77
+ id: number;
78
+ name: string;
79
+ status: string;
80
+ container_id: string | null;
81
+ image_tag: string | null;
82
+ domain: string | null;
83
+ docker_image: string | null;
84
+ github_url: string | null;
85
+ };
86
+
87
+ async function resolveProject(name: string): Promise<Project> {
88
+ const res = await apiGet("/api/projects");
89
+ if (!res.ok) throw new Error(`Failed to list projects: ${res.status}`);
90
+ const projects = (await res.json()) as Project[];
91
+ const match = projects.find((p) => p.name === name || String(p.id) === name);
92
+ if (!match) throw new Error(`Project "${name}" not found`);
93
+ return match;
94
+ }
95
+
96
+ // --- SSE stream reader ---
97
+
98
+ async function readSSE(res: Response): Promise<{ logs: string; error?: string }> {
99
+ const reader = res.body?.getReader();
100
+ if (!reader) return { logs: "" };
101
+
102
+ const decoder = new TextDecoder();
103
+ let buffer = "";
104
+ let currentEvent = "";
105
+ let logs = "";
106
+ let error: string | undefined;
107
+
108
+ while (true) {
109
+ const { done, value } = await reader.read();
110
+ if (done) break;
111
+ buffer += decoder.decode(value, { stream: true });
112
+
113
+ const lines = buffer.split("\n");
114
+ buffer = lines.pop() || "";
115
+
116
+ for (const line of lines) {
117
+ if (line.startsWith("event: ")) {
118
+ currentEvent = line.slice(7).trim();
119
+ } else if (line.startsWith("data: ")) {
120
+ const data = JSON.parse(line.slice(6));
121
+ if (currentEvent === "log") logs += data;
122
+ else if (currentEvent === "error") error = data;
123
+ currentEvent = "";
124
+ }
125
+ }
126
+ }
127
+ return { logs, error };
128
+ }
129
+
130
+ // --- MCP Server ---
131
+
132
+ const server = new McpServer({
133
+ name: "moor",
134
+ version: "0.1.0",
135
+ });
136
+
137
+ // --- Tools ---
138
+
139
+ server.registerTool(
140
+ "moor_status",
141
+ {
142
+ title: "List Projects",
143
+ description: "List all projects managed by Moor with their status, source, and domain.",
144
+ },
145
+ async () => {
146
+ const res = await apiGet("/api/projects");
147
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
148
+ const projects = (await res.json()) as Project[];
149
+ const summary = projects.map((p) => ({
150
+ name: p.name,
151
+ status: p.status,
152
+ source: p.docker_image || p.github_url || null,
153
+ domain: p.domain,
154
+ }));
155
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
156
+ },
157
+ );
158
+
159
+ server.registerTool(
160
+ "moor_logs",
161
+ {
162
+ title: "Get Container Logs",
163
+ description: "Get recent logs from a project's container.",
164
+ inputSchema: z.object({
165
+ project: z.string().describe("Project name or ID"),
166
+ lines: z.number().optional().default(100).describe("Number of log lines to retrieve"),
167
+ }),
168
+ },
169
+ async ({ project, lines }) => {
170
+ const p = await resolveProject(project);
171
+ const res = await apiGet(`/api/projects/${p.id}/logs?tail=${lines}`);
172
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
173
+ const data = (await res.json()) as { logs: string };
174
+ return {
175
+ content: [{ type: "text", text: data.logs || "(no logs)" }],
176
+ };
177
+ },
178
+ );
179
+
180
+ server.registerTool(
181
+ "moor_rebuild",
182
+ {
183
+ title: "Rebuild Project",
184
+ description:
185
+ "Rebuild a project from source (git pull + docker build) and restart the container. Returns the build output.",
186
+ inputSchema: z.object({
187
+ project: z.string().describe("Project name or ID"),
188
+ no_cache: z.boolean().optional().default(false).describe("Build without Docker cache"),
189
+ }),
190
+ },
191
+ async ({ project, no_cache }) => {
192
+ const p = await resolveProject(project);
193
+ const query = no_cache ? "?nocache=true" : "";
194
+ const res = await apiPost(`/api/projects/${p.id}/run${query}`);
195
+ const { logs, error } = await readSSE(res);
196
+ if (error) throw new Error(error);
197
+ return { content: [{ type: "text", text: logs || "Rebuild complete." }] };
198
+ },
199
+ );
200
+
201
+ server.registerTool(
202
+ "moor_restart",
203
+ {
204
+ title: "Restart Project",
205
+ description: "Stop and start a project's container.",
206
+ inputSchema: z.object({
207
+ project: z.string().describe("Project name or ID"),
208
+ }),
209
+ },
210
+ async ({ project }) => {
211
+ const p = await resolveProject(project);
212
+ const stopRes = await apiPost(`/api/projects/${p.id}/stop`);
213
+ if (!stopRes.ok) throw new Error(`Failed to stop: ${await stopRes.text()}`);
214
+ const startRes = await apiPost(`/api/projects/${p.id}/start`);
215
+ if (!startRes.ok) throw new Error(`Failed to start: ${await startRes.text()}`);
216
+ return { content: [{ type: "text", text: `${p.name} restarted.` }] };
217
+ },
218
+ );
219
+
220
+ server.registerTool(
221
+ "moor_exec",
222
+ {
223
+ title: "Execute Command",
224
+ description: "Run a shell command inside a project's running container.",
225
+ inputSchema: z.object({
226
+ project: z.string().describe("Project name or ID"),
227
+ command: z.string().describe("Shell command to execute"),
228
+ }),
229
+ },
230
+ async ({ project, command }) => {
231
+ const p = await resolveProject(project);
232
+ const res = await apiPost(`/api/projects/${p.id}/exec`, { command });
233
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
234
+ const result = (await res.json()) as {
235
+ exitCode: number;
236
+ stdout: string;
237
+ stderr: string;
238
+ };
239
+ let text = "";
240
+ if (result.stdout) text += result.stdout;
241
+ if (result.stderr) text += `\n[stderr] ${result.stderr}`;
242
+ text += `\n[exit code: ${result.exitCode}]`;
243
+ return { content: [{ type: "text", text }] };
244
+ },
245
+ );
246
+
247
+ server.registerTool(
248
+ "moor_env_list",
249
+ {
250
+ title: "List Environment Variables",
251
+ description: "List all environment variables set for a project.",
252
+ inputSchema: z.object({
253
+ project: z.string().describe("Project name or ID"),
254
+ }),
255
+ },
256
+ async ({ project }) => {
257
+ const p = await resolveProject(project);
258
+ const res = await apiGet(`/api/projects/${p.id}/envs`);
259
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
260
+ const vars = (await res.json()) as { key: string; value: string }[];
261
+ if (vars.length === 0)
262
+ return { content: [{ type: "text", text: "No environment variables set." }] };
263
+ const text = vars.map((v) => `${v.key}=${v.value}`).join("\n");
264
+ return { content: [{ type: "text", text }] };
265
+ },
266
+ );
267
+
268
+ server.registerTool(
269
+ "moor_env_set",
270
+ {
271
+ title: "Set Environment Variables",
272
+ description:
273
+ "Set environment variables for a project. Merges with existing vars. Automatically restarts the container if running.",
274
+ inputSchema: z.object({
275
+ project: z.string().describe("Project name or ID"),
276
+ vars: z
277
+ .record(z.string(), z.string())
278
+ .describe('Key-value pairs to set, e.g. { "DATABASE_URL": "postgres://..." }'),
279
+ }),
280
+ },
281
+ async ({ project, vars }) => {
282
+ const p = await resolveProject(project);
283
+
284
+ // Fetch existing and merge
285
+ const existingRes = await apiGet(`/api/projects/${p.id}/envs`);
286
+ if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`);
287
+ const existing = (await existingRes.json()) as { key: string; value: string }[];
288
+ const merged = new Map(existing.map((v) => [v.key, v.value]));
289
+ for (const [key, value] of Object.entries(vars)) {
290
+ merged.set(key, value);
291
+ }
292
+ const allVars = Array.from(merged, ([key, value]) => ({ key, value }));
293
+
294
+ const setRes = await apiPut(`/api/projects/${p.id}/envs`, allVars);
295
+ if (!setRes.ok) throw new Error(`Failed to set envs: ${await setRes.text()}`);
296
+
297
+ const keys = Object.keys(vars).join(", ");
298
+ let text = `Set ${keys} on ${p.name}.`;
299
+
300
+ // Restart if running
301
+ if (p.status === "running") {
302
+ await apiPost(`/api/projects/${p.id}/stop`);
303
+ const startRes = await apiPost(`/api/projects/${p.id}/start`);
304
+ if (!startRes.ok) throw new Error(`Set vars but failed to restart: ${await startRes.text()}`);
305
+ text += " Container restarted.";
306
+ }
307
+
308
+ return { content: [{ type: "text", text }] };
309
+ },
310
+ );
311
+
312
+ server.registerTool(
313
+ "moor_stats",
314
+ {
315
+ title: "Server Stats",
316
+ description: "Get server resource usage: CPU, memory, disk, and container counts.",
317
+ },
318
+ async () => {
319
+ const res = await apiGet("/api/server/stats");
320
+ if (!res.ok) throw new Error(`Failed: ${res.status}`);
321
+ const s = (await res.json()) as {
322
+ hostname: string;
323
+ os: string;
324
+ uptime: string;
325
+ cpu: { percent: number; cores: number };
326
+ memory: { total: string; used: string; percent: number };
327
+ disk: { total: string; used: string; percent: number };
328
+ containers: { running: number; total: number };
329
+ };
330
+ const text = [
331
+ `Host: ${s.hostname}`,
332
+ `OS: ${s.os}`,
333
+ `Uptime: ${s.uptime}`,
334
+ `CPU: ${s.cpu.percent}% (${s.cpu.cores} cores)`,
335
+ `Memory: ${s.memory.used} / ${s.memory.total} (${s.memory.percent}%)`,
336
+ `Disk: ${s.disk.used} / ${s.disk.total} (${s.disk.percent}%)`,
337
+ `Containers: ${s.containers.running} running / ${s.containers.total} total`,
338
+ ].join("\n");
339
+ return { content: [{ type: "text", text }] };
340
+ },
341
+ );
342
+
343
+ // --- Start ---
344
+
345
+ const transport = new StdioServerTransport();
346
+ await server.connect(transport);