Files
AutoDL-Remote/tests/test-autodl-remote-mcp.mjs

560 lines
16 KiB
JavaScript

import assert from "node:assert/strict";
import { chmod, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
import readline from "node:readline";
import test from "node:test";
const repoRoot = path.resolve(import.meta.dirname, "..");
const serverPath = path.join(
repoRoot,
"plugins",
"autodl-remote",
"mcp",
"server.mjs",
);
class McpClient {
constructor(child) {
this.child = child;
this.nextId = 1;
this.pending = new Map();
this.stderr = "";
readline.createInterface({ input: child.stdout }).on("line", (line) => {
const message = JSON.parse(line);
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (message.error) pending.reject(new Error(JSON.stringify(message.error)));
else pending.resolve(message.result);
});
child.stderr.on("data", (chunk) => {
this.stderr += chunk;
});
child.on("exit", (code) => {
for (const pending of this.pending.values()) {
pending.reject(
new Error(`MCP server exited with ${code}: ${this.stderr.trim()}`),
);
}
this.pending.clear();
});
}
request(method, params = {}) {
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.child.stdin.write(
`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
);
});
}
close() {
this.child.stdin.end();
}
}
async function createRecordingCli(tempDir) {
const fakeCli = path.join(tempDir, "autodl-remote-recording");
await writeFile(
fakeCli,
[
"#!/usr/bin/env node",
"console.log(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd() }));",
"",
].join("\n"),
);
await chmod(fakeCli, 0o755);
return fakeCli;
}
function startClient(fakeCli, env = {}) {
const child = spawn(process.execPath, [serverPath], {
cwd: repoRoot,
env: { ...process.env, AUTODL_REMOTE_MCP_CLI: fakeCli, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
return new McpClient(child);
}
async function call(client, name, args) {
const result = await client.request("tools/call", {
name,
arguments: args,
});
assert.equal(result.isError, false);
return JSON.parse(result.structuredContent.stdout);
}
test("MCP exposes account list through the existing CLI", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const fakeCli = path.join(tempDir, "autodl-remote");
await writeFile(
fakeCli,
"#!/usr/bin/env bash\nset -euo pipefail\nprintf '* gpu-a\\n gpu-b\\n'\n",
);
await chmod(fakeCli, 0o755);
const child = spawn(process.execPath, [serverPath], {
cwd: repoRoot,
env: { ...process.env, AUTODL_REMOTE_MCP_CLI: fakeCli },
stdio: ["pipe", "pipe", "pipe"],
});
const client = new McpClient(child);
t.after(() => client.close());
const initialized = await client.request("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
});
assert.equal(initialized.serverInfo.version, "0.9.0");
const listed = await client.request("tools/list");
assert.ok(listed.tools.some((tool) => tool.name === "autodl_account"));
const called = await client.request("tools/call", {
name: "autodl_account",
arguments: { action: "list" },
});
assert.equal(called.isError, false);
assert.equal(called.structuredContent.ok, true);
assert.equal(called.structuredContent.command, "account list");
assert.match(called.structuredContent.stdout, /gpu-a/);
});
test("MCP maps experiment, fleet, tmux, dashboard, and shutdown workflows", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-workflows-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const projectRoot = path.join(tempDir, "project");
await mkdir(projectRoot);
const client = startClient(await createRecordingCli(tempDir));
t.after(() => client.close());
const run = await call(client, "autodl_run", {
action: "submit",
projectRoot,
argv: ["python", "train.py"],
syncUp: ["src", "configs/base.yaml"],
tmux: true,
name: "baseline-a",
model: "qwen",
tags: ["baseline"],
});
assert.deepEqual(run.args, [
"run",
"submit",
"--sync-up",
"src",
"--sync-up",
"configs/base.yaml",
"--tmux",
"--name",
"baseline-a",
"--model",
"qwen",
"--tag",
"baseline",
"--",
"python",
"train.py",
]);
const jobTail = await call(client, "autodl_job", {
action: "tail",
projectRoot,
name: "baseline-a",
lines: 40,
});
assert.deepEqual(jobTail.args, ["job", "tail", "--lines", "40", "baseline-a"]);
const fleet = await call(client, "autodl_fleet", {
action: "add",
projectRoot,
fleet: "lab",
device: "gpu-1",
account: "gpu-a",
remote: "/root/workspace/a",
tags: "a100,80g",
});
assert.deepEqual(fleet.args, [
"fleet",
"add",
"lab",
"gpu-1",
"--account",
"gpu-a",
"--remote",
"/root/workspace/a",
"--tags",
"a100,80g",
]);
const captured = await call(client, "autodl_tmux", {
action: "capture",
projectRoot,
session: "baseline-a",
lines: 25,
});
assert.deepEqual(captured.args, [
"tmux",
"capture",
"--lines",
"25",
"--",
"baseline-a",
]);
const dashboard = await call(client, "autodl_dashboard", {
projectRoot,
fleet: "lab",
output: "reports/fleet.html",
lines: 60,
});
assert.deepEqual(dashboard.args, [
"dashboard",
"--fleet",
"lab",
"--output",
"reports/fleet.html",
"--lines",
"60",
]);
const shutdown = await call(client, "autodl_shutdown", {
projectRoot,
confirm: "shutdown",
waitSeconds: 3,
});
assert.deepEqual(shutdown.args, ["shutdown", "--wait", "3"]);
});
test("MCP requires explicit confirmation for destructive operations", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-safety-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const projectRoot = path.join(tempDir, "project");
await mkdir(projectRoot);
const client = startClient(await createRecordingCli(tempDir));
t.after(() => client.close());
await assert.rejects(
client.request("tools/call", {
name: "autodl_job",
arguments: { action: "stop", projectRoot, name: "train-a", confirm: "train-b" },
}),
/exactly match name/,
);
await assert.rejects(
client.request("tools/call", {
name: "autodl_exec",
arguments: {
projectRoot,
argv: ["shutdown"],
allowDangerous: true,
},
}),
/confirmDangerous=allow-dangerous/,
);
await assert.rejects(
client.request("tools/call", {
name: "autodl_transfer",
arguments: {
action: "upload",
projectRoot,
localPath: "src",
delete: true,
},
}),
/confirmDelete=delete-remote-extraneous/,
);
await assert.rejects(
client.request("tools/call", {
name: "autodl_file",
arguments: { action: "read", projectRoot },
}),
/path is required/,
);
await assert.rejects(
client.request("tools/call", {
name: "autodl_tmux",
arguments: { action: "install", projectRoot },
}),
/confirm=install-tmux/,
);
await assert.rejects(
client.request("tools/call", {
name: "autodl_shutdown",
arguments: { projectRoot, confirm: "yes" },
}),
/confirm=shutdown/,
);
});
test("MCP keeps declared file operands inside the selected project", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-boundary-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const projectRoot = path.join(tempDir, "project");
await mkdir(projectRoot);
const outside = path.join(tempDir, "outside.txt");
await writeFile(outside, "secret");
await symlink(outside, path.join(projectRoot, "outside-link"));
const client = startClient(await createRecordingCli(tempDir));
t.after(() => client.close());
const rejectedCalls = [
["autodl_file", { action: "read", projectRoot, path: "/etc/passwd" }],
["autodl_file", { action: "tail", projectRoot, path: "../outside.txt" }],
["autodl_transfer", { action: "upload", projectRoot, localPath: "/etc/passwd" }],
["autodl_transfer", { action: "upload", projectRoot, localPath: "outside-link" }],
["autodl_transfer", { action: "download", projectRoot, remotePath: "runs/a", localPath: "../outside.txt" }],
["autodl_transfer", { action: "download", projectRoot, remotePath: "/etc/passwd", localPath: "copy" }],
["autodl_exec", { projectRoot, cwd: "../outside", argv: ["pwd"] }],
["autodl_run", { action: "submit", projectRoot, syncUp: ["../outside.txt"], argv: ["true"] }],
["autodl_dashboard", { projectRoot, output: "../outside.html" }],
];
for (const [name, arguments_] of rejectedCalls) {
await assert.rejects(
client.request("tools/call", { name, arguments: arguments_ }),
/project-relative|escape projectRoot/,
);
}
});
test("MCP rejects ambiguous names and negotiates a supported protocol version", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-canonical-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const projectRoot = path.join(tempDir, "project");
await mkdir(projectRoot);
const client = startClient(await createRecordingCli(tempDir));
t.after(() => client.close());
await assert.rejects(
client.request("tools/call", {
name: "autodl_job",
arguments: { action: "stop", projectRoot, name: "train prod", confirm: "train prod" },
}),
/canonical name/,
);
const initialized = await client.request("initialize", {
protocolVersion: "2099-01-01",
capabilities: {},
clientInfo: { name: "future", version: "1" },
});
assert.equal(initialized.protocolVersion, "2025-06-18");
});
test("MCP returns bounded structured output for CLI failures", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-output-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const projectRoot = path.join(tempDir, "project");
await mkdir(projectRoot);
const fakeCli = path.join(tempDir, "autodl-remote-failing");
await writeFile(
fakeCli,
[
"#!/usr/bin/env node",
"process.stdout.write('A'.repeat(100));",
"process.stderr.write('B'.repeat(100));",
"process.exitCode = 7;",
"",
].join("\n"),
);
await chmod(fakeCli, 0o755);
const client = startClient(fakeCli, { AUTODL_REMOTE_MCP_MAX_OUTPUT_BYTES: "16" });
t.after(() => client.close());
const result = await client.request("tools/call", {
name: "autodl_doctor",
arguments: { projectRoot },
});
assert.equal(result.isError, true);
assert.equal(result.structuredContent.ok, false);
assert.equal(result.structuredContent.exitCode, 7);
assert.equal(Buffer.byteLength(result.structuredContent.stdout), 16);
assert.equal(Buffer.byteLength(result.structuredContent.stderr), 16);
assert.equal(result.structuredContent.stdoutTruncated, true);
assert.equal(result.structuredContent.stderrTruncated, true);
});
test("MCP drains an in-flight CLI call after stdin reaches EOF", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-eof-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const fakeCli = path.join(tempDir, "autodl-remote-slow");
await writeFile(
fakeCli,
[
"#!/usr/bin/env node",
"setTimeout(() => console.log('drained'), 100);",
"",
].join("\n"),
);
await chmod(fakeCli, 0o755);
const child = spawn(process.execPath, [serverPath], {
cwd: repoRoot,
env: { ...process.env, AUTODL_REMOTE_MCP_CLI: fakeCli },
stdio: ["pipe", "pipe", "pipe"],
});
const lines = [];
readline.createInterface({ input: child.stdout }).on("line", (line) => {
lines.push(JSON.parse(line));
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.stdin.end(
`${JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "autodl_account", arguments: { action: "list" } },
})}\n`,
);
const exitCode = await new Promise((resolve) => child.on("exit", resolve));
assert.equal(exitCode, 0, stderr);
assert.equal(lines.length, 1);
assert.equal(lines[0].result.structuredContent.ok, true);
assert.match(lines[0].result.structuredContent.stdout, /drained/);
});
test("MCP exits cleanly when the client closes its response stream", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-epipe-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const child = spawn(process.execPath, [serverPath], {
cwd: repoRoot,
env: {
...process.env,
AUTODL_REMOTE_MCP_CLI: await createRecordingCli(tempDir),
},
stdio: ["pipe", "pipe", "pipe"],
});
let stderr = "";
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.stdout.destroy();
child.stdin.end(
`${JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "autodl_account", arguments: { action: "list" } },
})}\n`,
);
const exitCode = await new Promise((resolve) => child.on("exit", resolve));
assert.equal(exitCode, 0, stderr);
assert.doesNotMatch(stderr, /EPIPE/);
});
test("MCP standardizes common project, file, transfer, and execution commands", async (t) => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "autodl-mcp-map-"));
t.after(async () => rm(tempDir, { recursive: true, force: true }));
const projectRoot = path.join(tempDir, "project");
await mkdir(projectRoot);
const client = startClient(await createRecordingCli(tempDir));
t.after(() => client.close());
const listed = await client.request("tools/list");
assert.deepEqual(
listed.tools.map((tool) => tool.name),
[
"autodl_account",
"autodl_bind",
"autodl_doctor",
"autodl_model_dir",
"autodl_file",
"autodl_transfer",
"autodl_exec",
"autodl_job",
"autodl_run",
"autodl_fleet",
"autodl_tmux",
"autodl_dashboard",
"autodl_shutdown",
],
);
const bound = await call(client, "autodl_bind", {
projectRoot,
account: "gpu-a",
remote: "/root/workspace/demo",
force: true,
});
assert.deepEqual(bound.args, [
"bind",
"--account",
"gpu-a",
"--remote",
"/root/workspace/demo",
"--force",
]);
assert.equal(bound.cwd, await realpath(projectRoot));
const read = await call(client, "autodl_file", {
action: "read",
projectRoot,
path: "logs/train.log",
lines: 80,
account: "gpu-a",
});
assert.deepEqual(read.args, [
"cat",
"--account",
"gpu-a",
"--lines",
"80",
"--",
"logs/train.log",
]);
const uploaded = await call(client, "autodl_transfer", {
action: "upload",
projectRoot,
localPath: "src",
remotePath: "src",
dryRun: true,
delete: true,
confirmDelete: "delete-remote-extraneous",
});
assert.deepEqual(uploaded.args, [
"put",
"--dry-run",
"--delete",
"--",
"src",
"src",
]);
const executed = await call(client, "autodl_exec", {
projectRoot,
argv: ["bash", "-lc", "python train.py | tee train.log"],
mode: "tmux",
name: "train-a",
tags: ["baseline", "a100"],
outputs: ["runs/train-a"],
});
assert.deepEqual(executed.args, [
"exec",
"--tmux",
"--name",
"train-a",
"--tag",
"baseline",
"--tag",
"a100",
"--output",
"runs/train-a",
"--",
"bash",
"-lc",
"python train.py | tee train.log",
]);
});