283 lines
7.5 KiB
Lua
283 lines
7.5 KiB
Lua
-- Computer-side MCP execution primitives.
|
|
--
|
|
-- Protocol-agnostic building blocks for the MCP `exec-lua` / `write-file` features.
|
|
-- The transport lives elsewhere: servers/mcp-computer-server.lua reacts to cloud
|
|
-- server_request events and replies through the trapos-server gateway (see
|
|
-- apis/libcloud.lua). This module only knows how to run code and write files.
|
|
|
|
local function defaultOs()
|
|
return os;
|
|
end
|
|
|
|
local function formatLabel(label)
|
|
if type(label) ~= 'string' or label == '' then
|
|
return 'null';
|
|
end
|
|
return label;
|
|
end
|
|
|
|
local function appendOutput(output, value)
|
|
output[#output + 1] = tostring(value);
|
|
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 valueType = type(value);
|
|
if valueType == 'nil' then
|
|
return { type = 'nil' };
|
|
end
|
|
|
|
if valueType == 'string' or valueType == 'number' or valueType == 'boolean' then
|
|
return { type = valueType, value = value };
|
|
end
|
|
|
|
local ok, serialized = pcall(textutils.serialize, value);
|
|
if ok then
|
|
return { type = valueType, repr = serialized };
|
|
end
|
|
return { type = valueType, repr = tostring(value) };
|
|
end
|
|
|
|
-- Builds a CraftOS-style require/package pair the same way /rom/programs/shell.lua does
|
|
-- (dofile of cc.require, then .make(env, dir)). Returned factory caches the make function so
|
|
-- the rom module is only loaded once. Stays synchronous (require never yields), so it is safe
|
|
-- inside the synchronous cloud handler -- unlike spawning a shell, which yields and deadlocks.
|
|
local function defaultRequireFactory()
|
|
local make;
|
|
local loaded = false;
|
|
return function(env, dir)
|
|
if not loaded then
|
|
loaded = true;
|
|
local ok, mod = pcall(dofile, '/rom/modules/main/cc/require.lua');
|
|
if ok and type(mod) == 'table' then
|
|
make = mod.make;
|
|
end
|
|
end
|
|
if type(make) ~= 'function' then
|
|
return nil, nil;
|
|
end
|
|
local madeOk, req, pkg = pcall(make, env, dir or '');
|
|
if not madeOk then
|
|
return nil, nil;
|
|
end
|
|
return req, pkg;
|
|
end
|
|
end
|
|
|
|
local function createExecEnv(output, requireFactory, requireDir, execOpts)
|
|
execOpts = execOpts or {};
|
|
local env = setmetatable({}, { __index = _G });
|
|
env.arg = copyArgs(execOpts.args or {});
|
|
|
|
env.write = function(value)
|
|
appendOutput(output, value);
|
|
end;
|
|
|
|
env.print = function(...)
|
|
local values = table.pack(...);
|
|
for i = 1, values.n do
|
|
if i > 1 then
|
|
appendOutput(output, '\t');
|
|
end
|
|
appendOutput(output, values[i]);
|
|
end
|
|
appendOutput(output, '\n');
|
|
end;
|
|
|
|
if requireFactory then
|
|
local req, pkg = requireFactory(env, requireDir);
|
|
if req then
|
|
env.require = req;
|
|
env.package = pkg;
|
|
end
|
|
end
|
|
|
|
local shellProxy = createShellProxy(execOpts.shell, execOpts.runningProgram);
|
|
if shellProxy then
|
|
env.shell = shellProxy;
|
|
end
|
|
|
|
return env;
|
|
end
|
|
|
|
local function executeSource(code, chunkName, deps, execOpts)
|
|
execOpts = execOpts or {};
|
|
local output = {};
|
|
local args = execOpts.args or {};
|
|
|
|
local fn, loadErr = load(code, chunkName, 't', createExecEnv(output, deps.requireFactory, deps.requireDir, execOpts));
|
|
if not fn then
|
|
return { ok = false, error = tostring(loadErr), output = table.concat(output) };
|
|
end
|
|
|
|
local values = table.pack(pcall(fn, table.unpack(args)));
|
|
if not values[1] then
|
|
return { ok = false, error = tostring(values[2]), output = table.concat(output) };
|
|
end
|
|
|
|
local returns = {};
|
|
for i = 2, values.n do
|
|
returns[#returns + 1] = serializeReturn(values[i]);
|
|
end
|
|
return { ok = true, returns = returns, output = table.concat(output) };
|
|
end
|
|
|
|
local function createMcpComputer(deps)
|
|
deps = deps or {};
|
|
|
|
-- requireFactory: function(env, dir) -> require, package. Defaults to the rom-backed factory;
|
|
-- pass `false` to disable require injection, or a function to stub it in tests.
|
|
local requireFactory = deps.requireFactory;
|
|
if requireFactory == nil then
|
|
requireFactory = defaultRequireFactory();
|
|
end
|
|
local resolved = {
|
|
requireFactory = requireFactory or nil,
|
|
requireDir = deps.requireDir or '',
|
|
shell = deps.shell or shell,
|
|
};
|
|
|
|
local api = {};
|
|
|
|
api.formatLabel = formatLabel;
|
|
|
|
function api.formatPong(osLike)
|
|
osLike = osLike or defaultOs();
|
|
return 'pong from ' .. tostring(osLike.getComputerID())
|
|
.. ' (Label: ' .. formatLabel(osLike.getComputerLabel()) .. ')';
|
|
end
|
|
|
|
function api.executeLua(code)
|
|
if type(code) ~= 'string' or code == '' then
|
|
return { ok = false, error = 'code must be a non-empty string', output = '' };
|
|
end
|
|
|
|
return executeSource(code, 'mcp-exec', resolved);
|
|
end
|
|
|
|
function api.executeFile(path, fsLike, rawArgs)
|
|
fsLike = fsLike or fs;
|
|
if type(path) ~= 'string' or path == '' then
|
|
return { ok = false, error = 'path must be a non-empty string', output = '' };
|
|
end
|
|
local argsOk, argsOrErr = normalizeArgs(rawArgs);
|
|
if not argsOk then
|
|
return { ok = false, error = argsOrErr, output = '' };
|
|
end
|
|
|
|
local handle, openErr = fsLike.open(path, 'r');
|
|
if not handle then
|
|
return { ok = false, error = tostring(openErr or 'failed to open file'), output = '' };
|
|
end
|
|
|
|
local ok, contentOrErr = pcall(function()
|
|
return handle.readAll();
|
|
end);
|
|
local closeOk, closeErr = pcall(function()
|
|
handle.close();
|
|
end);
|
|
|
|
if not ok then
|
|
return { ok = false, error = tostring(contentOrErr), output = '' };
|
|
end
|
|
if not closeOk then
|
|
return { ok = false, error = tostring(closeErr), output = '' };
|
|
end
|
|
|
|
local fileDeps = {
|
|
requireFactory = resolved.requireFactory,
|
|
requireDir = dirname(path),
|
|
};
|
|
return executeSource(contentOrErr, '@' .. path, fileDeps, {
|
|
args = argsOrErr,
|
|
runningProgram = path,
|
|
shell = resolved.shell,
|
|
});
|
|
end
|
|
|
|
function api.writeFile(path, content, fsLike)
|
|
fsLike = fsLike or fs;
|
|
if type(path) ~= 'string' or path == '' then
|
|
return { ok = false, error = 'path must be a non-empty string' };
|
|
end
|
|
|
|
if type(content) ~= 'string' then
|
|
return { ok = false, error = 'content must be a string' };
|
|
end
|
|
|
|
local handle, openErr = fsLike.open(path, 'w');
|
|
if not handle then
|
|
return { ok = false, error = tostring(openErr or 'failed to open file') };
|
|
end
|
|
|
|
local ok, writeErr = pcall(function()
|
|
handle.write(content);
|
|
end);
|
|
local closeOk, closeErr = pcall(function()
|
|
handle.close();
|
|
end);
|
|
|
|
if not ok then
|
|
return { ok = false, error = tostring(writeErr) };
|
|
end
|
|
if not closeOk then
|
|
return { ok = false, error = tostring(closeErr) };
|
|
end
|
|
|
|
return { ok = true, path = path, bytes = string.len(content) };
|
|
end
|
|
|
|
return api;
|
|
end
|
|
|
|
return createMcpComputer;
|