@moor-sh/mcp 0.5.0 → 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.5.0",
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
 
@@ -1087,12 +1088,22 @@ server.registerTool(
1087
1088
  {
1088
1089
  title: "Get Async Exec Status",
1089
1090
  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
+ "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
1092
  inputSchema: z.object({
1092
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
+ ),
1093
1103
  }),
1094
1104
  },
1095
- async ({ run_id }) => {
1105
+ async ({ run_id, tail_bytes }) => {
1106
+ const cap = tail_bytes ?? 8192;
1096
1107
  const res = await apiGet(`/api/exec/${run_id}`);
1097
1108
  if (res.status === 404) throw new Error(`run_id ${run_id} not found`);
1098
1109
  if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
@@ -1119,30 +1130,42 @@ server.registerTool(
1119
1130
  lines.push(`command: ${data.command}`);
1120
1131
  if (data.killed_pid) lines.push(`killed_pid: ${data.killed_pid}`);
1121
1132
  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
- }
1133
+ appendStream(lines, "stdout", data.stdout, data.stdout_total_bytes, cap);
1134
+ appendStream(lines, "stderr", data.stderr, data.stderr_total_bytes, cap);
1142
1135
  return { content: [{ type: "text", text: lines.join("\n") }] };
1143
1136
  },
1144
1137
  );
1145
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
+
1146
1169
  server.registerTool(
1147
1170
  "moor_exec_stop",
1148
1171
  {
@@ -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
+ }