Files
lmcp/hub.lua
T
Markus Fritsche 250f3d38f0 hub: probe ssh-only backends with a TCP connect instead of leaving them unknown
The design note says the probe is lmcp-only because checking ssh "is expensive (3-6s per
offline host) and the hub exists specifically to absorb lots of offline hosts". That holds
for an ssh SESSION. A bare TCP connect to port 22 answers the only question a host card
asks — is the box there — with no handshake and no auth.

Measured on the room host: a dead target costs 1.05s, a live one milliseconds, and riding
the existing parallel fan-out keeps wall clock at one budget window — 3.07s for 14 lmcp
plus 9 ssh probes together, against 3.10s for the 14 lmcp probes alone. The cost objection
is answered rather than ignored.

Eight reachable hosts had been reported as "no probe result": dcw2, deus, escher, hermes,
nash, noether, orca, pipi. They now report UP via=ssh, and a host whose port 22 refuses
gets a real reason ("ssh port unreachable") instead of silence.

Deliberately unchanged: a backend with BOTH paths whose lmcp is down still reads DOWN, per
the existing note that remote_* falls through to ssh regardless. Only ssh-ONLY backends
get the new probe. Without nc the probe reports nothing rather than guessing DOWN — an
unprobed host is honest, a wrongly-asserted one is not.

Two detours worth recording, since both were self-inflicted and cost a restart each:

  * The first attempt edited /opt/lmcp/hub.lua and restarted the service, which loads
    /usr/share/lua/5.4/hub.lua — a separate copy. Nothing keeps the two in step; the log
    line still showed the old format string, which is the only reason it was noticed.
  * The second attempt compared this repository against the deployed file and concluded
    that 120 lines of MCP tool annotations had never been committed. They had. The working
    copy was 17 commits stale, so the "drift" was entirely an artefact of the comparison.
    Against the current tree the real change is 36 lines, and the patched file now hashes
    identical to the running one.
2026-08-02 21:35:15 +02:00

722 lines
29 KiB
Lua

