feat(mcp): pass args to run-file
This commit is contained in:
parent
d35cb1b2ec
commit
abf28ba445
@ -30,6 +30,11 @@ Always go through the `just` recipes — they set repo-local state and the macOS
|
|||||||
- For one-shot automated probes prefer `just trapos-exec '<lua>'` / `just craftos-exec '<lua>'`: they always shut down and have a host watchdog, so they cannot hang the terminal.
|
- For one-shot automated probes prefer `just trapos-exec '<lua>'` / `just craftos-exec '<lua>'`: they always shut down and have a host watchdog, so they cannot hang the terminal.
|
||||||
- `just test [--pretty]` runs the full CraftOS-PC test suite (`__TRAPOS_TEST_OK__` on success).
|
- `just test [--pretty]` runs the full CraftOS-PC test suite (`__TRAPOS_TEST_OK__` on success).
|
||||||
|
|
||||||
|
## Running Connected Computers
|
||||||
|
|
||||||
|
- For connected TrapOS computers, prefer the MCP `run-file` tool for existing ComputerCraft programs such as `/programs/ccpm.lua`; pass CLI-style arguments with its `args` array, for example `path = "/programs/ccpm.lua", args = { "install", "pkg" }`. Do not use raw MCP Lua execution as a substitute for launching a program, because raw snippets may not provide globals such as `shell` or `require`.
|
||||||
|
- The MCP `run-file` tool path is a file path, not a shell command. Do not append CLI arguments to `path`; `/programs/ccpm.lua install pkg` is treated as a single missing file path.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
|
- `apis/eventloop.lua` is the single-threaded event loop around `os.pullEventRaw`; consider using it everywhere async behavior is needed. A handler that returns `api.STOP` auto-unregisters.
|
||||||
|
|||||||
@ -20,6 +20,56 @@ local function appendOutput(output, value)
|
|||||||
output[#output + 1] = tostring(value);
|
output[#output + 1] = tostring(value);
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function normalizeArgs(rawArgs)
|
||||||
|
if rawArgs == nil then
|
||||||
|
return true, {};
|
||||||
|
end
|
||||||
|
if type(rawArgs) ~= 'table' then
|
||||||
|
return false, 'args must be a table of strings';
|
||||||
|
end
|
||||||
|
|
||||||
|
local args = {};
|
||||||
|
for i = 1, #rawArgs do
|
||||||
|
local arg = rawArgs[i];
|
||||||
|
if type(arg) ~= 'string' then
|
||||||
|
return false, 'args must be a table of strings';
|
||||||
|
end
|
||||||
|
args[i] = arg;
|
||||||
|
end
|
||||||
|
return true, args;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function copyArgs(args)
|
||||||
|
local copy = {};
|
||||||
|
for i = 1, #args do
|
||||||
|
copy[i] = args[i];
|
||||||
|
end
|
||||||
|
return copy;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function dirname(path)
|
||||||
|
local dir = string.match(path, '^(.*)/[^/]*$');
|
||||||
|
if dir == nil then
|
||||||
|
return '';
|
||||||
|
end
|
||||||
|
if dir == '' then
|
||||||
|
return '/';
|
||||||
|
end
|
||||||
|
return dir;
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createShellProxy(shellLib, runningProgram)
|
||||||
|
if type(shellLib) ~= 'table' then
|
||||||
|
return nil;
|
||||||
|
end
|
||||||
|
|
||||||
|
return setmetatable({
|
||||||
|
getRunningProgram = function()
|
||||||
|
return runningProgram;
|
||||||
|
end,
|
||||||
|
}, { __index = shellLib });
|
||||||
|
end
|
||||||
|
|
||||||
local function serializeReturn(value)
|
local function serializeReturn(value)
|
||||||
local valueType = type(value);
|
local valueType = type(value);
|
||||||
if valueType == 'nil' then
|
if valueType == 'nil' then
|
||||||
@ -63,8 +113,10 @@ local function defaultRequireFactory()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createExecEnv(output, requireFactory, requireDir)
|
local function createExecEnv(output, requireFactory, requireDir, execOpts)
|
||||||
|
execOpts = execOpts or {};
|
||||||
local env = setmetatable({}, { __index = _G });
|
local env = setmetatable({}, { __index = _G });
|
||||||
|
env.arg = copyArgs(execOpts.args or {});
|
||||||
|
|
||||||
env.write = function(value)
|
env.write = function(value)
|
||||||
appendOutput(output, value);
|
appendOutput(output, value);
|
||||||
@ -89,18 +141,25 @@ local function createExecEnv(output, requireFactory, requireDir)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local shellProxy = createShellProxy(execOpts.shell, execOpts.runningProgram);
|
||||||
|
if shellProxy then
|
||||||
|
env.shell = shellProxy;
|
||||||
|
end
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
end
|
end
|
||||||
|
|
||||||
local function executeSource(code, chunkName, deps)
|
local function executeSource(code, chunkName, deps, execOpts)
|
||||||
|
execOpts = execOpts or {};
|
||||||
local output = {};
|
local output = {};
|
||||||
|
local args = execOpts.args or {};
|
||||||
|
|
||||||
local fn, loadErr = load(code, chunkName, 't', createExecEnv(output, deps.requireFactory, deps.requireDir));
|
local fn, loadErr = load(code, chunkName, 't', createExecEnv(output, deps.requireFactory, deps.requireDir, execOpts));
|
||||||
if not fn then
|
if not fn then
|
||||||
return { ok = false, error = tostring(loadErr), output = table.concat(output) };
|
return { ok = false, error = tostring(loadErr), output = table.concat(output) };
|
||||||
end
|
end
|
||||||
|
|
||||||
local values = table.pack(pcall(fn));
|
local values = table.pack(pcall(fn, table.unpack(args)));
|
||||||
if not values[1] then
|
if not values[1] then
|
||||||
return { ok = false, error = tostring(values[2]), output = table.concat(output) };
|
return { ok = false, error = tostring(values[2]), output = table.concat(output) };
|
||||||
end
|
end
|
||||||
@ -124,6 +183,7 @@ local function createMcpComputer(deps)
|
|||||||
local resolved = {
|
local resolved = {
|
||||||
requireFactory = requireFactory or nil,
|
requireFactory = requireFactory or nil,
|
||||||
requireDir = deps.requireDir or '',
|
requireDir = deps.requireDir or '',
|
||||||
|
shell = deps.shell or shell,
|
||||||
};
|
};
|
||||||
|
|
||||||
local api = {};
|
local api = {};
|
||||||
@ -144,11 +204,15 @@ local function createMcpComputer(deps)
|
|||||||
return executeSource(code, 'mcp-exec', resolved);
|
return executeSource(code, 'mcp-exec', resolved);
|
||||||
end
|
end
|
||||||
|
|
||||||
function api.executeFile(path, fsLike)
|
function api.executeFile(path, fsLike, rawArgs)
|
||||||
fsLike = fsLike or fs;
|
fsLike = fsLike or fs;
|
||||||
if type(path) ~= 'string' or path == '' then
|
if type(path) ~= 'string' or path == '' then
|
||||||
return { ok = false, error = 'path must be a non-empty string', output = '' };
|
return { ok = false, error = 'path must be a non-empty string', output = '' };
|
||||||
end
|
end
|
||||||
|
local argsOk, argsOrErr = normalizeArgs(rawArgs);
|
||||||
|
if not argsOk then
|
||||||
|
return { ok = false, error = argsOrErr, output = '' };
|
||||||
|
end
|
||||||
|
|
||||||
local handle, openErr = fsLike.open(path, 'r');
|
local handle, openErr = fsLike.open(path, 'r');
|
||||||
if not handle then
|
if not handle then
|
||||||
@ -169,7 +233,15 @@ local function createMcpComputer(deps)
|
|||||||
return { ok = false, error = tostring(closeErr), output = '' };
|
return { ok = false, error = tostring(closeErr), output = '' };
|
||||||
end
|
end
|
||||||
|
|
||||||
return executeSource(contentOrErr, '@' .. path, resolved);
|
local fileDeps = {
|
||||||
|
requireFactory = resolved.requireFactory,
|
||||||
|
requireDir = dirname(path),
|
||||||
|
};
|
||||||
|
return executeSource(contentOrErr, '@' .. path, fileDeps, {
|
||||||
|
args = argsOrErr,
|
||||||
|
runningProgram = path,
|
||||||
|
shell = resolved.shell,
|
||||||
|
});
|
||||||
end
|
end
|
||||||
|
|
||||||
function api.writeFile(path, content, fsLike)
|
function api.writeFile(path, content, fsLike)
|
||||||
|
|||||||
@ -35,9 +35,11 @@ The ComputerCraft side executes the code in `mcp-computer` and returns a normal
|
|||||||
- `result.output` containing captured `print` and `write` output.
|
- `result.output` containing captured `print` and `write` output.
|
||||||
- `error` for syntax or runtime failure.
|
- `error` for syntax or runtime failure.
|
||||||
|
|
||||||
For existing Lua files, `run-file` sends a target path instead of source code. The
|
For existing Lua files, `run-file` sends a target path instead of source code, with an
|
||||||
ComputerCraft side reads that file and loads the source into the same captured execution
|
optional `args` array for CLI-style program arguments. The ComputerCraft side reads that
|
||||||
environment as `exec-lua`, using the file path as the chunk name for useful errors.
|
file and loads the source into the same captured execution environment as `exec-lua`,
|
||||||
|
using the file path as the chunk name for useful errors. Arguments are passed as Lua
|
||||||
|
varargs and exposed through the `arg` table.
|
||||||
|
|
||||||
Execution is enabled by default for any computer running the updated `mcp-computer` program. We intentionally do not add an `--allow-exec` flag for this first version because the current workflow is a local, explicitly trusted development bridge and the user accepts the risk.
|
Execution is enabled by default for any computer running the updated `mcp-computer` program. We intentionally do not add an `--allow-exec` flag for this first version because the current workflow is a local, explicitly trusted development bridge and the user accepts the risk.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"trapos-net": "0.3.0",
|
"trapos-net": "0.3.0",
|
||||||
"trapos-ui": "0.2.2",
|
"trapos-ui": "0.2.2",
|
||||||
"trapos-ai": "0.7.0",
|
"trapos-ai": "0.7.0",
|
||||||
"trapos-sandbox": "0.3.2",
|
"trapos-sandbox": "0.3.3",
|
||||||
"trapos-sandbox-legacy": "0.3.0",
|
"trapos-sandbox-legacy": "0.3.0",
|
||||||
"trapos": "0.11.2"
|
"trapos": "0.11.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-sandbox",
|
"name": "trapos-sandbox",
|
||||||
"version": "0.3.2",
|
"version": "0.3.3",
|
||||||
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server",
|
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -25,7 +25,7 @@ if not el then
|
|||||||
end
|
end
|
||||||
|
|
||||||
local sandbox = createSandbox();
|
local sandbox = createSandbox();
|
||||||
local mcp = createMcpComputer();
|
local mcp = createMcpComputer({ shell = shell });
|
||||||
|
|
||||||
-- A code error or a failed file write is a *successful* round-trip that carries an
|
-- A code error or a failed file write is a *successful* round-trip that carries an
|
||||||
-- ok:false result (so captured output survives the reply); only a thrown handler or
|
-- ok:false result (so captured output survives the reply); only a thrown handler or
|
||||||
@ -41,7 +41,7 @@ sandbox.serve('exec-lua', function(payload)
|
|||||||
end, { eventloop = el });
|
end, { eventloop = el });
|
||||||
|
|
||||||
sandbox.serve('run-file', function(payload)
|
sandbox.serve('run-file', function(payload)
|
||||||
local result = mcp.executeFile(payload and payload.path);
|
local result = mcp.executeFile(payload and payload.path, nil, payload and payload.args);
|
||||||
return true, {
|
return true, {
|
||||||
ok = result.ok,
|
ok = result.ok,
|
||||||
returns = result.returns or {},
|
returns = result.returns or {},
|
||||||
|
|||||||
@ -184,6 +184,24 @@ testlib.test('executeFile injects require into the file environment', function()
|
|||||||
testlib.assertEquals(result.returns[1].value, 'FROM_FILE');
|
testlib.assertEquals(result.returns[1].value, 'FROM_FILE');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
testlib.test('executeFile passes the file directory to require factory', function()
|
||||||
|
local seenDir;
|
||||||
|
local mcpComputer = createMcpComputer({
|
||||||
|
requireFactory = function(_, dir)
|
||||||
|
seenDir = dir;
|
||||||
|
return function() return 'ok'; end, {};
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
local fsLike = fsWithFiles({
|
||||||
|
['/programs/tool.lua'] = "return require('dep')",
|
||||||
|
});
|
||||||
|
|
||||||
|
local result = mcpComputer.executeFile('/programs/tool.lua', fsLike);
|
||||||
|
|
||||||
|
testlib.assertEquals(result.ok, true);
|
||||||
|
testlib.assertEquals(seenDir, '/programs');
|
||||||
|
end);
|
||||||
|
|
||||||
testlib.test('executeFile captures output and return values from file', function()
|
testlib.test('executeFile captures output and return values from file', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local fsLike = fsWithFiles({
|
local fsLike = fsWithFiles({
|
||||||
@ -200,6 +218,46 @@ testlib.test('executeFile captures output and return values from file', function
|
|||||||
testlib.assertEquals(result.returns[2].value, 'ok');
|
testlib.assertEquals(result.returns[2].value, 'ok');
|
||||||
end);
|
end);
|
||||||
|
|
||||||
|
testlib.test('executeFile passes args as varargs and arg table', function()
|
||||||
|
local mcpComputer = createMcpComputer();
|
||||||
|
local fsLike = fsWithFiles({
|
||||||
|
['/argv.lua'] = "local a, b = ...; return a, b, arg[1], arg[2]",
|
||||||
|
});
|
||||||
|
|
||||||
|
local result = mcpComputer.executeFile('/argv.lua', fsLike, { 'install', 'pkg' });
|
||||||
|
|
||||||
|
testlib.assertEquals(result.ok, true);
|
||||||
|
testlib.assertEquals(result.returns[1].value, 'install');
|
||||||
|
testlib.assertEquals(result.returns[2].value, 'pkg');
|
||||||
|
testlib.assertEquals(result.returns[3].value, 'install');
|
||||||
|
testlib.assertEquals(result.returns[4].value, 'pkg');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('executeFile validates args', function()
|
||||||
|
local mcpComputer = createMcpComputer();
|
||||||
|
local fsLike = fsWithFiles({
|
||||||
|
['/argv.lua'] = 'return true',
|
||||||
|
});
|
||||||
|
|
||||||
|
local result = mcpComputer.executeFile('/argv.lua', fsLike, { 'ok', 2 });
|
||||||
|
|
||||||
|
testlib.assertEquals(result.ok, false);
|
||||||
|
testlib.assertTrue(string.find(result.error, 'args', 1, true));
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('executeFile provides shell getRunningProgram for the file', function()
|
||||||
|
local mcpComputer = createMcpComputer({ shell = { path = function() return '/rom/programs'; end } });
|
||||||
|
local fsLike = fsWithFiles({
|
||||||
|
['/programs/tool.lua'] = "return shell.getRunningProgram(), shell.path()",
|
||||||
|
});
|
||||||
|
|
||||||
|
local result = mcpComputer.executeFile('/programs/tool.lua', fsLike);
|
||||||
|
|
||||||
|
testlib.assertEquals(result.ok, true);
|
||||||
|
testlib.assertEquals(result.returns[1].value, '/programs/tool.lua');
|
||||||
|
testlib.assertEquals(result.returns[2].value, '/rom/programs');
|
||||||
|
end);
|
||||||
|
|
||||||
testlib.test('executeFile reports validation and missing file errors', function()
|
testlib.test('executeFile reports validation and missing file errors', function()
|
||||||
local mcpComputer = createMcpComputer();
|
local mcpComputer = createMcpComputer();
|
||||||
local fsLike = fakeFs();
|
local fsLike = fakeFs();
|
||||||
|
|||||||
4
tools/mcp-bridge/package-lock.json
generated
4
tools/mcp-bridge/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-bridge",
|
"name": "mcp-bridge",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mcp-bridge",
|
"name": "mcp-bridge",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.17.1"
|
"ws": "^8.17.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mcp-bridge",
|
"name": "mcp-bridge",
|
||||||
"version": "0.3.0",
|
"version": "0.3.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -95,13 +95,13 @@ export class LinkRegistry {
|
|||||||
return formatExecutionResult(computer, result);
|
return formatExecutionResult(computer, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
async runFile(computerId: number, path: string, timeoutMs: number): Promise<string> {
|
async runFile(computerId: number, path: string, args: string[], timeoutMs: number): Promise<string> {
|
||||||
const computer = this.computers.get(computerId);
|
const computer = this.computers.get(computerId);
|
||||||
if (!computer) {
|
if (!computer) {
|
||||||
return `No computer with id ${computerId} connected.`;
|
return `No computer with id ${computerId} connected.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.requestComputer(computer, "run-file", { path }, timeoutMs);
|
const result = await this.requestComputer(computer, "run-file", { path, args }, timeoutMs);
|
||||||
return formatExecutionResult(computer, result);
|
return formatExecutionResult(computer, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
|
|||||||
return jsonRpcResult(id, {
|
return jsonRpcResult(id, {
|
||||||
protocolVersion: "2024-11-05",
|
protocolVersion: "2024-11-05",
|
||||||
capabilities: { tools: {} },
|
capabilities: { tools: {} },
|
||||||
serverInfo: { name: "mcp-bridge", version: "0.3.0" },
|
serverInfo: { name: "mcp-bridge", version: "0.3.1" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,6 +121,7 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
|
|||||||
properties: {
|
properties: {
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
|
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
|
||||||
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
|
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
|
||||||
|
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
||||||
},
|
},
|
||||||
required: ["computerId", "path"],
|
required: ["computerId", "path"],
|
||||||
@ -167,7 +168,7 @@ async function handleSingleRequest(request: JsonRpcRequest, registry: LinkRegist
|
|||||||
return jsonRpcError(id, -32602, parsed.error);
|
return jsonRpcError(id, -32602, parsed.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await registry.runFile(parsed.computerId, parsed.path, parsed.timeoutMs);
|
const text = await registry.runFile(parsed.computerId, parsed.path, parsed.args, parsed.timeoutMs);
|
||||||
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +225,7 @@ function parseWriteFileArgs(
|
|||||||
function parseRunFileArgs(
|
function parseRunFileArgs(
|
||||||
args: Record<string, unknown>,
|
args: Record<string, unknown>,
|
||||||
defaultTimeoutMs: number,
|
defaultTimeoutMs: number,
|
||||||
): { ok: true; computerId: number; path: string; timeoutMs: number } | { ok: false; error: string } {
|
): { ok: true; computerId: number; path: string; args: string[]; timeoutMs: number } | { ok: false; error: string } {
|
||||||
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
|
if (typeof args.computerId !== "number" || !Number.isFinite(args.computerId)) {
|
||||||
return { ok: false, error: "computerId must be a finite number" };
|
return { ok: false, error: "computerId must be a finite number" };
|
||||||
}
|
}
|
||||||
@ -233,12 +234,20 @@ function parseRunFileArgs(
|
|||||||
return { ok: false, error: "path must be a non-empty string" };
|
return { ok: false, error: "path must be a non-empty string" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.args !== undefined && !isStringArray(args.args)) {
|
||||||
|
return { ok: false, error: "args must be an array of strings" };
|
||||||
|
}
|
||||||
|
|
||||||
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
if (args.timeoutMs !== undefined && (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
|
||||||
return { ok: false, error: "timeoutMs must be a positive finite number" };
|
return { ok: false, error: "timeoutMs must be a positive finite number" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
|
const timeoutMs = Math.min(args.timeoutMs ?? defaultTimeoutMs, 30_000);
|
||||||
return { ok: true, computerId: args.computerId, path: args.path, timeoutMs };
|
return { ok: true, computerId: args.computerId, path: args.path, args: args.args ?? [], timeoutMs };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonRpcResult(id: unknown, result: unknown): unknown {
|
function jsonRpcResult(id: unknown, result: unknown): unknown {
|
||||||
|
|||||||
@ -97,6 +97,7 @@ test("MCP tools/list includes exec-lua schema", async () => {
|
|||||||
properties: {
|
properties: {
|
||||||
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
|
computerId: { type: "number", description: "ComputerCraft computer id to run on." },
|
||||||
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
|
path: { type: "string", description: "Lua file path on the ComputerCraft computer." },
|
||||||
|
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
||||||
},
|
},
|
||||||
required: ["computerId", "path"],
|
required: ["computerId", "path"],
|
||||||
@ -168,12 +169,12 @@ test("run-file sends path to the selected computer", async () => {
|
|||||||
const computer = new FakeSocket();
|
const computer = new FakeSocket();
|
||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
||||||
|
|
||||||
const promise = registry.runFile(12, "/helloworld.lua", 50);
|
const promise = registry.runFile(12, "/helloworld.lua", ["install", "pkg"], 50);
|
||||||
assert.deepEqual(computer.lastRequest(), {
|
assert.deepEqual(computer.lastRequest(), {
|
||||||
type: "request",
|
type: "request",
|
||||||
id: computer.lastRequest().id,
|
id: computer.lastRequest().id,
|
||||||
method: "run-file",
|
method: "run-file",
|
||||||
params: { path: "/helloworld.lua" },
|
params: { path: "/helloworld.lua", args: ["install", "pkg"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
|
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
|
||||||
@ -183,7 +184,7 @@ test("run-file sends path to the selected computer", async () => {
|
|||||||
test("run-file reports unknown computer", async () => {
|
test("run-file reports unknown computer", async () => {
|
||||||
const registry = new LinkRegistry();
|
const registry = new LinkRegistry();
|
||||||
|
|
||||||
assert.equal(await registry.runFile(99, "/helloworld.lua", 50), "No computer with id 99 connected.");
|
assert.equal(await registry.runFile(99, "/helloworld.lua", [], 50), "No computer with id 99 connected.");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("MCP run-file validates required arguments", async () => {
|
test("MCP run-file validates required arguments", async () => {
|
||||||
@ -207,12 +208,19 @@ test("MCP run-file returns text content", async () => {
|
|||||||
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
registry.register({ computerId: 12, label: "base-turtle", ws: computer as unknown as WebSocket, connectedAt: 1, lastSeenAt: 1 });
|
||||||
|
|
||||||
const responsePromise = handleMcpRequest(
|
const responsePromise = handleMcpRequest(
|
||||||
{ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "run-file", arguments: { computerId: 12, path: "/helloworld.lua" } } },
|
{
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 2,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "run-file", arguments: { computerId: 12, path: "/helloworld.lua", args: ["a", "b"] } },
|
||||||
|
},
|
||||||
registry,
|
registry,
|
||||||
10,
|
10,
|
||||||
);
|
);
|
||||||
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
|
computer.respond(registry, 12, { returns: [], output: "Blah!\n" });
|
||||||
|
|
||||||
|
assert.deepEqual(computer.lastRequest().params, { path: "/helloworld.lua", args: ["a", "b"] });
|
||||||
|
|
||||||
assert.deepEqual(await responsePromise, {
|
assert.deepEqual(await responsePromise, {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
id: 2,
|
id: 2,
|
||||||
|
|||||||
4
tools/trap-sandbox/package-lock.json
generated
4
tools/trap-sandbox/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trap-sandbox",
|
"name": "trap-sandbox",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trap-sandbox",
|
"name": "trap-sandbox",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/websocket": "^11",
|
"@fastify/websocket": "^11",
|
||||||
"@opencode-ai/sdk": "^1.17.4",
|
"@opencode-ai/sdk": "^1.17.4",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trap-sandbox",
|
"name": "trap-sandbox",
|
||||||
"version": "0.2.1",
|
"version": "0.2.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@ -24,7 +24,7 @@ type JsonRpcRequest = {
|
|||||||
params?: unknown;
|
params?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERVER_VERSION = "0.2.1";
|
const SERVER_VERSION = "0.2.2";
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{
|
{
|
||||||
@ -56,6 +56,7 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
traposId: { type: "string", description: 'Target traposId ("<id>:<label>", from probe-computers).' },
|
traposId: { type: "string", description: 'Target traposId ("<id>:<label>", from probe-computers).' },
|
||||||
path: { type: "string", description: "Absolute Lua file path on the TrapOS computer to run, for example /programs/ccpm.lua." },
|
path: { type: "string", description: "Absolute Lua file path on the TrapOS computer to run, for example /programs/ccpm.lua." },
|
||||||
|
args: { type: "array", items: { type: "string" }, description: "Optional program arguments passed as Lua varargs." },
|
||||||
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
timeoutMs: { type: "number", description: "Optional host-side timeout in milliseconds, max 30000." },
|
||||||
},
|
},
|
||||||
required: ["traposId", "path"],
|
required: ["traposId", "path"],
|
||||||
@ -131,7 +132,7 @@ async function handleSingle(request: JsonRpcRequest, deps: McpDeps): Promise<unk
|
|||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
return jsonRpcError(id, -32602, parsed.error);
|
return jsonRpcError(id, -32602, parsed.error);
|
||||||
}
|
}
|
||||||
const text = await runFile(deps, parsed.traposId, parsed.path, parsed.timeoutMs);
|
const text = await runFile(deps, parsed.traposId, parsed.path, parsed.args, parsed.timeoutMs);
|
||||||
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
return jsonRpcResult(id, { content: [{ type: "text", text }] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,10 +198,10 @@ function formatExecutionResult(traposId: string, payload: Record<string, unknown
|
|||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFile(deps: McpDeps, traposId: string, path: string, timeoutMs: number): Promise<string> {
|
async function runFile(deps: McpDeps, traposId: string, path: string, args: string[], timeoutMs: number): Promise<string> {
|
||||||
let payload: Record<string, unknown>;
|
let payload: Record<string, unknown>;
|
||||||
try {
|
try {
|
||||||
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path }, timeoutMs);
|
payload = await deps.registry.request(deps.accountId, traposId, "run-file", { path, args }, timeoutMs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return transportError(traposId, err);
|
return transportError(traposId, err);
|
||||||
}
|
}
|
||||||
@ -249,7 +250,7 @@ function parseExecLuaArgs(args: Record<string, unknown>, deps: McpDeps): ExecLua
|
|||||||
return { ok: true, traposId: args.traposId, code: args.code, timeoutMs: timeout.value };
|
return { ok: true, traposId: args.traposId, code: args.code, timeoutMs: timeout.value };
|
||||||
}
|
}
|
||||||
|
|
||||||
type RunFileArgs = { ok: true; traposId: string; path: string; timeoutMs: number } | { ok: false; error: string };
|
type RunFileArgs = { ok: true; traposId: string; path: string; args: string[]; timeoutMs: number } | { ok: false; error: string };
|
||||||
|
|
||||||
function parseRunFileArgs(args: Record<string, unknown>, deps: McpDeps): RunFileArgs {
|
function parseRunFileArgs(args: Record<string, unknown>, deps: McpDeps): RunFileArgs {
|
||||||
if (typeof args.traposId !== "string" || args.traposId.trim() === "") {
|
if (typeof args.traposId !== "string" || args.traposId.trim() === "") {
|
||||||
@ -258,11 +259,18 @@ function parseRunFileArgs(args: Record<string, unknown>, deps: McpDeps): RunFile
|
|||||||
if (typeof args.path !== "string" || args.path.trim() === "") {
|
if (typeof args.path !== "string" || args.path.trim() === "") {
|
||||||
return { ok: false, error: "path must be a non-empty string" };
|
return { ok: false, error: "path must be a non-empty string" };
|
||||||
}
|
}
|
||||||
|
if (args.args !== undefined && !isStringArray(args.args)) {
|
||||||
|
return { ok: false, error: "args must be an array of strings" };
|
||||||
|
}
|
||||||
const timeout = parseTimeout(args.timeoutMs, deps);
|
const timeout = parseTimeout(args.timeoutMs, deps);
|
||||||
if (!timeout.ok) {
|
if (!timeout.ok) {
|
||||||
return { ok: false, error: timeout.error };
|
return { ok: false, error: timeout.error };
|
||||||
}
|
}
|
||||||
return { ok: true, traposId: args.traposId, path: args.path, timeoutMs: timeout.value };
|
return { ok: true, traposId: args.traposId, path: args.path, args: args.args ?? [], timeoutMs: timeout.value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStringArray(value: unknown): value is string[] {
|
||||||
|
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
type WriteFileArgs =
|
type WriteFileArgs =
|
||||||
|
|||||||
@ -35,24 +35,28 @@ test("MCP exec-lua / run-file / write-file / probe round-trip over the gateway",
|
|||||||
assert.match(exec, /"value":2/); // return value serialized
|
assert.match(exec, /"value":2/); // return value serialized
|
||||||
assert.match(exec, /\nhi$/); // captured print output
|
assert.match(exec, /\nhi$/); // captured print output
|
||||||
|
|
||||||
|
const program = "local a, b = ...; print(a .. ':' .. b); return arg[1], arg[2], shell.getRunningProgram()";
|
||||||
const write = await callTool(sandbox, "write-file", {
|
const write = await callTool(sandbox, "write-file", {
|
||||||
traposId: "7:base",
|
traposId: "7:base",
|
||||||
path: "/mcp-it.txt",
|
path: "/mcp-it.lua",
|
||||||
content: "hello",
|
content: program,
|
||||||
});
|
});
|
||||||
assert.match(write, /ok: true/);
|
assert.match(write, /ok: true/);
|
||||||
assert.match(write, /bytes: 5/);
|
assert.match(write, new RegExp(`bytes: ${program.length}`));
|
||||||
|
|
||||||
const run = await callTool(sandbox, "run-file", { traposId: "7:base", path: "/mcp-it.txt" });
|
const run = await callTool(sandbox, "run-file", { traposId: "7:base", path: "/mcp-it.lua", args: ["install", "pkg"] });
|
||||||
assert.match(run, /^computer: 7:base\nok: false/);
|
assert.match(run, /^computer: 7:base\nok: true/);
|
||||||
assert.match(run, /\/mcp-it\.txt:1: .*syntax error/);
|
assert.match(run, /"value":"install"/);
|
||||||
|
assert.match(run, /"value":"pkg"/);
|
||||||
|
assert.match(run, /"value":"\/mcp-it\.lua"/);
|
||||||
|
assert.match(run, /\ninstall:pkg$/);
|
||||||
|
|
||||||
// Read it back through exec-lua to prove the write actually landed.
|
// Read it back through exec-lua to prove the write actually landed.
|
||||||
const verify = await callTool(sandbox, "exec-lua", {
|
const verify = await callTool(sandbox, "exec-lua", {
|
||||||
traposId: "7:base",
|
traposId: "7:base",
|
||||||
code: "local h = fs.open('/mcp-it.txt', 'r'); local c = h.readAll(); h.close(); return c",
|
code: "local h = fs.open('/mcp-it.lua', 'r'); local c = h.readAll(); h.close(); return c",
|
||||||
});
|
});
|
||||||
assert.match(verify, /"value":"hello"/);
|
assert.match(verify, /"shell.getRunningProgram/);
|
||||||
|
|
||||||
// Unknown traposId is a transport (not_connected) failure, not a crash.
|
// Unknown traposId is a transport (not_connected) failure, not a crash.
|
||||||
const ghost = await callTool(sandbox, "exec-lua", { traposId: "9:ghost", code: "return 1" });
|
const ghost = await callTool(sandbox, "exec-lua", { traposId: "9:ghost", code: "return 1" });
|
||||||
|
|||||||
@ -131,17 +131,50 @@ test("run-file sends the path and formats execution output", async () => {
|
|||||||
const a = new FakeSocket();
|
const a = new FakeSocket();
|
||||||
reg.register(ACCOUNT, "7:base", a);
|
reg.register(ACCOUNT, "7:base", a);
|
||||||
|
|
||||||
const text = await callAndAnswer(deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua" }, true, {
|
const text = await callAndAnswer(
|
||||||
|
deps,
|
||||||
|
a,
|
||||||
|
"7:base",
|
||||||
|
"run-file",
|
||||||
|
{ traposId: "7:base", path: "/hello.lua", args: ["install", "pkg"] },
|
||||||
|
true,
|
||||||
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
returns: [],
|
returns: [],
|
||||||
output: "secret\n",
|
output: "secret\n",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
assert.equal(a.lastFrame().type, "run-file");
|
assert.equal(a.lastFrame().type, "run-file");
|
||||||
assert.deepEqual((a.lastFrame().payload as { path: string }).path, "/hello.lua");
|
assert.deepEqual(a.lastFrame().payload, { path: "/hello.lua", args: ["install", "pkg"] });
|
||||||
assert.equal(text, ["computer: 7:base", "ok: true", "returns: []", "output:", "secret"].join("\n"));
|
assert.equal(text, ["computer: 7:base", "ok: true", "returns: []", "output:", "secret"].join("\n"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("run-file defaults args to an empty array", async () => {
|
||||||
|
const reg = new GatewayRegistry(silentLog);
|
||||||
|
const deps = makeDeps(reg);
|
||||||
|
const a = new FakeSocket();
|
||||||
|
reg.register(ACCOUNT, "7:base", a);
|
||||||
|
|
||||||
|
await callAndAnswer(deps, a, "7:base", "run-file", { traposId: "7:base", path: "/hello.lua" }, true, {
|
||||||
|
ok: true,
|
||||||
|
returns: [],
|
||||||
|
output: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(a.lastFrame().payload, { path: "/hello.lua", args: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run-file rejects non-string args", async () => {
|
||||||
|
const reply = await handleMcpRequest(
|
||||||
|
rpc("tools/call", { name: "run-file", arguments: { traposId: "7:base", path: "/hello.lua", args: ["ok", 2] } }),
|
||||||
|
makeDeps(new GatewayRegistry(silentLog)),
|
||||||
|
);
|
||||||
|
const error = (reply as { error?: { code: number; message: string } }).error;
|
||||||
|
assert.equal(error?.code, -32602);
|
||||||
|
assert.equal(error?.message, "args must be an array of strings");
|
||||||
|
});
|
||||||
|
|
||||||
test("write-file reports the written path and byte count", async () => {
|
test("write-file reports the written path and byte count", async () => {
|
||||||
const reg = new GatewayRegistry(silentLog);
|
const reg = new GatewayRegistry(silentLog);
|
||||||
const deps = makeDeps(reg);
|
const deps = makeDeps(reg);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user