@moor-sh/mcp 0.5.0 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -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
 
@@ -571,6 +572,21 @@ server.registerTool(
571
572
  .enum(["no", "on-failure", "always", "unless-stopped"])
572
573
  .optional()
573
574
  .describe("Docker restart policy (default: unless-stopped)"),
575
+ memory_limit_mb: z
576
+ .number()
577
+ .int()
578
+ .min(6)
579
+ .optional()
580
+ .describe(
581
+ "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).",
582
+ ),
583
+ cpus: z
584
+ .number()
585
+ .positive()
586
+ .optional()
587
+ .describe(
588
+ "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 — use omit for that). Max host core count. Takes effect on container recreate.",
589
+ ),
574
590
  }),
575
591
  },
576
592
  async (input) => {
@@ -592,7 +608,7 @@ server.registerTool(
592
608
  {
593
609
  title: "Update Project",
594
610
  description:
595
- "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately.",
611
+ "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) — an already-running container keeps its existing limits.",
596
612
  inputSchema: z.object({
597
613
  project: z.string().describe("Project name or ID to update"),
598
614
  name: z
@@ -609,6 +625,23 @@ server.registerTool(
609
625
  domain: z.string().optional(),
610
626
  domain_port: z.number().int().positive().optional(),
611
627
  restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
628
+ memory_limit_mb: z
629
+ .number()
630
+ .int()
631
+ .min(6)
632
+ .nullable()
633
+ .optional()
634
+ .describe(
635
+ "Max RAM in MB. Pass null to clear (return to unbounded). Min 6, max host total memory. Takes effect on container recreate.",
636
+ ),
637
+ cpus: z
638
+ .number()
639
+ .positive()
640
+ .nullable()
641
+ .optional()
642
+ .describe(
643
+ "Max CPU cores (fractional OK; min 0.001). Pass null to clear. Max host core count. Takes effect on container recreate.",
644
+ ),
612
645
  }),
613
646
  },
614
647
  async (input) => {
@@ -887,6 +920,23 @@ server.registerTool(
887
920
  .enum(["no", "on-failure", "always", "unless-stopped"])
888
921
  .optional()
889
922
  .describe("Docker restart policy (API default: unless-stopped)"),
923
+ memory_limit_mb: z
924
+ .number()
925
+ .int()
926
+ .min(6)
927
+ .nullable()
928
+ .optional()
929
+ .describe(
930
+ "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.",
931
+ ),
932
+ cpus: z
933
+ .number()
934
+ .positive()
935
+ .nullable()
936
+ .optional()
937
+ .describe(
938
+ "Max CPU cores. Fractional OK (e.g. 0.5; min 0.001). Max host core count. Pass null on update to clear.",
939
+ ),
890
940
  env: z
891
941
  .record(z.string(), z.string())
892
942
  .optional()
@@ -964,6 +1014,8 @@ server.registerTool(
964
1014
  domain: normalizedDomain,
965
1015
  domain_port: input.domain_port,
966
1016
  restart_policy: input.restart_policy,
1017
+ memory_limit_mb: input.memory_limit_mb,
1018
+ cpus: input.cpus,
967
1019
  };
968
1020
  const res = await apiPost("/api/projects", createBody);
969
1021
  if (!res.ok) throw new Error(`[create] ${await res.text()}`);
@@ -981,6 +1033,8 @@ server.registerTool(
981
1033
  if (normalizedDomain !== undefined) updateBody.domain = normalizedDomain;
982
1034
  if (input.domain_port !== undefined) updateBody.domain_port = input.domain_port;
983
1035
  if (input.restart_policy !== undefined) updateBody.restart_policy = input.restart_policy;
1036
+ if (input.memory_limit_mb !== undefined) updateBody.memory_limit_mb = input.memory_limit_mb;
1037
+ if (input.cpus !== undefined) updateBody.cpus = input.cpus;
984
1038
 
985
1039
  if (Object.keys(updateBody).length > 0) {
986
1040
  const res = await apiPut(`/api/projects/${existing.id}`, updateBody);
@@ -1087,12 +1141,22 @@ server.registerTool(
1087
1141
  {
1088
1142
  title: "Get Async Exec Status",
1089
1143
  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.",
1144
+ "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.",
1091
1145
  inputSchema: z.object({
1092
1146
  run_id: z.number().int().positive().describe("Run ID returned by moor_exec_async"),
1147
+ tail_bytes: z
1148
+ .number()
1149
+ .int()
1150
+ .min(0)
1151
+ .max(65_536)
1152
+ .optional()
1153
+ .describe(
1154
+ "Max bytes of each stream (stdout, stderr) returned inline. Default 8192. Max 65536 (the API storage cap). Set to 0 for metadata-only.",
1155
+ ),
1093
1156
  }),
1094
1157
  },
1095
- async ({ run_id }) => {
1158
+ async ({ run_id, tail_bytes }) => {
1159
+ const cap = tail_bytes ?? 8192;
1096
1160
  const res = await apiGet(`/api/exec/${run_id}`);
1097
1161
  if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1098
1162
  if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
@@ -1119,30 +1183,42 @@ server.registerTool(
1119
1183
  lines.push(`command: ${data.command}`);
1120
1184
  if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`);
1121
1185
  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
- }
1186
+ appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap);
1187
+ appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap);
1142
1188
  return { content: [{ type: "text", text: lines.join("\n") }] };
1143
1189
  },
1144
1190
  );
1145
1191
 
1192
+ function appendStream(
1193
+ lines: string[],
1194
+ name: string,
1195
+ raw: string,
1196
+ totalBytes: number,
1197
+ cap: number,
1198
+ ): void {
1199
+ if (!raw && totalBytes === 0) return;
1200
+ if (!raw) {
1201
+ // API returned an empty string but the stream did emit data (totalBytes > 0
1202
+ // is possible when no bytes survived API-side tail cap, though unlikely).
1203
+ lines.push(`${name}_total_bytes=${totalBytes}`);
1204
+ return;
1205
+ }
1206
+ const { tail, storedBytes, trimmed: mcpTrimmed } = tailUtf8(raw, cap);
1207
+ const apiTrimmed = totalBytes > storedBytes;
1208
+ let header: string;
1209
+ if (mcpTrimmed && apiTrimmed) {
1210
+ header = `${name} (showing last ${tail.length} chars of ${storedBytes} stored bytes; ${totalBytes} total bytes seen):`;
1211
+ } else if (mcpTrimmed) {
1212
+ header = `${name} (showing last ${tail.length} chars of ${storedBytes} total bytes):`;
1213
+ } else if (apiTrimmed) {
1214
+ header = `${name} (tail of ${storedBytes} stored from ${totalBytes} total bytes seen):`;
1215
+ } else {
1216
+ header = `${name}:`;
1217
+ }
1218
+ lines.push(header);
1219
+ if (cap > 0) lines.push(tail);
1220
+ }
1221
+
1146
1222
  server.registerTool(
1147
1223
  "moor_exec_stop",
1148
1224
  {
@@ -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
+ }