#!/usr/bin/env lua
-- lmcp hub — fleet-wide MCP broker.
--
-- One MCP endpoint that fans out to every lmcp-backed host, with an SSH
-- fallback for hosts whose lmcp is temporarily down (or not installed).
-- Exposes a small set of "remote_*" tools that all take a `host` arg
-- naming the target in the backend registry.
--
-- Registry file (default /opt/herding/etc/hub-backends.conf):
-- # name ssh_host lmcp_url token
-- boltzmann boltzmann.fritz.box http://boltzmann.fritz.box:8080/mcp <bearer>
-- tesla tesla http://tesla.fritz.box:8080/mcp -
-- broglie - http://broglie.fritz.box:8080/mcp -
-- Use `-` for "not applicable". Lines starting with # are comments.
-- Missing `ssh_host`: no ssh fallback available. Missing `lmcp_url`: ssh-only.
--
-- SPDX-License-Identifier: MIT
local dir = arg[0]:match('(.*/)') or './'
package.path = package.path .. ';' .. dir .. '?.lua'
local lmcp = require('lmcp')
local json = require('json')
local socket = require('socket')
-- ---- Backend registry ---------------------------------------------------
local CONF_PATH = os.getenv("LMCP_HUB_BACKENDS") or "/opt/herding/etc/hub-backends.conf"
local PROBE_TTL_UP = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_UP") or "30")
local PROBE_TTL_DOWN_MIN = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_DOWN_MIN") or "60")
local PROBE_TTL_DOWN_MAX = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_DOWN_MAX") or "900")
local PROBE_BUDGET = tonumber(os.getenv("LMCP_HUB_PROBE_BUDGET") or "3")
-- TCP port that answers "is this host there" for ssh-only backends.
local SSH_PROBE_PORT = os.getenv("LMCP_HUB_SSH_PORT") or "22"
local LMCP_TIMEOUT = tonumber(os.getenv("LMCP_HUB_LMCP_TIMEOUT") or "6")
local SSH_TIMEOUT = tonumber(os.getenv("LMCP_HUB_SSH_TIMEOUT") or "10")
local SSH_HARD_TIMEOUT = tonumber(os.getenv("LMCP_HUB_SSH_HARD_TIMEOUT") or "30")
local LOG_REQUESTS = (os.getenv("LMCP_HUB_LOG") or "1") ~= "0"
local function logreq(fmt, ...)
if not LOG_REQUESTS then return end
io.stderr:write(string.format("[hub %s] " .. fmt .. "\n",
os.date("%H:%M:%S"), ...))
io.stderr:flush()
end
local function monotonic()
local ok, s = pcall(socket.gettime)
return ok and s or os.time()
end
local backends = {} -- name -> { name, ssh_host, lmcp_url, token }
local status = {} -- name -> { up=bool, via="lmcp"|"ssh"|nil, checked=t, err=... }
local function load_registry()
local f = io.open(CONF_PATH, "r")
if not f then
io.stderr:write("hub: no backend registry at " .. CONF_PATH .. "\n")
return
end
backends = {}
for line in f:lines() do
line = line:gsub("^%s+", ""):gsub("%s+$", "")
if line ~= "" and not line:match("^#") then
local parts = {}
for p in line:gmatch("%S+") do parts[#parts+1] = p end
if #parts >= 2 then
local name = parts[1]
local ssh_host = (parts[2] ~= "-" and parts[2]) or nil
local lmcp_url = (parts[3] ~= "-" and parts[3]) or nil
local token = (parts[4] ~= "-" and parts[4]) or nil
backends[name] = {
name = name,
ssh_host = ssh_host,
lmcp_url = lmcp_url,
token = token,
}
end
end
end
f:close()
end
-- ---- Outbound HTTP client (plain, no TLS) ------------------------------
local function parse_url(url)
local scheme, host, port, path = url:match("^(%w+)://([^:/]+):?(%d*)(/?.*)$")
if not scheme then return nil, "bad url" end
port = tonumber(port) or (scheme == "https" and 443 or 80)
if path == "" then path = "/" end
return { scheme = scheme, host = host, port = port, path = path }
end
local function http_post_json(url, body, token, timeout)
local u, err = parse_url(url)
if not u then return nil, err end
if u.scheme ~= "http" then return nil, "hub only speaks http to backends" end
local sock = socket.tcp()
sock:settimeout(timeout or 6)
local ok, e = sock:connect(u.host, u.port)
if not ok then sock:close(); return nil, "connect: " .. e end
local headers = {
"POST " .. u.path .. " HTTP/1.1",
"Host: " .. u.host .. ":" .. u.port,
"Content-Type: application/json",
"Content-Length: " .. tostring(#body),
"Connection: close",
}
if token then
headers[#headers+1] = "Authorization: Bearer " .. token
end
local req = table.concat(headers, "\r\n") .. "\r\n\r\n" .. body
local _, se = sock:send(req)
if se then sock:close(); return nil, "send: " .. se end
local chunks = {}
while true do
local data, rerr, partial = sock:receive(4096)
if data then chunks[#chunks+1] = data
elseif partial and #partial > 0 then chunks[#chunks+1] = partial
end
if not data then
if rerr == "timeout" then sock:close(); return nil, "timeout" end
break
end
end
sock:close()
local resp = table.concat(chunks)
local hend = resp:find("\r\n\r\n", 1, true)
if not hend then return nil, "no body separator" end
local status_line = resp:sub(1, resp:find("\r\n", 1, true) - 1)
local status_code = tonumber(status_line:match("HTTP/[%d%.]+%s+(%d+)"))
local body_str = resp:sub(hend + 4)
if status_code ~= 200 then
return nil, "http " .. tostring(status_code) .. ": " .. body_str:sub(1, 200)
end
local ok2, parsed = pcall(json.decode, body_str)
if not ok2 then return nil, "bad json: " .. body_str:sub(1, 200) end
return parsed
end
local function jsonrpc_call(url, token, method, params)
local body = json.encode({
jsonrpc = "2.0",
id = os.time() * 1000 + math.random(1000, 9999),
method = method,
params = params or {},
})
local resp, err = http_post_json(url, body, token, LMCP_TIMEOUT)
if not resp then return nil, err end
if resp.error then
return nil, "rpc: " .. (resp.error.message or "unknown")
end
return resp.result
end
-- ---- SSH exec (shell-escape-free via bash -s on stdin) -----------------
local function shell_quote(s)
-- Single-quote wrap, replace embedded single quotes with '\''
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
local function tmpwrite(content)
local path = os.tmpname()
local f = io.open(path, "w")
if not f then return nil, "tmpfile create failed" end
f:write(content)
f:close()
return path
end
local function ssh_run_script(ssh_host, script)
-- Write the script body locally, then ssh with stdin redirected from it.
-- bash -s reads the script from stdin. Nothing inside `script` is subject
-- to another shell round of expansion — this is the escape-free path.
local spath, perr = tmpwrite(script)
if not spath then return nil, perr, -1 end
local outpath = spath .. ".out"
-- Hard-cap wall time with GNU `timeout` — ssh's ConnectTimeout only
-- bounds TCP connect, not the session. Without this, a half-dead sshd
-- (auth stall, remote bash-s hang) locks the hub event loop indefinitely.
local cmd = string.format(
"timeout --kill-after=2 %d ssh -o ConnectTimeout=%d -o ServerAliveInterval=5 -o ServerAliveCountMax=2 -o BatchMode=yes -o StrictHostKeyChecking=accept-new %s 'bash -s' < %s > %s 2>&1; echo $? > %s.rc",
SSH_HARD_TIMEOUT, SSH_TIMEOUT, shell_quote(ssh_host), shell_quote(spath),
shell_quote(outpath), shell_quote(spath)
)
local t0 = monotonic()
os.execute(cmd)
local dt = monotonic() - t0
local rc_f = io.open(spath .. ".rc", "r")
local rc = -1
if rc_f then
local s = rc_f:read("*a") or ""
s = s:match("^(%S+)") or ""
rc = tonumber(s) or -1
rc_f:close()
end
local out_f = io.open(outpath, "r")
local output = out_f and out_f:read("*a") or ""
if out_f then out_f:close() end
os.remove(spath); os.remove(outpath); os.remove(spath .. ".rc")
-- timeout(1) exits 124 on wall-clock expiry, 137 on SIGKILL. Surface it.
if rc == 124 or rc == 137 then
logreq("ssh HARD-TIMEOUT host=%s after=%.2fs rc=%d", ssh_host, dt, rc)
return nil, string.format("ssh hard-timeout after %ds", SSH_HARD_TIMEOUT), rc
end
logreq("ssh host=%s elapsed=%.2fs rc=%d bytes=%d", ssh_host, dt, rc, #output)
return output, nil, rc
end
-- ---- Health probe ------------------------------------------------------
--
-- Design notes:
-- - Probe is lmcp-only. SSH is NOT checked here: it's expensive (3-6s per
-- offline host) and the hub exists specifically to absorb lots of
-- offline hosts. Hosts with lmcp down but ssh up show as DOWN in the
-- health list, but actual remote_* calls still fall through to ssh
-- fallback correctly.
-- - Sticky DOWN cache with exponential backoff: a DOWN host is re-probed
-- at intervals that grow from PROBE_TTL_DOWN_MIN (default 60s) up to
-- PROBE_TTL_DOWN_MAX (default 900s). Prevents a sleeping fleet from
-- burning probe budget every health check.
-- - `remote_list_hosts` uses a parallel curl fan-out for all hosts at
-- once so wall-clock is bounded by PROBE_BUDGET (default 3s), not the
-- sum of per-host timeouts.
local function cache_fresh(s, now)
if not s then return false end
local ttl
if s.up then
ttl = PROBE_TTL_UP
else
ttl = math.min(PROBE_TTL_DOWN_MAX, PROBE_TTL_DOWN_MIN * (2 ^ (s.down_streak or 0)))
end
return (now - s.checked) < ttl
end
local function apply_probe_result(name, up, err, via, tool_count)
local prev = status[name]
local new_s = {
checked = os.time(),
up = up,
via = up and (via or "lmcp") or nil,
err = err,
tool_count = tool_count,
down_streak = up and 0 or ((prev and prev.down_streak or 0) + 1),
}
status[name] = new_s
return new_s
end
-- Fallback single-host probe used only when a caller explicitly needs a
-- status refresh outside the parallel path (currently only
-- remote_list_hosts when a single host is looked up). lmcp-only.
local function probe(name, force)
local b = backends[name]
if not b then return { up = false, err = "unknown host" } end
local s = status[name]
if not force and cache_fresh(s, os.time()) then return s end
if not b.lmcp_url then
return apply_probe_result(name, false, "no lmcp url", nil, nil)
end
local result, err = jsonrpc_call(b.lmcp_url, b.token, "tools/list", {})
if result and result.tools then
return apply_probe_result(name, true, nil, "lmcp", #result.tools)
end
return apply_probe_result(name, false, "lmcp: " .. tostring(err), nil, nil)
end
-- Parallel lmcp probe for every backend with an lmcp_url, via a single
-- bash fan-out of curl calls. Total wall clock ≈ PROBE_BUDGET.
local function probe_all_parallel(force)
local now = os.time()
local need, need_ssh = {}, {}
for name, b in pairs(backends) do
if force or not cache_fresh(status[name], now) then
if b.lmcp_url then
need[#need+1] = b
elseif b.ssh_host then
-- ssh-only: no lmcp endpoint to ask, but "is the box there" is still
-- answerable cheaply. See the SSH probe note below.
need_ssh[#need_ssh+1] = b
end
end
end
if #need == 0 and #need_ssh == 0 then return end
local script_parts = {}
for _, b in ipairs(need) do
local auth = b.token and (" -H 'Authorization: Bearer " .. b.token .. "'") or ""
local url = b.lmcp_url:gsub("'", "'\\''")
script_parts[#script_parts+1] = string.format(
"(curl --max-time %d -s -o /dev/null -w '%s %%{http_code} %%{time_total}\\n' -X POST%s -H 'Content-Type: application/json' -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}' '%s' || echo '%s ERR 0') &",
PROBE_BUDGET, b.name, auth, url, b.name
)
end
-- SSH probe. The design note above rejects checking ssh because a session costs
-- 3-6s per offline host — true for a SESSION. A bare TCP connect to 22 answers the
-- only question a host card asks ("is it there") with no handshake and no auth:
-- measured on this host, a dead target costs 1.05s and a live one milliseconds, and
-- it rides the same parallel fan-out, so wall clock stays one budget window.
-- Without nc we report nothing rather than guessing DOWN — a wrong claim is worse
-- than the "no probe result" the dashboard already renders as unknown.
for _, b in ipairs(need_ssh) do
local host = b.ssh_host:gsub("'", "'\\''")
script_parts[#script_parts+1] = string.format(
"(if command -v nc >/dev/null 2>&1; then " ..
"nc -z -w%d '%s' %s >/dev/null 2>&1 && echo '%s SSHUP 0' || echo '%s SSHDOWN 0'; " ..
"else echo '%s SSHSKIP 0'; fi) &",
PROBE_BUDGET, host, SSH_PROBE_PORT, b.name, b.name, b.name)
end
script_parts[#script_parts+1] = "wait"
local t0 = monotonic()
local p = io.popen(table.concat(script_parts, "\n"))
local out = p and p:read("*a") or ""
if p then p:close() end
local dt = monotonic() - t0
local seen = {}
for line in out:gmatch("[^\n]+") do
local name, code, t = line:match("^(%S+)%s+(%S+)%s+([%d%.]+)")
if name then
seen[name] = true
if code == "SSHUP" then
apply_probe_result(name, true, nil, "ssh", nil)
elseif code == "SSHDOWN" then
apply_probe_result(name, false, "ssh port unreachable", nil, nil)
elseif code == "SSHSKIP" then
-- nc missing: leave it unprobed rather than assert a state.
seen[name] = nil
elseif code == "200" then
apply_probe_result(name, true, nil, "lmcp", nil)
else
apply_probe_result(name, false, "lmcp code=" .. code, nil, nil)
end
end
end
-- Hosts with no output (fan-out error): mark DOWN
for _, b in ipairs(need) do
if not seen[b.name] then
apply_probe_result(b.name, false, "probe fan-out missing", nil, nil)
end
end
logreq("probe_all_parallel lmcp=%d ssh=%d elapsed=%.2fs", #need, #need_ssh, dt)
end
-- ---- Call-tool dispatcher ----------------------------------------------
-- `allow_ssh`: whether the tool has an SSH fallback path
-- `ssh_impl`: function(backend, args) -> output string, or nil + error
local function call_remote(tool, args, allow_ssh, ssh_impl)
local t_start = monotonic()
local host = args.host
if type(host) ~= "string" or host == "" then
logreq("tool=%s ERR missing-host", tool)
return "Error: missing `host` parameter"
end
local b = backends[host]
if not b then
logreq("tool=%s host=%s ERR unknown-host", tool, host)
return string.format("Error: unknown host %q (registry: %s)", host, CONF_PATH)
end
local errs = {}
local via = nil
-- Try lmcp first
if b.lmcp_url then
local args_pass = {}
for k, v in pairs(args) do if k ~= "host" then args_pass[k] = v end end
local result, err = jsonrpc_call(b.lmcp_url, b.token, "tools/call",
{ name = tool, arguments = args_pass })
if result then
via = "lmcp"
logreq("tool=%s host=%s via=lmcp elapsed=%.2fs", tool, host, monotonic() - t_start)
-- Propagate backend's content, extract first text block
if type(result.content) == "table" and result.content[1] and result.content[1].text then
local prefix = result.isError and "[lmcp isError] " or ""
return prefix .. result.content[1].text
end
return json.encode(result)
end
errs[#errs+1] = "lmcp: " .. tostring(err)
else
errs[#errs+1] = "lmcp: no url configured"
end
-- Fallback to ssh
if allow_ssh and ssh_impl and b.ssh_host then
local out, serr = ssh_impl(b, args)
if out ~= nil then
logreq("tool=%s host=%s via=ssh elapsed=%.2fs", tool, host, monotonic() - t_start)
return "[via ssh fallback]\n" .. out
end
errs[#errs+1] = "ssh: " .. tostring(serr)
elseif allow_ssh and not b.ssh_host then
errs[#errs+1] = "ssh: no host configured"
end
logreq("tool=%s host=%s FAIL elapsed=%.2fs err=%s", tool, host, monotonic() - t_start, table.concat(errs, " | "):sub(1, 200))
return "Error: " .. table.concat(errs, " | ")
end
-- ---- SSH implementations for each fallback-capable tool ----------------
local function ssh_shell(b, a)
if type(a.command) ~= "string" then return nil, "command required" end
local cwd_prefix = ""
if a.cwd and a.cwd ~= "" then
cwd_prefix = "cd " .. shell_quote(a.cwd) .. " && "
end
local script = cwd_prefix .. a.command .. "\n"
local out, err, rc = ssh_run_script(b.ssh_host, script)
if err then return nil, err end
-- ssh exits 255 when it couldn't connect/authenticate. Treat that as
-- fallback-failed so the caller sees the combined lmcp+ssh errors.
if rc == 255 then
local line1 = (out:match("^([^\n]+)") or ""):gsub("%s+$", "")
return nil, "ssh connect failed: " .. line1
end
return string.format("[rc=%d]\n%s", rc, out)
end
local function ssh_read_file(b, a)
if type(a.path) ~= "string" then return nil, "path required" end
local script = "cat -- " .. shell_quote(a.path) .. "\n"
local out, err, rc = ssh_run_script(b.ssh_host, script)
if err then return nil, err end
if rc ~= 0 then return nil, "cat exit " .. rc .. ": " .. out:sub(1, 200) end
return out
end
local function ssh_write_file(b, a)
if type(a.path) ~= "string" or type(a.content) ~= "string" then
return nil, "path and content required"
end
-- Emit a script that decodes content from base64, so no shell
-- interpretation of the payload is possible. Lua stdlib has no base64;
-- implement a minimal encoder inline.
local b64_alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local function b64enc(data)
local out = {}
local pad = (3 - (#data % 3)) % 3
local padded = data .. ("\0"):rep(pad)
for i = 1, #padded, 3 do
local a1, a2, a3 = padded:byte(i), padded:byte(i+1), padded:byte(i+2)
local n = a1 * 65536 + a2 * 256 + a3
out[#out+1] = b64_alphabet:sub(((n >> 18) & 63) + 1, ((n >> 18) & 63) + 1)
out[#out+1] = b64_alphabet:sub(((n >> 12) & 63) + 1, ((n >> 12) & 63) + 1)
out[#out+1] = b64_alphabet:sub(((n >> 6) & 63) + 1, ((n >> 6) & 63) + 1)
out[#out+1] = b64_alphabet:sub(((n ) & 63) + 1, ((n ) & 63) + 1)
end
local s = table.concat(out)
if pad > 0 then s = s:sub(1, -pad - 1) .. ("="):rep(pad) end
return s
end
local payload = b64enc(a.content)
local script = string.format(
"base64 -d > %s <<'EOF_B64_PAYLOAD'\n%s\nEOF_B64_PAYLOAD\n",
shell_quote(a.path), payload
)
local out, err, rc = ssh_run_script(b.ssh_host, script)
if err then return nil, err end
if rc ~= 0 then return nil, "write exit " .. rc .. ": " .. out:sub(1, 200) end
return string.format("Written %d bytes to %s (via ssh)", #a.content, a.path)
end
local function ssh_list_dir(b, a)
local path = (a and type(a.path) == "string" and a.path) or "."
local script = "ls -1 -- " .. shell_quote(path) .. "\n"
local out, err, rc = ssh_run_script(b.ssh_host, script)
if err then return nil, err end
if rc ~= 0 then return nil, "ls exit " .. rc .. ": " .. out:sub(1, 200) end
return out
end
local function ssh_search_files(b, a)
if type(a.pattern) ~= "string" then return nil, "pattern required" end
local path = (a.path and a.path ~= "") and a.path or "/"
local script = string.format(
"find %s -name %s 2>/dev/null | head -200\n",
shell_quote(path), shell_quote(a.pattern)
)
local out, err, rc = ssh_run_script(b.ssh_host, script)
if err then return nil, err end
return out
end
-- ---- Server setup ------------------------------------------------------
load_registry()
math.randomseed(os.time())
local server = lmcp.new(os.getenv("LMCP_NAME") or "hub-tools", {
port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8090,
version = "0.5.4",
conf = os.getenv("LMCP_HUB_CONF") or "/opt/herding/etc/lmcp-hub.conf",
})
local HOST_ARG = {
type = "string",
description = "Name of the backend host (see remote_list_hosts)",
}
server:tool("remote_list_hosts",
"List all registered fleet hosts and their live status (lmcp vs ssh vs down).",
{ type = "object", properties = {
force = { type = "boolean", description = "Force re-probe (bypass cache)", default = false },
} },
function(a)
local force = a and a.force or false
probe_all_parallel(force)
local lines = {}
local names = {}
for n in pairs(backends) do names[#names+1] = n end
table.sort(names)
for _, n in ipairs(names) do
local b = backends[n]
local s = status[n] or { up = false, err = "no probe result" }
local paths = {}
if b.lmcp_url then paths[#paths+1] = "lmcp" end
if b.ssh_host then paths[#paths+1] = "ssh" end
lines[#lines+1] = string.format(
"%-14s %-6s via=%-4s paths=%s %s",
n,
s.up and "UP" or "DOWN",
tostring(s.via or "-"),
table.concat(paths, ","),
s.err and ("[" .. s.err .. "]") or ""
)
end
return table.concat(lines, "\n")
end,
{ annotations = {
title = "List fleet hosts",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
server:tool("remote_shell", "Run a shell command on a fleet host. lmcp-primary with ssh fallback.",
{ type = "object", properties = {
host = HOST_ARG,
command = { type = "string", description = "Shell command" },
cwd = { type = "string", description = "Working directory" },
timeout = { type = "integer", description = "Timeout (seconds)", default = 120 },
}, required = { "host", "command" } },
function(a) return call_remote("shell", a, true, ssh_shell) end,
{ annotations = {
title = "Remote shell",
readOnlyHint = false,
destructiveHint = true,
idempotentHint = false,
openWorldHint = true,
} }
)
server:tool("remote_read_file", "Read a file from a fleet host.",
{ type = "object", properties = {
host = HOST_ARG,
path = { type = "string", description = "File path" },
}, required = { "host", "path" } },
function(a) return call_remote("read_file", a, true, ssh_read_file) end,
{ annotations = {
title = "Remote read file",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
server:tool("remote_write_file", "Write content to a file on a fleet host.",
{ type = "object", properties = {
host = HOST_ARG,
path = { type = "string" },
content = { type = "string" },
}, required = { "host", "path", "content" } },
function(a) return call_remote("write_file", a, true, ssh_write_file) end,
{ annotations = {
title = "Remote write file",
readOnlyHint = false,
destructiveHint = true,
idempotentHint = true,
openWorldHint = true,
} }
)
server:tool("remote_edit_file",
"Literal-match edit on a fleet host. **Requires backend lmcp up** — no ssh fallback.",
{ type = "object", properties = {
host = HOST_ARG,
path = { type = "string" },
old_string = { type = "string" },
new_string = { type = "string" },
replace_all = { type = "boolean", default = false },
}, required = { "host", "path", "old_string", "new_string" } },
function(a) return call_remote("edit_file", a, false, nil) end,
{ annotations = {
title = "Remote edit file",
readOnlyHint = false,
destructiveHint = true,
idempotentHint = false,
openWorldHint = true,
} }
)
server:tool("remote_shell_bg",
"Launch a detached background command on a fleet host (Linux). Returns PID + log path immediately. Requires backend lmcp v0.5.2+.",
{ type = "object", properties = {
host = HOST_ARG,
command = { type = "string", description = "Shell command" },
cwd = { type = "string" },
log = { type = "string", description = "Log file path" },
}, required = { "host", "command" } },
function(a) return call_remote("shell_bg", a, false, nil) end,
{ annotations = {
title = "Remote shell (background)",
readOnlyHint = false,
destructiveHint = true,
idempotentHint = false,
openWorldHint = true,
} }
)
server:tool("remote_list_dir", "List directory entries on a fleet host.",
{ type = "object", properties = {
host = HOST_ARG,
path = { type = "string", default = "." },
}, required = { "host" } },
function(a) return call_remote("list_dir", a, true, ssh_list_dir) end,
{ annotations = {
title = "Remote list directory",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
server:tool("remote_search_files", "find-by-pattern on a fleet host.",
{ type = "object", properties = {
host = HOST_ARG,
pattern = { type = "string" },
path = { type = "string", default = "/" },
}, required = { "host", "pattern" } },
function(a) return call_remote("search_files", a, true, ssh_search_files) end,
{ annotations = {
title = "Remote find files",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
-- ---- Fleet-central local tools (migrated from tools.d/hertz.lua, 2026-07-18) ----
-- These run LOCALLY on hertz (where the hub process also lives), so no ssh
-- backend hop. Consolidated here so the hub is the single fleet-management
-- endpoint. Still also served by hertz-tools (:8080) for now — remove there
-- once every client (pi-agents etc.) has a @hub session.
local function run_local(cmd, timeout)
local full = timeout and ("timeout " .. tostring(timeout) .. " " .. cmd) or cmd
local p = io.popen(full .. " 2>&1")
if not p then return "Error: popen failed" end
local out = p:read("*a")
p:close()
return out or ""
end
server:tool("apropos",
"Search shared fleet memory (stash) for facts about the fleet, projects, decisions, and preferences. query = 2-6 words on the topic; limit = max results (default 3). Read-only.",
{ type = "object", properties = {
query = { type = "string", description = "2-6 words describing what to recall" },
limit = { type = "integer", description = "max results, default 3" },
}, required = { "query" } },
function(a)
local q = tostring(a.query or ""):gsub("[^%w%s%-%.]", " "):gsub("%s+", " ")
if q:gsub("%s", "") == "" then return "Error: query required" end
local lim = tonumber(a.limit) or 3
return run_local("python3 /opt/lmcp/helpers/stash_recall.py '" .. q .. "' " .. lim, 30)
end,
{ annotations = {
title = "Apropos (fleet memory)",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
server:tool("wake_fleet",
"Wake a fleet NUC (pve1..pve4) via Fritz!Box Wake-on-LAN. Powers a node ON only; it cannot power anything off. Node boots in ~30-60s.",
{ type = "object", properties = {
node = { type = "string", description = "Node to wake: '1'..'4' or 'pve1'..'pve4'" },
}, required = { "node" } },
function(a)
local node = tostring(a.node or ""):gsub("[^%w]", "")
if not node:match("^p?v?e?[1-4]$") then
return "Error: node must be 1-4 or pve1-pve4 (got: " .. tostring(a.node) .. ")"
end
return run_local("sudo /root/.local/bin/wake-pve " .. node, 15)
end,
{ annotations = {
title = "Wake fleet NUC",
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
io.stderr:write(string.format("lmcp-hub starting on port %d with %d backends from %s\n",
server.port, (function() local n = 0; for _ in pairs(backends) do n = n + 1 end; return n end)(), CONF_PATH))
server:run()