@moor-sh/mcp 0.4.1 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.4.1",
3
+ "version": "0.5.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": {
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { McpServer, StdioServerTransport } from "@modelcontextprotocol/server";
3
3
  import { z } from "zod";
4
+ import { tailUtf8 } from "./tail-utf8";
4
5
 
5
6
  // --- Config ---
6
7
 
@@ -1042,6 +1043,172 @@ server.registerTool(
1042
1043
  },
1043
1044
  );
1044
1045
 
1046
+ // --- Async exec (#34 Phase B) ---
1047
+
1048
+ server.registerTool(
1049
+ "moor_exec_async",
1050
+ {
1051
+ title: "Start Async Exec",
1052
+ description:
1053
+ "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.",
1054
+ inputSchema: z.object({
1055
+ project: z.string().describe("Project name or ID"),
1056
+ command: z.string().min(1).describe("Shell command to execute"),
1057
+ timeout_ms: z
1058
+ .number()
1059
+ .int()
1060
+ .min(60_000)
1061
+ .max(86_400_000)
1062
+ .optional()
1063
+ .describe(
1064
+ "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.",
1065
+ ),
1066
+ }),
1067
+ },
1068
+ async ({ project, command, timeout_ms }) => {
1069
+ const p = await resolveProject(project);
1070
+ const body: Record<string, unknown> = { command };
1071
+ if (timeout_ms !== undefined) body.timeout_ms = timeout_ms;
1072
+ const res = await apiPost(`/api/projects/${p.id}/exec/async`, body);
1073
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1074
+ const data = (await res.json()) as { run_id: number };
1075
+ return {
1076
+ content: [
1077
+ {
1078
+ type: "text",
1079
+ text: `Started async exec on ${p.name}. run_id=${data.run_id}. Use moor_exec_status to poll; moor_exec_stop to terminate.`,
1080
+ },
1081
+ ],
1082
+ };
1083
+ },
1084
+ );
1085
+
1086
+ server.registerTool(
1087
+ "moor_exec_status",
1088
+ {
1089
+ title: "Get Async Exec Status",
1090
+ description:
1091
+ "Return the current state of an async exec run: state, exit code (when finished), running tail of stdout/stderr (default 8 KiB each inline; the API stores up to 64 KiB), total bytes seen, duration, and any error message. State is one of: running, exited, stopped, timed_out, error. Pass tail_bytes to control how many bytes of each stream are returned inline (0 to 65536; default 8192). The API's 64 KiB-per-stream storage cap is unchanged — tail_bytes only controls what the MCP tool returns to keep responses under typical agent token limits.",
1092
+ inputSchema: z.object({
1093
+ run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
1094
+ tail_bytes: z
1095
+ .number()
1096
+ .int()
1097
+ .min(0)
1098
+ .max(65_536)
1099
+ .optional()
1100
+ .describe(
1101
+ "Max bytes of each stream (stdout, stderr) returned inline. Default 8192. Max 65536 (the API storage cap). Set to 0 for metadata-only.",
1102
+ ),
1103
+ }),
1104
+ },
1105
+ async ({ run_id, tail_bytes }) => {
1106
+ const cap = tail_bytes ?? 8192;
1107
+ const res = await apiGet(`/api/exec/${run_id}`);
1108
+ if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1109
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1110
+ const data = (await res.json()) as {
1111
+ id: number;
1112
+ state: string;
1113
+ exit_code: number | null;
1114
+ stdout: string;
1115
+ stderr: string;
1116
+ stdout_total_bytes: number;
1117
+ stderr_total_bytes: number;
1118
+ duration_ms: number;
1119
+ command: string;
1120
+ killed_pid: string | null;
1121
+ error_message: string | null;
1122
+ started_at: string;
1123
+ finished_at: string | null;
1124
+ };
1125
+ const lines: string[] = [];
1126
+ lines.push(
1127
+ `run_id=${data.id} state=${data.state} duration=${formatMs(data.duration_ms)}` +
1128
+ (data.exit_code !== null ? ` exit_code=${data.exit_code}` : ""),
1129
+ );
1130
+ lines.push(`command: ${data.command}`);
1131
+ if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`);
1132
+ if (data.error_message) lines.push(`error: ${data.error_message}`);
1133
+ appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap);
1134
+ appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap);
1135
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1136
+ },
1137
+ );
1138
+
1139
+ function appendStream(
1140
+ lines: string[],
1141
+ name: string,
1142
+ raw: string,
1143
+ totalBytes: number,
1144
+ cap: number,
1145
+ ): void {
1146
+ if (!raw && totalBytes === 0) return;
1147
+ if (!raw) {
1148
+ // API returned an empty string but the stream did emit data (totalBytes > 0
1149
+ // is possible when no bytes survived API-side tail cap, though unlikely).
1150
+ lines.push(`${name}_total_bytes=${totalBytes}`);
1151
+ return;
1152
+ }
1153
+ const { tail, storedBytes, trimmed: mcpTrimmed } = tailUtf8(raw, cap);
1154
+ const apiTrimmed = totalBytes > storedBytes;
1155
+ let header: string;
1156
+ if (mcpTrimmed && apiTrimmed) {
1157
+ header = `${name} (showing last ${tail.length} chars of ${storedBytes} stored bytes; ${totalBytes} total bytes seen):`;
1158
+ } else if (mcpTrimmed) {
1159
+ header = `${name} (showing last ${tail.length} chars of ${storedBytes} total bytes):`;
1160
+ } else if (apiTrimmed) {
1161
+ header = `${name} (tail of ${storedBytes} stored from ${totalBytes} total bytes seen):`;
1162
+ } else {
1163
+ header = `${name}:`;
1164
+ }
1165
+ lines.push(header);
1166
+ if (cap > 0) lines.push(tail);
1167
+ }
1168
+
1169
+ server.registerTool(
1170
+ "moor_exec_stop",
1171
+ {
1172
+ title: "Stop Async Exec",
1173
+ description:
1174
+ "Terminate a running async exec by run_id. Walks the descendant process tree inside the container and sends SIGTERM then SIGKILL. Always transitions the run to a terminal state: state=stopped on clean termination (all descendants gone), state=error if any descendant survived OR if the kill handle was lost (moor restart, missing pidfile). Stop is NOT retry-safe — the kill script removes the pidfile after every attempt, and reparented survivors are unreachable from the original PID.",
1175
+ inputSchema: z.object({
1176
+ run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
1177
+ }),
1178
+ },
1179
+ async ({ run_id }) => {
1180
+ const res = await apiPost(`/api/exec/${run_id}/stop`);
1181
+ if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1182
+ const data = (await res.json()) as {
1183
+ ok: boolean;
1184
+ state: string;
1185
+ killed_pid: string | null;
1186
+ live_remaining: number;
1187
+ message: string;
1188
+ };
1189
+ return {
1190
+ content: [
1191
+ {
1192
+ type: "text",
1193
+ text: `run_id=${run_id} state=${data.state} ${data.message}`,
1194
+ },
1195
+ ],
1196
+ };
1197
+ },
1198
+ );
1199
+
1200
+ function formatMs(ms: number): string {
1201
+ if (ms < 1000) return `${ms}ms`;
1202
+ const s = Math.floor(ms / 1000);
1203
+ if (s < 60) return `${s}s`;
1204
+ const m = Math.floor(s / 60);
1205
+ const rs = s % 60;
1206
+ if (m < 60) return `${m}m${rs}s`;
1207
+ const h = Math.floor(m / 60);
1208
+ const rm = m % 60;
1209
+ return `${h}h${rm}m${rs}s`;
1210
+ }
1211
+
1045
1212
  // --- Start ---
1046
1213
 
1047
1214
  const transport = new StdioServerTransport();
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { tailUtf8 } from "./tail-utf8";
3
+
4
+ describe("tailUtf8", () => {
5
+ test("returns the string unchanged when under maxBytes", () => {
6
+ const r = tailUtf8("hello", 100);
7
+ expect(r.tail).toBe("hello");
8
+ expect(r.storedBytes).toBe(5);
9
+ expect(r.trimmed).toBe(false);
10
+ });
11
+
12
+ test("returns the last maxBytes bytes for ASCII input", () => {
13
+ const r = tailUtf8("0123456789", 4);
14
+ expect(r.tail).toBe("6789");
15
+ expect(r.storedBytes).toBe(10);
16
+ expect(r.trimmed).toBe(true);
17
+ });
18
+
19
+ test("returns an empty string when maxBytes is 0", () => {
20
+ const r = tailUtf8("hello", 0);
21
+ expect(r.tail).toBe("");
22
+ expect(r.storedBytes).toBe(5);
23
+ expect(r.trimmed).toBe(true);
24
+ });
25
+
26
+ test("preserves complete trailing multi-byte codepoints", () => {
27
+ // "café" = c(1) a(1) f(1) é(2) = 5 bytes. Cap at 4 → trim "c", landing
28
+ // on "a" boundary; result "afé" (4 bytes).
29
+ const r = tailUtf8("café", 4);
30
+ expect(r.tail).toBe("afé");
31
+ expect(r.storedBytes).toBe(5);
32
+ expect(r.trimmed).toBe(true);
33
+ expect(r.tail).not.toContain("\ufffd");
34
+ });
35
+
36
+ test("aligns to a codepoint boundary when the cut falls inside a 2-byte sequence", () => {
37
+ // "héllo" = h(1) é(2) l(1) l(1) o(1) = 6 bytes. Cap at 4 → start at
38
+ // byte 2, which is the continuation of é. Align forward to "llo" (3 bytes).
39
+ const r = tailUtf8("héllo", 4);
40
+ expect(r.tail).toBe("llo");
41
+ expect(r.storedBytes).toBe(6);
42
+ expect(r.tail).not.toContain("\ufffd");
43
+ });
44
+
45
+ test("aligns to a codepoint boundary inside a 3-byte sequence", () => {
46
+ // "€€€" = 9 bytes (each € = 3 bytes). Cap at 4 → start at byte 5, which
47
+ // is a continuation. Align forward to start of last € → "€" (3 bytes).
48
+ const r = tailUtf8("€€€", 4);
49
+ expect(r.tail).toBe("€");
50
+ expect(r.storedBytes).toBe(9);
51
+ expect(r.tail).not.toContain("\ufffd");
52
+ });
53
+
54
+ test("aligns inside a 4-byte sequence (surrogate pair codepoint)", () => {
55
+ // "🌍🌍" = 8 bytes (each emoji = 4 bytes). Cap at 5 → start at byte 3,
56
+ // continuation. Align forward to start of last emoji → "🌍" (4 bytes).
57
+ const r = tailUtf8("🌍🌍", 5);
58
+ expect(r.tail).toBe("🌍");
59
+ expect(r.storedBytes).toBe(8);
60
+ expect(r.tail).not.toContain("\ufffd");
61
+ });
62
+
63
+ test("reports storedBytes as the original UTF-8 byte length, not JS char length", () => {
64
+ // "🌍" is 1 codepoint but 4 UTF-8 bytes (and 2 UTF-16 code units in JS).
65
+ const r = tailUtf8("🌍", 100);
66
+ expect(r.storedBytes).toBe(4);
67
+ expect(r.tail.length).toBe(2); // JS string length counts UTF-16 code units
68
+ });
69
+ });
@@ -0,0 +1,25 @@
1
+ // Byte-aware UTF-8 tail-trim used by moor_exec_status (#44). The MCP tool
2
+ // can't return the full API tail (up to 64 KiB per stream) inline because
3
+ // large responses blow agent token budgets. Caller picks how many bytes per
4
+ // stream they want; the trim is UTF-8 safe — the result never starts mid-
5
+ // codepoint, which would cause TextDecoder to emit U+FFFD at the head.
6
+
7
+ export function tailUtf8(
8
+ s: string,
9
+ maxBytes: number,
10
+ ): { tail: string; storedBytes: number; trimmed: boolean } {
11
+ const bytes = new TextEncoder().encode(s);
12
+ const storedBytes = bytes.length;
13
+ if (storedBytes <= maxBytes) return { tail: s, storedBytes, trimmed: false };
14
+ if (maxBytes === 0) return { tail: "", storedBytes, trimmed: true };
15
+ // Start at the offset that leaves maxBytes bytes; walk forward past any
16
+ // UTF-8 continuation bytes (10xxxxxx) so the decoded tail begins on a
17
+ // codepoint boundary.
18
+ let start = storedBytes - maxBytes;
19
+ while (start < storedBytes && (bytes[start] & 0xc0) === 0x80) start++;
20
+ return {
21
+ tail: new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(start)),
22
+ storedBytes,
23
+ trimmed: true,
24
+ };
25
+ }