@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 +1 -1
- package/src/index.ts +45 -22
- package/src/tail-utf8.test.ts +69 -0
- package/src/tail-utf8.ts +25 -0
package/package.json
CHANGED
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 (
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
+
});
|
package/src/tail-utf8.ts
ADDED
|
@@ -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
|
+
}
|