#!/usr/bin/env lua -- lmcp server — cross-platform shell tools -- Works on Linux, macOS, and Windows without modification. -- SPDX-License-Identifier: MIT -- Resolve package paths relative to this script local dir = arg[0]:match('(.*[/\\])') or './' local sep = package.config:sub(1, 1) -- '/' on Unix, '\\' on Windows package.path = package.path .. ';' .. dir .. '?.lua' -- Windows: add lua\ subdirectory for LuaSocket DLLs if sep == '\\' then package.cpath = package.cpath .. ';' .. dir .. 'lua\\?.dll' .. ';' .. dir .. 'lua\\socket\\?.dll' .. ';' .. dir .. 'lua\\mime\\?.dll' end local lmcp = require('lmcp') -- ---- Platform detection ---- local WINDOWS = sep == '\\' local function is_windows() return WINDOWS end -- ---- Non-blocking command execution with timeout ---- -- io.popen blocks until the child exits. On any OS, a long-running -- process (like a daemon) will hang lmcp forever. We work around this -- by spawning into temp files and polling a sentinel. local function tmpname() if WINDOWS then local tmp = os.getenv("TEMP") or "C:\\Windows\\Temp" return tmp .. "\\lmcp_" .. os.time() .. "_" .. math.random(10000, 99999) else return os.tmpname() end end local function sleep_ms(ms) if WINDOWS then -- ping loopback: ~1s per -n count. For sub-second, use busy-wait. if ms < 500 then local target = os.clock() + ms / 1000 while os.clock() < target do end else local secs = math.ceil(ms / 1000) os.execute("ping -n " .. (secs + 1) .. " 127.0.0.1 >nul 2>&1") end else -- POSIX: use sleep command (supports fractional seconds on GNU) if ms < 1000 then os.execute("sleep 0." .. string.format("%03d", ms)) else os.execute("sleep " .. math.ceil(ms / 1000)) end end end local function file_exists(path) local f = io.open(path, 'r') if f then f:close(); return true end return false end local function read_file(path) local f = io.open(path, 'r') if not f then return nil end local c = f:read('*a'); f:close(); return c end local function remove_silent(path) os.remove(path) end local function run(cmd, timeout_sec) timeout_sec = timeout_sec or 120 local base = tmpname() local out_file = base .. ".out" local done_file = base .. ".done" if WINDOWS then -- Write a batch wrapper that runs the command and signals completion local bat_file = base .. ".bat" local bf = io.open(bat_file, 'w') if not bf then return "Error: could not create temp file" end bf:write("@echo off\r\n") bf:write(cmd .. ' > "' .. out_file .. '" 2>&1\r\n') bf:write('echo %ERRORLEVEL% > "' .. done_file .. '"\r\n') bf:close() os.execute('start /B cmd /C "' .. bat_file .. '"') -- Poll for sentinel local elapsed = 0 local interval = 100 -- ms while elapsed < timeout_sec * 1000 do if file_exists(done_file) then break end sleep_ms(interval) elapsed = elapsed + interval if interval < 2000 then interval = math.floor(interval * 1.5) end end local output = read_file(out_file) remove_silent(bat_file) remove_silent(out_file) remove_silent(done_file) if elapsed >= timeout_sec * 1000 then return output or ("Error: command timed out after " .. timeout_sec .. "s") end return output and output ~= "" and output or "(no output)" else -- POSIX: use shell backgrounding + wait with timeout -- sh -c '(cmd > out 2>&1; echo $? > done) &' then poll local sh_cmd = string.format( "(%s) > '%s' 2>&1; echo $? > '%s'", cmd, out_file, done_file ) os.execute("sh -c '" .. sh_cmd:gsub("'", "'\\''") .. "' &") local elapsed = 0 local interval = 50 -- ms while elapsed < timeout_sec * 1000 do if file_exists(done_file) then break end sleep_ms(interval) elapsed = elapsed + interval if interval < 2000 then interval = math.floor(interval * 1.5) end end local output = read_file(out_file) remove_silent(out_file) remove_silent(done_file) if elapsed >= timeout_sec * 1000 then return output or ("Error: command timed out after " .. timeout_sec .. "s") end return output and output ~= "" and output or "(no output)" end end -- ---- Server setup ---- local server_name = os.getenv("LMCP_NAME") or (WINDOWS and "windows-tools" or "linux-tools") local server = lmcp.new(server_name, { port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8080, }) -- ---- Tools ---- server:tool("shell", "Execute a shell command.", { type = "object", properties = { command = { type = "string", description = "Command to execute" }, cwd = { type = "string", description = "Working directory" }, timeout = { type = "integer", description = "Timeout in seconds", default = 120 }, powershell = { type = "boolean", description = "Use PowerShell (Windows only)", default = false }, }, required = { "command" }, }, function(a) local cmd = a.command if a.cwd then if WINDOWS then cmd = 'cd /d "' .. a.cwd .. '" && ' .. cmd else cmd = 'cd "' .. a.cwd .. '" && ' .. cmd end end if a.powershell and WINDOWS then cmd = 'powershell -NoProfile -Command "' .. cmd:gsub('"', '\\"') .. '"' end return run(cmd, a.timeout or 120) end) server:tool("read_file", "Read a file.", { type = "object", properties = { path = { type = "string" } }, required = { "path" }, }, function(a) local c = read_file(a.path) if not c then return "Error: could not read " .. a.path end return c end) server:tool("write_file", "Write content to a file.", { type = "object", properties = { path = { type = "string" }, content = { type = "string" }, }, required = { "path", "content" }, }, function(a) local f = io.open(a.path, 'w') if not f then return "Error: could not write " .. a.path end f:write(a.content); f:close() return string.format("Written %d bytes to %s", #a.content, a.path) end) server:tool("list_dir", "List directory contents.", { type = "object", properties = { path = { type = "string", default = "." } }, }, function(a) local path = a.path or "." if WINDOWS then return run('dir /b "' .. path .. '"', 10) else return run("ls -1 '" .. path:gsub("'", "'\\''") .. "'", 10) end end) server:tool("search_files", "Search for files by pattern.", { type = "object", properties = { pattern = { type = "string", description = "File name pattern" }, path = { type = "string", default = WINDOWS and "C:\\" or "/" }, }, required = { "pattern" }, }, function(a) local path = a.path or (WINDOWS and "C:\\" or "/") if WINDOWS then return run('dir /b /s "' .. path .. '\\' .. a.pattern .. '"', 30) else return run("find '" .. path:gsub("'", "'\\''") .. "' -name '" .. a.pattern:gsub("'", "'\\''") .. "' 2>/dev/null", 30) end end) if WINDOWS then server:tool("systeminfo", "Get Windows system information.", { type = "object", properties = {}, }, function() return run("systeminfo", 30) end) end io.stderr:write(string.format("lmcp %s starting on port %d (%s)\n", server_name, server.port, WINDOWS and "Windows" or "POSIX")) server:run()