@love-moon/conductor-cli 0.13.3 → 0.15.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/CHANGELOG.md +114 -0
- package/bin/conductor-fire.js +209 -60
- package/bin/conductor-remote.js +5 -1
- package/bin/conductor.js +1 -1
- package/package.json +5 -5
- package/src/daemon.js +38 -11
- package/src/remote/client.js +1 -1
- package/src/remote/cp.js +2 -2
- package/src/remote/exec.js +36 -6
- package/src/remote/mcp-launch.js +167 -0
- package/src/remote/mcp.js +849 -0
- package/src/remote-exec-handlers.js +15 -3
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `conductor remote mcp` — a stdio MCP server whose tools act on one directory
|
|
3
|
+
* on another daemon's host (RFC 0040).
|
|
4
|
+
*
|
|
5
|
+
* The AI runs on this daemon, the task's worktree lives on `--host`. Without
|
|
6
|
+
* this server the AI reaches it only through `conductor remote exec -- <shell>`:
|
|
7
|
+
* hand-numbered `sed -n` reads, heredoc/`sed -i` edits that are easy to get
|
|
8
|
+
* wrong, `rg` output cut to the last 64 000 characters, and nothing stopping a
|
|
9
|
+
* forgotten `-t/-w` from editing the local checkout instead. Here the target is
|
|
10
|
+
* fixed at startup and the tools have the shape of the native ones.
|
|
11
|
+
*
|
|
12
|
+
* Nothing new runs on the target: reads, searches and shell commands go through
|
|
13
|
+
* `remote exec`, and edits/writes move whole files with `remote cp`'s transfer
|
|
14
|
+
* (exact bytes, no shell quoting, atomic rename on the target).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fsp from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
import readline from "node:readline";
|
|
22
|
+
import { createRequire } from "node:module";
|
|
23
|
+
|
|
24
|
+
import { EXIT, UsageError, loadCliConfig } from "./client.js";
|
|
25
|
+
import { execRemote, waitForRun } from "./exec.js";
|
|
26
|
+
import { downloadFile, uploadFile } from "./cp.js";
|
|
27
|
+
import { MCP_SERVER_NAME } from "./mcp-launch.js";
|
|
28
|
+
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const { version: CLI_VERSION } = require("../../package.json");
|
|
31
|
+
|
|
32
|
+
const PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
33
|
+
|
|
34
|
+
/** `remote exec` keeps the last 64 000 characters; stay well inside that. */
|
|
35
|
+
const OUTPUT_BUDGET_BYTES = 60_000;
|
|
36
|
+
const READ_DEFAULT_LIMIT = 2000;
|
|
37
|
+
const READ_MAX_LINE_CHARS = 2000;
|
|
38
|
+
const SEARCH_DEFAULT_LIMIT = 100;
|
|
39
|
+
const SEARCH_MAX_LIMIT = 1000;
|
|
40
|
+
const FILE_OP_TIMEOUT_MS = 120_000;
|
|
41
|
+
const BASH_DEFAULT_TIMEOUT_MS = 120_000;
|
|
42
|
+
const BASH_MAX_TIMEOUT_MS = 600_000;
|
|
43
|
+
const DEFAULT_FILE_MODE = 0o644;
|
|
44
|
+
|
|
45
|
+
/** A failure the AI should see as a tool error, not a protocol error. */
|
|
46
|
+
export class ToolError extends Error {}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Remote scripts. Arguments are always passed as positional parameters
|
|
50
|
+
// (`bash -c <script> conductor-mcp <args...>`), never interpolated, so no
|
|
51
|
+
// path or pattern is ever re-parsed by a shell.
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
const READ_SCRIPT = `f=$1; start=$2; end=$3; budget=$4
|
|
55
|
+
if [ -d "$f" ]; then echo "is a directory: $f" >&2; exit 21; fi
|
|
56
|
+
if [ ! -e "$f" ]; then echo "no such file: $f" >&2; exit 2; fi
|
|
57
|
+
if [ ! -r "$f" ]; then echo "permission denied: $f" >&2; exit 13; fi
|
|
58
|
+
awk 'END { print NR }' "$f" || exit $?
|
|
59
|
+
sed -n "\${start},\${end}p" "$f" | head -c "$budget"`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A symlink is refused rather than followed: the daemon's upload renames over
|
|
63
|
+
* the link itself, which would silently turn it into a regular file.
|
|
64
|
+
*/
|
|
65
|
+
const SYMLINK_CHECK = `if [ -L "$1" ]; then echo "$1 is a symlink to $(readlink "$1"); edit the target instead" >&2; exit 40; fi`;
|
|
66
|
+
|
|
67
|
+
/** Hash via stdin: given a file name, GNU sha256sum escapes odd names with a leading "\\". */
|
|
68
|
+
const HASH_SCRIPT = `${SYMLINK_CHECK}
|
|
69
|
+
if command -v sha256sum >/dev/null 2>&1; then sha256sum <"$1"; else shasum -a 256 <"$1"; fi`;
|
|
70
|
+
|
|
71
|
+
/** Makes the parent directory and reports the current mode, if the file exists. */
|
|
72
|
+
const PREPARE_WRITE_SCRIPT = `${SYMLINK_CHECK}
|
|
73
|
+
f=$1
|
|
74
|
+
if [ -d "$f" ]; then echo "is a directory: $f" >&2; exit 21; fi
|
|
75
|
+
mkdir -p "$(dirname "$f")" || exit $?
|
|
76
|
+
if [ -e "$f" ]; then stat -c %a "$f" 2>/dev/null || stat -f %Lp "$f"; fi`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Runs "$@" on `target` into a temp file, prints "<d|f> <line count>", then one
|
|
80
|
+
* page of it. The search runs from inside the target directory (or a file's
|
|
81
|
+
* parent) on a relative path, because that is the only way ripgrep anchors a
|
|
82
|
+
* glob like `src/**\/*.ts`; the caller turns the relative paths back into
|
|
83
|
+
* absolute ones. Every page re-runs the command, so paging relies on each
|
|
84
|
+
* caller asking for a deterministic order (`--sort`/`--sortr`).
|
|
85
|
+
*/
|
|
86
|
+
const PAGE_SCRIPT = `off=$1; lim=$2; budget=$3; nozero=$4; target=$5; shift 5
|
|
87
|
+
if [ -d "$target" ]; then cd "$target" || exit 2; rel=.; kind=d
|
|
88
|
+
elif [ -e "$target" ]; then cd "$(dirname "$target")" || exit 2; rel=$(basename "$target"); kind=f
|
|
89
|
+
else echo "no such file or directory: $target" >&2; exit 2; fi
|
|
90
|
+
tmp=$(mktemp "\${TMPDIR:-/tmp}/conductor-mcp.XXXXXX") || exit 125
|
|
91
|
+
trap 'rm -f "$tmp" "$tmp.f"' EXIT
|
|
92
|
+
"$@" "$rel" >"$tmp"; code=$?
|
|
93
|
+
if [ "$code" -gt 1 ] && [ ! -s "$tmp" ]; then exit "$code"; fi
|
|
94
|
+
if [ "$nozero" = 1 ]; then grep -v ':0$' "$tmp" >"$tmp.f"; mv "$tmp.f" "$tmp"; fi
|
|
95
|
+
echo "$kind $(wc -l <"$tmp" | tr -d ' ')"
|
|
96
|
+
tail -n "+$((off + 1))" "$tmp" | head -n "$lim" | head -c "$budget"`;
|
|
97
|
+
|
|
98
|
+
/** Fallback for `remote_glob` on a host without ripgrep: `$1` pattern, `$2` dir. */
|
|
99
|
+
const GLOB_FALLBACK_SCRIPT = `cd "$2" || exit 2
|
|
100
|
+
shopt -s globstar nullglob dotglob 2>/dev/null
|
|
101
|
+
IFS=$'\\n'
|
|
102
|
+
for f in $1; do [ -f "$f" ] && printf '%s/%s\\n' "$PWD" "$f"; done
|
|
103
|
+
exit 0`;
|
|
104
|
+
|
|
105
|
+
const TOOLS_SCRIPT = `command -v rg >/dev/null 2>&1 && echo rg || echo grep`;
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
const isWindowsStylePath = (value) => /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\");
|
|
112
|
+
|
|
113
|
+
function countOccurrences(text, needle) {
|
|
114
|
+
let count = 0;
|
|
115
|
+
let index = text.indexOf(needle);
|
|
116
|
+
while (index !== -1) {
|
|
117
|
+
count += 1;
|
|
118
|
+
index = text.indexOf(needle, index + needle.length);
|
|
119
|
+
}
|
|
120
|
+
return count;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Split a page of output into whole lines, dropping a line cut by the byte budget. */
|
|
124
|
+
function splitPage(text, budget) {
|
|
125
|
+
const hitBudget = Buffer.byteLength(text) >= budget - 3;
|
|
126
|
+
const lines = text.split("\n");
|
|
127
|
+
if (lines[lines.length - 1] === "") {
|
|
128
|
+
lines.pop();
|
|
129
|
+
} else if (hitBudget && lines.length > 1) {
|
|
130
|
+
lines.pop();
|
|
131
|
+
}
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function splitHeader(stdout) {
|
|
136
|
+
const newline = stdout.indexOf("\n");
|
|
137
|
+
if (newline === -1) return { header: stdout.trim(), body: "" };
|
|
138
|
+
return { header: stdout.slice(0, newline).trim(), body: stdout.slice(newline + 1) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function formatNumbered(lines, firstLine) {
|
|
142
|
+
return lines
|
|
143
|
+
.map((line, index) => {
|
|
144
|
+
const shown = line.length > READ_MAX_LINE_CHARS
|
|
145
|
+
? `${line.slice(0, READ_MAX_LINE_CHARS)}… [line truncated]`
|
|
146
|
+
: line;
|
|
147
|
+
return `${String(firstLine + index).padStart(6)}\t${shown}`;
|
|
148
|
+
})
|
|
149
|
+
.join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toInteger(value, name, { min, max, fallback }) {
|
|
153
|
+
if (value === undefined || value === null) return fallback;
|
|
154
|
+
const number = Number(value);
|
|
155
|
+
if (!Number.isInteger(number) || number < min || (max !== undefined && number > max)) {
|
|
156
|
+
throw new ToolError(`${name} must be an integer${max !== undefined ? ` between ${min} and ${max}` : ` >= ${min}`}`);
|
|
157
|
+
}
|
|
158
|
+
return number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function requireString(value, name, { allowEmpty = false } = {}) {
|
|
162
|
+
if (typeof value !== "string" || (!allowEmpty && value === "")) {
|
|
163
|
+
throw new ToolError(`${name} is required`);
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runFailure(run, what) {
|
|
169
|
+
const detail = (run?.stderrTail || run?.error || run?.stdoutTail || "").trim();
|
|
170
|
+
const status = run?.status === "running"
|
|
171
|
+
? "timed out"
|
|
172
|
+
: `exited ${run?.exitCode ?? run?.status ?? "?"}`;
|
|
173
|
+
return new ToolError(detail ? `${what} failed (${status}): ${detail}` : `${what} failed (${status})`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// The workspace: every tool's behaviour, independent of MCP framing.
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {object} opts
|
|
182
|
+
* @param {string} opts.host daemon the workspace lives on
|
|
183
|
+
* @param {string} opts.root no tool may touch a path outside this directory
|
|
184
|
+
* @param {string} [opts.cwd] relative paths resolve here (default: root)
|
|
185
|
+
* @param {(req: {command: string, args: string[], workspace?: string, timeoutMs: number}) => Promise<object>} opts.exec
|
|
186
|
+
* run one command on the host; resolves with the run record
|
|
187
|
+
* @param {(runId: string, timeoutMs: number) => Promise<object>} opts.wait
|
|
188
|
+
* keep waiting for a run `exec` returned while still running
|
|
189
|
+
* @param {(remotePath: string, localPath: string) => Promise<{sha256: string, mode?: number}>} opts.download
|
|
190
|
+
* @param {(localPath: string, remotePath: string) => Promise<unknown>} opts.upload
|
|
191
|
+
* replaces the remote file atomically, with the local file's mode
|
|
192
|
+
*/
|
|
193
|
+
export function createRemoteWorkspace(opts) {
|
|
194
|
+
const { host, exec, wait, download, upload } = opts;
|
|
195
|
+
const pathApi = isWindowsStylePath(opts.root) ? path.win32 : path.posix;
|
|
196
|
+
const root = pathApi.normalize(opts.root);
|
|
197
|
+
const cwd = pathApi.normalize(opts.cwd || opts.root);
|
|
198
|
+
const tmpRoot = opts.tmpDir || os.tmpdir();
|
|
199
|
+
let searchTool = null;
|
|
200
|
+
|
|
201
|
+
function resolvePath(value, name = "file_path") {
|
|
202
|
+
const raw = requireString(value, name).trim();
|
|
203
|
+
const absolute = pathApi.isAbsolute(raw) ? pathApi.normalize(raw) : pathApi.join(cwd, raw);
|
|
204
|
+
const relative = pathApi.relative(root, absolute);
|
|
205
|
+
if (relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) {
|
|
206
|
+
throw new ToolError(`${raw} is outside the remote workspace ${root} on ${host}`);
|
|
207
|
+
}
|
|
208
|
+
return absolute;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runScript(script, args, { what, timeoutMs = FILE_OP_TIMEOUT_MS, okCodes = [0] } = {}) {
|
|
212
|
+
const run = await exec({ command: "bash", args: ["-c", script, "conductor-mcp", ...args.map(String)], timeoutMs });
|
|
213
|
+
if (run?.status !== "completed" || !okCodes.includes(run.exitCode)) {
|
|
214
|
+
throw runFailure(run, what);
|
|
215
|
+
}
|
|
216
|
+
return run;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function withTempDir(fn) {
|
|
220
|
+
const dir = await fsp.mkdtemp(path.join(tmpRoot, "conductor-mcp-"));
|
|
221
|
+
try {
|
|
222
|
+
return await fn(dir);
|
|
223
|
+
} finally {
|
|
224
|
+
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function remoteSha256(file) {
|
|
229
|
+
const run = await runScript(HASH_SCRIPT, [file], { what: `hashing ${file}` });
|
|
230
|
+
return String(run.stdoutTail || "").trim().split(/\s+/)[0] || "";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function read({ file_path: filePath, offset, limit } = {}) {
|
|
234
|
+
const file = resolvePath(filePath);
|
|
235
|
+
const start = toInteger(offset, "offset", { min: 1, fallback: 1 });
|
|
236
|
+
const count = toInteger(limit, "limit", { min: 1, fallback: READ_DEFAULT_LIMIT });
|
|
237
|
+
const run = await runScript(READ_SCRIPT, [file, start, start + count - 1, OUTPUT_BUDGET_BYTES], {
|
|
238
|
+
what: `reading ${file}`,
|
|
239
|
+
});
|
|
240
|
+
const { header, body } = splitHeader(String(run.stdoutTail || ""));
|
|
241
|
+
const total = Number.parseInt(header, 10) || 0;
|
|
242
|
+
if (total === 0) return `(${file} is empty)`;
|
|
243
|
+
if (start > total) {
|
|
244
|
+
throw new ToolError(`offset ${start} is past the end of ${file} (${total} lines)`);
|
|
245
|
+
}
|
|
246
|
+
if (body.includes("\u0000")) {
|
|
247
|
+
throw new ToolError(`${file} looks like a binary file; use remote_bash to inspect it`);
|
|
248
|
+
}
|
|
249
|
+
const lines = splitPage(body, OUTPUT_BUDGET_BYTES);
|
|
250
|
+
const last = start + lines.length - 1;
|
|
251
|
+
let text = formatNumbered(lines, start);
|
|
252
|
+
if (last < total) {
|
|
253
|
+
text += `\n\n[Showing lines ${start}-${last} of ${total}. Continue with offset=${last + 1}.]`;
|
|
254
|
+
}
|
|
255
|
+
return text;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function edit({ file_path: filePath, old_string: oldRaw, new_string: newRaw, replace_all: replaceAll } = {}) {
|
|
259
|
+
const file = resolvePath(filePath);
|
|
260
|
+
let oldString = requireString(oldRaw, "old_string", { allowEmpty: true });
|
|
261
|
+
let newString = requireString(newRaw, "new_string", { allowEmpty: true });
|
|
262
|
+
if (oldString === "") {
|
|
263
|
+
throw new ToolError("old_string must not be empty; use remote_write to create or overwrite a file");
|
|
264
|
+
}
|
|
265
|
+
if (oldString === newString) {
|
|
266
|
+
throw new ToolError("old_string and new_string are identical; nothing to change");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return withTempDir(async (dir) => {
|
|
270
|
+
const local = path.join(dir, "file");
|
|
271
|
+
const original = await download(file, local);
|
|
272
|
+
const bytes = await fsp.readFile(local);
|
|
273
|
+
if (bytes.includes(0)) {
|
|
274
|
+
throw new ToolError(`${file} looks like a binary file; refusing to edit it`);
|
|
275
|
+
}
|
|
276
|
+
const text = bytes.toString("utf8");
|
|
277
|
+
if (!Buffer.from(text, "utf8").equals(bytes)) {
|
|
278
|
+
throw new ToolError(`${file} is not valid UTF-8; refusing to edit it`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let matches = countOccurrences(text, oldString);
|
|
282
|
+
// A CRLF file edited with LF snippets: match the file's own line endings.
|
|
283
|
+
if (matches === 0 && text.includes("\r\n") && oldString.includes("\n") && !oldString.includes("\r\n")) {
|
|
284
|
+
const crlf = (value) => value.replace(/\r?\n/g, "\r\n");
|
|
285
|
+
oldString = crlf(oldString);
|
|
286
|
+
newString = crlf(newString);
|
|
287
|
+
matches = countOccurrences(text, oldString);
|
|
288
|
+
}
|
|
289
|
+
if (matches === 0) {
|
|
290
|
+
throw new ToolError(`old_string was not found in ${file}`);
|
|
291
|
+
}
|
|
292
|
+
if (matches > 1 && replaceAll !== true) {
|
|
293
|
+
throw new ToolError(
|
|
294
|
+
`old_string matches ${matches} places in ${file}; add surrounding context to make it unique, or set replace_all`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const firstIndex = text.indexOf(oldString);
|
|
299
|
+
const updated = replaceAll === true
|
|
300
|
+
? text.split(oldString).join(newString)
|
|
301
|
+
: text.slice(0, firstIndex) + newString + text.slice(firstIndex + oldString.length);
|
|
302
|
+
await fsp.writeFile(local, updated);
|
|
303
|
+
|
|
304
|
+
// Optimistic concurrency: refuse to overwrite a change made since we read.
|
|
305
|
+
const current = await remoteSha256(file);
|
|
306
|
+
if (current !== original.sha256) {
|
|
307
|
+
throw new ToolError(`${file} changed on ${host} while it was being edited; read it again and retry`);
|
|
308
|
+
}
|
|
309
|
+
await fsp.chmod(local, Number.isInteger(original.mode) ? original.mode : DEFAULT_FILE_MODE);
|
|
310
|
+
await upload(local, file);
|
|
311
|
+
|
|
312
|
+
const replaced = replaceAll === true ? matches : 1;
|
|
313
|
+
const firstLine = text.slice(0, firstIndex).split("\n").length;
|
|
314
|
+
const updatedLines = updated.split("\n");
|
|
315
|
+
if (updated.endsWith("\n")) updatedLines.pop();
|
|
316
|
+
const from = Math.max(1, firstLine - 3);
|
|
317
|
+
const to = Math.min(updatedLines.length, firstLine + newString.split("\n").length + 2);
|
|
318
|
+
return (
|
|
319
|
+
`Edited ${file} on ${host}: replaced ${replaced} occurrence${replaced === 1 ? "" : "s"}.\n` +
|
|
320
|
+
formatNumbered(updatedLines.slice(from - 1, to), from)
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function write({ file_path: filePath, content } = {}) {
|
|
326
|
+
const file = resolvePath(filePath);
|
|
327
|
+
const body = requireString(content, "content", { allowEmpty: true });
|
|
328
|
+
const prepared = await runScript(PREPARE_WRITE_SCRIPT, [file], { what: `preparing ${file}` });
|
|
329
|
+
const modeText = String(prepared.stdoutTail || "").trim();
|
|
330
|
+
const existed = /^[0-7]{3,4}$/.test(modeText);
|
|
331
|
+
const mode = existed ? Number.parseInt(modeText, 8) & 0o777 : DEFAULT_FILE_MODE;
|
|
332
|
+
|
|
333
|
+
return withTempDir(async (dir) => {
|
|
334
|
+
const local = path.join(dir, "file");
|
|
335
|
+
await fsp.writeFile(local, body);
|
|
336
|
+
await fsp.chmod(local, mode);
|
|
337
|
+
await upload(local, file);
|
|
338
|
+
return `${existed ? "Updated" : "Created"} ${file} on ${host} (${Buffer.byteLength(body)} bytes).`;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function resolveSearchTool() {
|
|
343
|
+
if (!searchTool) {
|
|
344
|
+
searchTool = runScript(TOOLS_SCRIPT, [], { what: "probing for ripgrep" })
|
|
345
|
+
.then((run) => (String(run.stdoutTail || "").trim() === "rg" ? "rg" : "grep"))
|
|
346
|
+
.catch((error) => {
|
|
347
|
+
searchTool = null;
|
|
348
|
+
throw error;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return searchTool;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Run a search command on `target` and return one page, with absolute paths. */
|
|
355
|
+
async function page(command, { target, offset, limit, nozero = false }) {
|
|
356
|
+
const run = await runScript(
|
|
357
|
+
PAGE_SCRIPT,
|
|
358
|
+
[offset, limit, OUTPUT_BUDGET_BYTES, nozero ? 1 : 0, target, ...command],
|
|
359
|
+
{ what: `searching ${target}` },
|
|
360
|
+
);
|
|
361
|
+
const { header, body } = splitHeader(String(run.stdoutTail || ""));
|
|
362
|
+
const [kind, count] = header.split(" ");
|
|
363
|
+
const total = Number.parseInt(count, 10) || 0;
|
|
364
|
+
const dir = kind === "f" ? pathApi.dirname(target) : target;
|
|
365
|
+
const prefix = dir.endsWith(pathApi.sep) ? dir : `${dir}${pathApi.sep}`;
|
|
366
|
+
// `--` separates context groups; everything else starts with a path that
|
|
367
|
+
// is relative to `dir` (`./x` from GNU tools and rg, bare `x` from BSD grep).
|
|
368
|
+
const lines = (total > offset ? splitPage(body, OUTPUT_BUDGET_BYTES) : [])
|
|
369
|
+
.map((line) => (line === "--" || pathApi.isAbsolute(line) ? line : prefix + line.replace(/^\.\//, "")));
|
|
370
|
+
return { total, lines };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function pageFooter(offset, shown, total) {
|
|
374
|
+
if (offset + shown >= total) return "";
|
|
375
|
+
return `\n\n[Showing results ${offset + 1}-${offset + shown} of ${total}. Continue with offset=${offset + shown}.]`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function grep(input = {}) {
|
|
379
|
+
const pattern = requireString(input.pattern, "pattern");
|
|
380
|
+
const searchPath = input.path === undefined ? cwd : resolvePath(input.path, "path");
|
|
381
|
+
const mode = input.output_mode ?? "files_with_matches";
|
|
382
|
+
if (!["files_with_matches", "content", "count"].includes(mode)) {
|
|
383
|
+
throw new ToolError("output_mode must be one of files_with_matches, content, count");
|
|
384
|
+
}
|
|
385
|
+
const offset = toInteger(input.offset, "offset", { min: 0, fallback: 0 });
|
|
386
|
+
const limit = toInteger(input.head_limit, "head_limit", { min: 1, max: SEARCH_MAX_LIMIT, fallback: SEARCH_DEFAULT_LIMIT });
|
|
387
|
+
const context = toInteger(input.context, "context", { min: 0, max: 20, fallback: 0 });
|
|
388
|
+
const tool = await resolveSearchTool();
|
|
389
|
+
|
|
390
|
+
let command;
|
|
391
|
+
const notes = [];
|
|
392
|
+
if (tool === "rg") {
|
|
393
|
+
command = ["rg", "--color=never", "--no-messages", "--hidden", "--glob", "!.git"];
|
|
394
|
+
if (mode === "files_with_matches") command.push("--files-with-matches", "--sortr=modified");
|
|
395
|
+
if (mode === "count") command.push("--count", "--with-filename", "--sort=path");
|
|
396
|
+
if (mode === "content") {
|
|
397
|
+
command.push("--line-number", "--with-filename", "--no-heading", "--max-columns=500", "--max-columns-preview", "--sort=path");
|
|
398
|
+
if (context > 0) command.push("--context", String(context));
|
|
399
|
+
}
|
|
400
|
+
if (input.case_insensitive === true) command.push("--ignore-case");
|
|
401
|
+
if (input.glob) command.push("--glob", requireString(input.glob, "glob"));
|
|
402
|
+
if (input.type) command.push("--type", requireString(input.type, "type"));
|
|
403
|
+
command.push("-e", pattern, "--");
|
|
404
|
+
} else {
|
|
405
|
+
notes.push(`[ripgrep is not installed on ${host}; used grep -E, which ignores .gitignore]`);
|
|
406
|
+
command = ["grep", "-r", "-I", "-E", "--exclude-dir=.git"];
|
|
407
|
+
if (mode === "files_with_matches") command.push("-l");
|
|
408
|
+
if (mode === "count") command.push("-c", "-H");
|
|
409
|
+
if (mode === "content") {
|
|
410
|
+
command.push("-n", "-H");
|
|
411
|
+
if (context > 0) command.push("-C", String(context));
|
|
412
|
+
}
|
|
413
|
+
if (input.case_insensitive === true) command.push("-i");
|
|
414
|
+
if (input.glob) command.push(`--include=${requireString(input.glob, "glob")}`);
|
|
415
|
+
if (input.type) notes.push("[type is not supported without ripgrep and was ignored]");
|
|
416
|
+
command.push("-e", pattern, "--");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const { total, lines } = await page(command, {
|
|
420
|
+
target: searchPath, offset, limit, nozero: tool === "grep" && mode === "count",
|
|
421
|
+
});
|
|
422
|
+
const prefix = notes.length > 0 ? `${notes.join("\n")}\n` : "";
|
|
423
|
+
if (total === 0) return `${prefix}No matches found.`;
|
|
424
|
+
if (lines.length === 0) return `${prefix}offset ${offset} is past the last result (${total} results).`;
|
|
425
|
+
const heading = mode === "files_with_matches" ? `Found ${total} file${total === 1 ? "" : "s"}\n` : "";
|
|
426
|
+
return `${prefix}${heading}${lines.join("\n")}${pageFooter(offset, lines.length, total)}`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function glob(input = {}) {
|
|
430
|
+
const searchPath = input.path === undefined ? cwd : resolvePath(input.path, "path");
|
|
431
|
+
let pattern = requireString(input.pattern, "pattern");
|
|
432
|
+
// Globs match relative to the search path, so an absolute one never would.
|
|
433
|
+
if (pathApi.isAbsolute(pattern)) {
|
|
434
|
+
const relative = pathApi.relative(searchPath, pattern);
|
|
435
|
+
if (!relative || relative.startsWith("..") || pathApi.isAbsolute(relative)) {
|
|
436
|
+
throw new ToolError(`pattern ${pattern} is not under ${searchPath}`);
|
|
437
|
+
}
|
|
438
|
+
pattern = relative;
|
|
439
|
+
}
|
|
440
|
+
const offset = toInteger(input.offset, "offset", { min: 0, fallback: 0 });
|
|
441
|
+
const limit = toInteger(input.head_limit, "head_limit", { min: 1, max: SEARCH_MAX_LIMIT, fallback: SEARCH_DEFAULT_LIMIT });
|
|
442
|
+
const tool = await resolveSearchTool();
|
|
443
|
+
const command = tool === "rg"
|
|
444
|
+
? ["rg", "--files", "--hidden", "--glob", "!.git", "--glob", pattern, "--sortr=modified", "--color=never", "--"]
|
|
445
|
+
: ["bash", "-c", GLOB_FALLBACK_SCRIPT, "conductor-mcp-glob", pattern];
|
|
446
|
+
const { total, lines } = await page(command, { target: searchPath, offset, limit });
|
|
447
|
+
if (total === 0) return "No files found.";
|
|
448
|
+
if (lines.length === 0) return `offset ${offset} is past the last result (${total} files).`;
|
|
449
|
+
return `${lines.join("\n")}${pageFooter(offset, lines.length, total)}`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function bash(input = {}) {
|
|
453
|
+
const timeoutMs = toInteger(input.timeout_ms, "timeout_ms", {
|
|
454
|
+
min: 1000, max: BASH_MAX_TIMEOUT_MS, fallback: BASH_DEFAULT_TIMEOUT_MS,
|
|
455
|
+
});
|
|
456
|
+
let run;
|
|
457
|
+
if (input.run_id !== undefined) {
|
|
458
|
+
run = await wait(requireString(input.run_id, "run_id"), timeoutMs);
|
|
459
|
+
} else {
|
|
460
|
+
const command = requireString(input.command, "command");
|
|
461
|
+
try {
|
|
462
|
+
run = await exec({ command: "bash", args: ["-lc", command], workspace: cwd, timeoutMs });
|
|
463
|
+
} catch (error) {
|
|
464
|
+
throw new ToolError(
|
|
465
|
+
`${error.message} (remote_bash runs in ${cwd} on ${host}; if the task worktree has not been created yet, create it first)`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const parts = [];
|
|
471
|
+
if (run?.stdoutTail) parts.push(run.stdoutTail.replace(/\n$/, ""));
|
|
472
|
+
if (run?.stderrTail) parts.push(`[stderr]\n${run.stderrTail.replace(/\n$/, "")}`);
|
|
473
|
+
if (run?.truncated) parts.push("[output truncated; showing the tail only]");
|
|
474
|
+
if (run?.error) parts.push(`[error] ${run.error}`);
|
|
475
|
+
if (run?.status === "running") {
|
|
476
|
+
parts.push(
|
|
477
|
+
`[still running on ${host} after ${Math.round(timeoutMs / 1000)}s; ` +
|
|
478
|
+
`call remote_bash with run_id="${run.runId}" to keep waiting]`,
|
|
479
|
+
);
|
|
480
|
+
return { text: parts.join("\n"), isError: false };
|
|
481
|
+
}
|
|
482
|
+
const exitCode = typeof run?.exitCode === "number" ? run.exitCode : null;
|
|
483
|
+
parts.push(`[exit code ${exitCode ?? run?.status ?? "unknown"}]`);
|
|
484
|
+
return { text: parts.join("\n"), isError: exitCode !== 0 };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return { host, root, cwd, resolvePath, read, edit, write, grep, glob, bash };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Tool catalogue
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
export function buildRemoteWorkspaceTools(workspace) {
|
|
495
|
+
const where = `on daemon "${workspace.host}"`;
|
|
496
|
+
const pathNote = `Relative paths resolve against ${workspace.cwd}; paths outside ${workspace.root} are refused.`;
|
|
497
|
+
return [
|
|
498
|
+
{
|
|
499
|
+
name: "remote_read",
|
|
500
|
+
description: `Read a file in the remote workspace ${where}, with line numbers (cat -n format). ${pathNote}`,
|
|
501
|
+
inputSchema: {
|
|
502
|
+
type: "object",
|
|
503
|
+
properties: {
|
|
504
|
+
file_path: { type: "string", description: "File to read" },
|
|
505
|
+
offset: { type: "integer", minimum: 1, description: "Line number to start at (default 1)" },
|
|
506
|
+
limit: { type: "integer", minimum: 1, description: `Number of lines to read (default ${READ_DEFAULT_LIMIT})` },
|
|
507
|
+
},
|
|
508
|
+
required: ["file_path"],
|
|
509
|
+
additionalProperties: false,
|
|
510
|
+
},
|
|
511
|
+
handler: workspace.read,
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "remote_edit",
|
|
515
|
+
description:
|
|
516
|
+
`Replace an exact string in a file in the remote workspace ${where}. Fails without writing if ` +
|
|
517
|
+
`old_string is missing or matches more than once (unless replace_all). ${pathNote}`,
|
|
518
|
+
inputSchema: {
|
|
519
|
+
type: "object",
|
|
520
|
+
properties: {
|
|
521
|
+
file_path: { type: "string", description: "File to edit" },
|
|
522
|
+
old_string: { type: "string", description: "Exact text to replace" },
|
|
523
|
+
new_string: { type: "string", description: "Replacement text" },
|
|
524
|
+
replace_all: { type: "boolean", description: "Replace every occurrence (default false)" },
|
|
525
|
+
},
|
|
526
|
+
required: ["file_path", "old_string", "new_string"],
|
|
527
|
+
additionalProperties: false,
|
|
528
|
+
},
|
|
529
|
+
handler: workspace.edit,
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: "remote_write",
|
|
533
|
+
description: `Create or overwrite a file in the remote workspace ${where}; parent directories are created. ${pathNote}`,
|
|
534
|
+
inputSchema: {
|
|
535
|
+
type: "object",
|
|
536
|
+
properties: {
|
|
537
|
+
file_path: { type: "string", description: "File to write" },
|
|
538
|
+
content: { type: "string", description: "Full file content" },
|
|
539
|
+
},
|
|
540
|
+
required: ["file_path", "content"],
|
|
541
|
+
additionalProperties: false,
|
|
542
|
+
},
|
|
543
|
+
handler: workspace.write,
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
name: "remote_grep",
|
|
547
|
+
description:
|
|
548
|
+
`Search file contents in the remote workspace ${where} with ripgrep regex syntax. ` +
|
|
549
|
+
`Results are paged: pass offset to continue. ${pathNote}`,
|
|
550
|
+
inputSchema: {
|
|
551
|
+
type: "object",
|
|
552
|
+
properties: {
|
|
553
|
+
pattern: { type: "string", description: "Regular expression" },
|
|
554
|
+
path: { type: "string", description: "File or directory to search (default: the work dir)" },
|
|
555
|
+
glob: { type: "string", description: 'Only files matching this glob, e.g. "*.ts"' },
|
|
556
|
+
type: { type: "string", description: 'ripgrep file type, e.g. "js", "py"' },
|
|
557
|
+
output_mode: {
|
|
558
|
+
type: "string",
|
|
559
|
+
enum: ["files_with_matches", "content", "count"],
|
|
560
|
+
description: "files_with_matches (default), content (matching lines), or count",
|
|
561
|
+
},
|
|
562
|
+
case_insensitive: { type: "boolean" },
|
|
563
|
+
context: { type: "integer", minimum: 0, maximum: 20, description: "Lines of context in content mode" },
|
|
564
|
+
offset: { type: "integer", minimum: 0, description: "Results to skip (default 0)" },
|
|
565
|
+
head_limit: { type: "integer", minimum: 1, maximum: SEARCH_MAX_LIMIT, description: `Results per page (default ${SEARCH_DEFAULT_LIMIT})` },
|
|
566
|
+
},
|
|
567
|
+
required: ["pattern"],
|
|
568
|
+
additionalProperties: false,
|
|
569
|
+
},
|
|
570
|
+
handler: workspace.grep,
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
name: "remote_glob",
|
|
574
|
+
description: `Find files by glob pattern (e.g. "src/**/*.ts") in the remote workspace ${where}, newest first. ${pathNote}`,
|
|
575
|
+
inputSchema: {
|
|
576
|
+
type: "object",
|
|
577
|
+
properties: {
|
|
578
|
+
pattern: { type: "string", description: "Glob pattern" },
|
|
579
|
+
path: { type: "string", description: "Directory to search (default: the work dir)" },
|
|
580
|
+
offset: { type: "integer", minimum: 0, description: "Results to skip (default 0)" },
|
|
581
|
+
head_limit: { type: "integer", minimum: 1, maximum: SEARCH_MAX_LIMIT, description: `Results per page (default ${SEARCH_DEFAULT_LIMIT})` },
|
|
582
|
+
},
|
|
583
|
+
required: ["pattern"],
|
|
584
|
+
additionalProperties: false,
|
|
585
|
+
},
|
|
586
|
+
handler: workspace.glob,
|
|
587
|
+
},
|
|
588
|
+
{
|
|
589
|
+
name: "remote_bash",
|
|
590
|
+
description:
|
|
591
|
+
`Run a bash command in ${workspace.cwd} ${where} (git, builds, tests). Output keeps the last 64 000 ` +
|
|
592
|
+
`characters. A command still running at timeout_ms keeps going; call again with the returned run_id to wait more.`,
|
|
593
|
+
inputSchema: {
|
|
594
|
+
type: "object",
|
|
595
|
+
properties: {
|
|
596
|
+
command: { type: "string", description: "Command to run with bash -lc" },
|
|
597
|
+
timeout_ms: {
|
|
598
|
+
type: "integer",
|
|
599
|
+
minimum: 1000,
|
|
600
|
+
maximum: BASH_MAX_TIMEOUT_MS,
|
|
601
|
+
description: `How long to wait (default ${BASH_DEFAULT_TIMEOUT_MS})`,
|
|
602
|
+
},
|
|
603
|
+
run_id: { type: "string", description: "Keep waiting for a command that was still running" },
|
|
604
|
+
},
|
|
605
|
+
additionalProperties: false,
|
|
606
|
+
},
|
|
607
|
+
handler: workspace.bash,
|
|
608
|
+
},
|
|
609
|
+
];
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function buildServerInstructions(workspace) {
|
|
613
|
+
return (
|
|
614
|
+
`The task workspace lives on daemon "${workspace.host}" at ${workspace.cwd}, not on this machine. ` +
|
|
615
|
+
"Use these remote_* tools to read, edit, write and search files there, and remote_bash for git, builds and tests. " +
|
|
616
|
+
"Local file tools and local shell commands do not see that workspace."
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// MCP framing (JSON-RPC 2.0 over newline-delimited stdio)
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
export function createMcpServer({ tools, instructions, name = MCP_SERVER_NAME, version = CLI_VERSION }) {
|
|
625
|
+
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
626
|
+
|
|
627
|
+
async function callTool(params) {
|
|
628
|
+
const tool = byName.get(params?.name);
|
|
629
|
+
if (!tool) {
|
|
630
|
+
return { content: [{ type: "text", text: `unknown tool: ${params?.name}` }], isError: true };
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const output = await tool.handler(params?.arguments ?? {});
|
|
634
|
+
const { text, isError = false } = typeof output === "string" ? { text: output } : output;
|
|
635
|
+
return { content: [{ type: "text", text }], isError };
|
|
636
|
+
} catch (error) {
|
|
637
|
+
return { content: [{ type: "text", text: `Error: ${error?.message || error}` }], isError: true };
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async function dispatch(method, params) {
|
|
642
|
+
switch (method) {
|
|
643
|
+
case "initialize": {
|
|
644
|
+
const requested = params?.protocolVersion;
|
|
645
|
+
return {
|
|
646
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(requested) ? requested : PROTOCOL_VERSIONS[0],
|
|
647
|
+
capabilities: { tools: { listChanged: false } },
|
|
648
|
+
serverInfo: { name, version },
|
|
649
|
+
...(instructions ? { instructions } : {}),
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
case "ping":
|
|
653
|
+
return {};
|
|
654
|
+
case "tools/list":
|
|
655
|
+
return {
|
|
656
|
+
tools: tools.map(({ name: toolName, description, inputSchema }) => ({ name: toolName, description, inputSchema })),
|
|
657
|
+
};
|
|
658
|
+
case "tools/call":
|
|
659
|
+
return callTool(params);
|
|
660
|
+
default: {
|
|
661
|
+
const error = new Error(`method not found: ${method}`);
|
|
662
|
+
error.code = -32601;
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** Returns the response to write, or null for a notification. */
|
|
669
|
+
async function handleMessage(message) {
|
|
670
|
+
if (!message || typeof message !== "object" || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
|
|
671
|
+
if (message && typeof message === "object" && ("result" in message || "error" in message)) {
|
|
672
|
+
return null; // a response to a request we never send
|
|
673
|
+
}
|
|
674
|
+
return { jsonrpc: "2.0", id: message?.id ?? null, error: { code: -32600, message: "invalid request" } };
|
|
675
|
+
}
|
|
676
|
+
const isNotification = message.id === undefined || message.id === null;
|
|
677
|
+
if (isNotification) return null;
|
|
678
|
+
try {
|
|
679
|
+
return { jsonrpc: "2.0", id: message.id, result: await dispatch(message.method, message.params) };
|
|
680
|
+
} catch (error) {
|
|
681
|
+
return {
|
|
682
|
+
jsonrpc: "2.0",
|
|
683
|
+
id: message.id,
|
|
684
|
+
error: { code: typeof error?.code === "number" ? error.code : -32603, message: error?.message || String(error) },
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return { handleMessage };
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/** Serve until `input` ends. Requests run concurrently; responses go out as they finish. */
|
|
693
|
+
export async function serveStdio(server, { input = process.stdin, output = process.stdout } = {}) {
|
|
694
|
+
const lines = readline.createInterface({ input, crlfDelay: Infinity });
|
|
695
|
+
const inFlight = new Set();
|
|
696
|
+
const send = (message) => {
|
|
697
|
+
if (message) output.write(`${JSON.stringify(message)}\n`);
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
for await (const line of lines) {
|
|
701
|
+
if (!line.trim()) continue;
|
|
702
|
+
let message;
|
|
703
|
+
try {
|
|
704
|
+
message = JSON.parse(line);
|
|
705
|
+
} catch {
|
|
706
|
+
send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const pending = server.handleMessage(message).then(send, (error) => {
|
|
710
|
+
send({ jsonrpc: "2.0", id: message?.id ?? null, error: { code: -32603, message: String(error?.message || error) } });
|
|
711
|
+
});
|
|
712
|
+
inFlight.add(pending);
|
|
713
|
+
pending.finally(() => inFlight.delete(pending));
|
|
714
|
+
}
|
|
715
|
+
await Promise.allSettled([...inFlight]);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---------------------------------------------------------------------------
|
|
719
|
+
// CLI
|
|
720
|
+
// ---------------------------------------------------------------------------
|
|
721
|
+
|
|
722
|
+
const VALUE_FLAGS = new Map([
|
|
723
|
+
["--host", "host"],
|
|
724
|
+
["--target", "host"],
|
|
725
|
+
["-t", "host"],
|
|
726
|
+
["--root", "root"],
|
|
727
|
+
["--cwd", "cwd"],
|
|
728
|
+
["--config-file", "configFile"],
|
|
729
|
+
]);
|
|
730
|
+
|
|
731
|
+
export function parseMcpArgs(argv) {
|
|
732
|
+
const options = { help: false };
|
|
733
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
734
|
+
const token = argv[index];
|
|
735
|
+
if (token === "--help" || token === "-h") {
|
|
736
|
+
options.help = true;
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const splitAt = token.startsWith("--") ? token.indexOf("=") : -1;
|
|
740
|
+
const name = splitAt > 0 ? token.slice(0, splitAt) : token;
|
|
741
|
+
if (!VALUE_FLAGS.has(name)) {
|
|
742
|
+
throw new UsageError(`unknown option: ${token}`);
|
|
743
|
+
}
|
|
744
|
+
const value = splitAt > 0 ? token.slice(splitAt + 1) : argv[index + 1];
|
|
745
|
+
if (value === undefined || value === "") {
|
|
746
|
+
throw new UsageError(`${name} requires a value`);
|
|
747
|
+
}
|
|
748
|
+
options[VALUE_FLAGS.get(name)] = value;
|
|
749
|
+
if (splitAt <= 0) index += 1;
|
|
750
|
+
}
|
|
751
|
+
return options;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export function showHelp(consoleImpl = console) {
|
|
755
|
+
consoleImpl.log(`conductor remote mcp - serve MCP tools bound to a directory on another daemon
|
|
756
|
+
|
|
757
|
+
Usage:
|
|
758
|
+
conductor remote mcp --host <daemon> --root <dir> [--cwd <dir>] [--config-file <path>]
|
|
759
|
+
|
|
760
|
+
Options:
|
|
761
|
+
-t, --host <daemon> Daemon the workspace lives on (required)
|
|
762
|
+
--root <dir> Absolute directory on that daemon; no tool may leave it (required)
|
|
763
|
+
--cwd <dir> Where relative paths and remote_bash run (default: --root)
|
|
764
|
+
--config-file <p> Conductor config file to authenticate with
|
|
765
|
+
-h, --help Show this help
|
|
766
|
+
|
|
767
|
+
Speaks MCP over stdin/stdout. Tools: remote_read, remote_edit, remote_write,
|
|
768
|
+
remote_grep, remote_glob, remote_bash. Fire starts it automatically for tasks
|
|
769
|
+
whose worktree lives on another daemon.
|
|
770
|
+
`);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export async function runRemoteMcp(argv, deps = {}) {
|
|
774
|
+
const consoleImpl = deps.console || console;
|
|
775
|
+
const env = deps.env || process.env;
|
|
776
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
777
|
+
|
|
778
|
+
let options;
|
|
779
|
+
try {
|
|
780
|
+
options = parseMcpArgs(argv);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
783
|
+
return EXIT.CLI_ERROR;
|
|
784
|
+
}
|
|
785
|
+
if (options.help) {
|
|
786
|
+
// stdout is the protocol channel only when serving; help is fine there.
|
|
787
|
+
showHelp(consoleImpl);
|
|
788
|
+
return EXIT.OK;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const host = typeof options.host === "string" ? options.host.trim() : "";
|
|
792
|
+
const root = typeof options.root === "string" ? options.root.trim() : "";
|
|
793
|
+
if (!host || !root) {
|
|
794
|
+
consoleImpl.error("Error: --host <daemon> and --root <dir> are required");
|
|
795
|
+
return EXIT.CLI_ERROR;
|
|
796
|
+
}
|
|
797
|
+
const pathApi = isWindowsStylePath(root) ? path.win32 : path.posix;
|
|
798
|
+
if (!pathApi.isAbsolute(root) || (options.cwd && !pathApi.isAbsolute(options.cwd))) {
|
|
799
|
+
consoleImpl.error("Error: --root and --cwd must be absolute paths on the target");
|
|
800
|
+
return EXIT.CLI_ERROR;
|
|
801
|
+
}
|
|
802
|
+
if (options.cwd) {
|
|
803
|
+
const relative = pathApi.relative(pathApi.normalize(root), pathApi.normalize(options.cwd));
|
|
804
|
+
if (relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)) {
|
|
805
|
+
consoleImpl.error("Error: --cwd must be inside --root");
|
|
806
|
+
return EXIT.CLI_ERROR;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
let config;
|
|
811
|
+
try {
|
|
812
|
+
config = deps.config || loadCliConfig(options.configFile, env);
|
|
813
|
+
} catch (error) {
|
|
814
|
+
consoleImpl.error(`Error: ${error.message}`);
|
|
815
|
+
return EXIT.CLI_ERROR;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Everything below must stay off stdout: it is the MCP channel.
|
|
819
|
+
const quietConsole = { log: () => {}, error: () => {} };
|
|
820
|
+
const basePath = `/api/agents/${encodeURIComponent(host)}/exec`;
|
|
821
|
+
const workspace = createRemoteWorkspace({
|
|
822
|
+
host,
|
|
823
|
+
root,
|
|
824
|
+
cwd: options.cwd,
|
|
825
|
+
exec: async ({ command, args, workspace: dir, timeoutMs }) =>
|
|
826
|
+
(await execRemote(config, host, command, { args, workspace: dir, timeoutMs, fetchImpl })).run,
|
|
827
|
+
wait: async (runId, timeoutMs) =>
|
|
828
|
+
(await waitForRun(config, basePath, { runId, status: "running" }, {
|
|
829
|
+
deadline: Date.now() + timeoutMs, fetchImpl,
|
|
830
|
+
})).run,
|
|
831
|
+
download: (remotePath, localPath) =>
|
|
832
|
+
downloadFile({
|
|
833
|
+
config, target: host, remotePath, localPath, fetchImpl, consoleImpl: quietConsole, quiet: true,
|
|
834
|
+
deadline: Date.now() + FILE_OP_TIMEOUT_MS, onProgress: () => {},
|
|
835
|
+
}),
|
|
836
|
+
upload: (localPath, remotePath) =>
|
|
837
|
+
uploadFile({
|
|
838
|
+
config, target: host, localPath, remotePath, fetchImpl, consoleImpl: quietConsole, quiet: true,
|
|
839
|
+
env, deadline: Date.now() + FILE_OP_TIMEOUT_MS, onProgress: () => {},
|
|
840
|
+
}),
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const server = createMcpServer({
|
|
844
|
+
tools: buildRemoteWorkspaceTools(workspace),
|
|
845
|
+
instructions: buildServerInstructions(workspace),
|
|
846
|
+
});
|
|
847
|
+
await serveStdio(server, { input: deps.stdin || process.stdin, output: deps.stdout || process.stdout });
|
|
848
|
+
return EXIT.OK;
|
|
849
|
+
}
|