@moor-sh/mcp 0.4.0 → 0.5.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 +1 -1
  2. package/src/index.ts +166 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
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
@@ -385,6 +385,28 @@ server.registerTool(
385
385
  const body: Record<string, unknown> = { command };
386
386
  if (timeout_ms !== undefined) body.timeout_ms = timeout_ms;
387
387
  const res = await apiPost(`/api/projects/${p.id}/exec`, body);
388
+ // The API returns 504 with a structured timeout body when the exec hit
389
+ // timeout_ms. Surface the kill outcome in the tool error so the agent can
390
+ // tell "the process was actually stopped" from "we just stopped waiting."
391
+ if (res.status === 504) {
392
+ const t = (await res.json()) as {
393
+ timeout_ms: number;
394
+ killed: boolean;
395
+ killed_pid: string | null;
396
+ live_remaining: number;
397
+ message: string;
398
+ };
399
+ let detail: string;
400
+ if (t.killed) {
401
+ detail = `Process tree terminated (container pid ${t.killed_pid}).`;
402
+ } else if (t.killed_pid !== null) {
403
+ detail = `Kill attempted on container pid ${t.killed_pid} but ${t.live_remaining} descendant process(es) still running inside the container.`;
404
+ } else {
405
+ detail =
406
+ "Process kill could not locate the running process — it may still be running inside the container.";
407
+ }
408
+ throw new Error(`Exec timed out after ${t.timeout_ms}ms. ${detail}`);
409
+ }
388
410
  if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
389
411
  const result = (await res.json()) as {
390
412
  exitCode: number;
@@ -1020,6 +1042,150 @@ server.registerTool(
1020
1042
  },
1021
1043
  );
1022
1044
 
1045
+ // --- Async exec (#34 Phase B) ---
1046
+
1047
+ server.registerTool(
1048
+ "moor_exec_async",
1049
+ {
1050
+ title: "Start Async Exec",
1051
+ description:
1052
+ "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.",
1053
+ inputSchema: z.object({
1054
+ project: z.string().describe("Project name or ID"),
1055
+ command: z.string().min(1).describe("Shell command to execute"),
1056
+ timeout_ms: z
1057
+ .number()
1058
+ .int()
1059
+ .min(60_000)
1060
+ .max(86_400_000)
1061
+ .optional()
1062
+ .describe(
1063
+ "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.",
1064
+ ),
1065
+ }),
1066
+ },
1067
+ async ({ project, command, timeout_ms }) => {
1068
+ const p = await resolveProject(project);
1069
+ const body: Record<string, unknown> = { command };
1070
+ if (timeout_ms !== undefined) body.timeout_ms = timeout_ms;
1071
+ const res = await apiPost(`/api/projects/${p.id}/exec/async`, body);
1072
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1073
+ const data = (await res.json()) as { run_id: number };
1074
+ return {
1075
+ content: [
1076
+ {
1077
+ type: "text",
1078
+ text: `Started async exec on ${p.name}. run_id=${data.run_id}. Use moor_exec_status to poll; moor_exec_stop to terminate.`,
1079
+ },
1080
+ ],
1081
+ };
1082
+ },
1083
+ );
1084
+
1085
+ server.registerTool(
1086
+ "moor_exec_status",
1087
+ {
1088
+ title: "Get Async Exec Status",
1089
+ description:
1090
+ "Return the current state of an async exec run: state, exit code (when finished), running tail of stdout/stderr (last 64 KiB each), total bytes seen, duration, and any error message. State is one of: running, exited, stopped, timed_out, error.",
1091
+ inputSchema: z.object({
1092
+ run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
1093
+ }),
1094
+ },
1095
+ async ({ run_id }) => {
1096
+ const res = await apiGet(`/api/exec/${run_id}`);
1097
+ if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1098
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
1099
+ const data = (await res.json()) as {
1100
+ id: number;
1101
+ state: string;
1102
+ exit_code: number | null;
1103
+ stdout: string;
1104
+ stderr: string;
1105
+ stdout_total_bytes: number;
1106
+ stderr_total_bytes: number;
1107
+ duration_ms: number;
1108
+ command: string;
1109
+ killed_pid: string | null;
1110
+ error_message: string | null;
1111
+ started_at: string;
1112
+ finished_at: string | null;
1113
+ };
1114
+ const lines: string[] = [];
1115
+ lines.push(
1116
+ `run_id=${data.id} state=${data.state} duration=${formatMs(data.duration_ms)}` +
1117
+ (data.exit_code !== null ? ` exit_code=${data.exit_code}` : ""),
1118
+ );
1119
+ lines.push(`command: ${data.command}`);
1120
+ if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`);
1121
+ if (data.error_message) lines.push(`error: ${data.error_message}`);
1122
+ if (data.stdout) {
1123
+ const note =
1124
+ data.stdout_total_bytes > data.stdout.length
1125
+ ? ` (tail of ${data.stdout_total_bytes} bytes)`
1126
+ : "";
1127
+ lines.push(`stdout${note}:`);
1128
+ lines.push(data.stdout);
1129
+ } else if (data.stdout_total_bytes > 0) {
1130
+ lines.push(`stdout_total_bytes=${data.stdout_total_bytes}`);
1131
+ }
1132
+ if (data.stderr) {
1133
+ const note =
1134
+ data.stderr_total_bytes > data.stderr.length
1135
+ ? ` (tail of ${data.stderr_total_bytes} bytes)`
1136
+ : "";
1137
+ lines.push(`stderr${note}:`);
1138
+ lines.push(data.stderr);
1139
+ } else if (data.stderr_total_bytes > 0) {
1140
+ lines.push(`stderr_total_bytes=${data.stderr_total_bytes}`);
1141
+ }
1142
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1143
+ },
1144
+ );
1145
+
1146
+ server.registerTool(
1147
+ "moor_exec_stop",
1148
+ {
1149
+ title: "Stop Async Exec",
1150
+ description:
1151
+ "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.",
1152
+ inputSchema: z.object({
1153
+ run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
1154
+ }),
1155
+ },
1156
+ async ({ run_id }) => {
1157
+ const res = await apiPost(`/api/exec/${run_id}/stop`);
1158
+ if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1159
+ const data = (await res.json()) as {
1160
+ ok: boolean;
1161
+ state: string;
1162
+ killed_pid: string | null;
1163
+ live_remaining: number;
1164
+ message: string;
1165
+ };
1166
+ return {
1167
+ content: [
1168
+ {
1169
+ type: "text",
1170
+ text: `run_id=${run_id} state=${data.state} ${data.message}`,
1171
+ },
1172
+ ],
1173
+ };
1174
+ },
1175
+ );
1176
+
1177
+ function formatMs(ms: number): string {
1178
+ if (ms < 1000) return `${ms}ms`;
1179
+ const s = Math.floor(ms / 1000);
1180
+ if (s < 60) return `${s}s`;
1181
+ const m = Math.floor(s / 60);
1182
+ const rs = s % 60;
1183
+ if (m < 60) return `${m}m${rs}s`;
1184
+ const h = Math.floor(m / 60);
1185
+ const rm = m % 60;
1186
+ return `${h}h${rm}m${rs}s`;
1187
+ }
1188
+
1023
1189
  // --- Start ---
1024
1190
 
1025
1191
  const transport = new StdioServerTransport();