From 490e688cc1e9ca328a72e02ea1b72a925906b5e8 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 19 Apr 2026 12:29:13 +0000 Subject: [PATCH] =?UTF-8?q?v0.5.0:=20add=20hub=20=E2=80=94=20fleet-wide=20?= =?UTF-8?q?MCP=20broker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One lmcp server on a central host (typically hertz) that proxies remote_* tools to every backend in a registry, with a clean SSH fallback for hosts whose lmcp is temporarily down or not installed. Tools: remote_list_hosts, remote_{shell,read_file,write_file,edit_file, list_dir,search_files}. Each takes a `host` argument naming the target in /opt/herding/etc/hub-backends.conf (or $LMCP_HUB_BACKENDS). Lazy 30s health cache; `remote_list_hosts force=true` bypasses it. Bearer auth on inbound (standard lmcp opts.conf / LMCP_TOKEN machinery); backend Bearer tokens kept in the registry and forwarded per-call. SSH fallback uses `ssh host 'bash -s' < local_script` — stdin-piped script body is the canonical shell-escape-free technique. Covers shell/read_file/write_file/list_dir/search_files. edit_file is lmcp-only because the literal-match + uniqueness check is nontrivial to replicate safely in shell. Ships an example systemd unit and a commented backends.conf template in examples/. No migration required for existing lmcp deployments — hub.lua is additive alongside the existing server.lua. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/hub-backends.conf.example | 22 ++ examples/lmcp-hub.service | 25 ++ hub.lua | 455 +++++++++++++++++++++++++++++ 3 files changed, 502 insertions(+) create mode 100644 examples/hub-backends.conf.example create mode 100644 examples/lmcp-hub.service create mode 100644 hub.lua diff --git a/examples/hub-backends.conf.example b/examples/hub-backends.conf.example new file mode 100644 index 0000000..47f91d6 --- /dev/null +++ b/examples/hub-backends.conf.example @@ -0,0 +1,22 @@ +# lmcp hub backend registry. +# +# Format: whitespace-separated "name ssh_host lmcp_url token" +# Use "-" for not-applicable fields. +# - Missing ssh_host (col 2 = -): no ssh fallback available for this backend +# - Missing lmcp_url (col 3 = -): this backend is ssh-only +# - Missing token (col 4 = -): backend accepts unauth lmcp (LAN-only hosts) +# +# Lines starting with # are comments. Blank lines ignored. +# +# LXD-container caveat: if your hub lives on the LXD host and the backend is +# a sibling LXD container, Fritz DNS often caches a stale DHCP lease for the +# container's hostname. Hardcode the container IP here and update when it +# rotates, or wire the .lxd stub zone into systemd-resolved. + +# name ssh_host lmcp_url token +# --- --- --- --- +# boltzmann boltzmann.fritz.box http://boltzmann.fritz.box:8080/mcp <64hex> +# tesla 192.168.88.67 http://192.168.88.67:8080/mcp - +# nc nc.reauktion.de http://nc.reauktion.de:8080/mcp +# pve1 pve1.fritz.box http://pve1.fritz.box:8080/mcp +# hertz - http://hertz.fritz.box:8080/mcp diff --git a/examples/lmcp-hub.service b/examples/lmcp-hub.service new file mode 100644 index 0000000..9c6b577 --- /dev/null +++ b/examples/lmcp-hub.service @@ -0,0 +1,25 @@ +[Unit] +Description=lmcp hub — fleet MCP broker +After=network.target +Wants=network.target + +[Service] +Type=simple +User=mfritsche +Group=mfritsche +# When deploying, copy hub.lua to /opt/lmcp/ or adjust the path below. +ExecStart=/usr/bin/lua5.4 /usr/share/lua/5.4/hub.lua +Environment=LMCP_NAME=hub-tools +Environment=LMCP_PORT=8090 +# Backend registry: space-separated "name ssh_host lmcp_url token" per line. +# See hub-backends.conf.example in this examples/ dir. +Environment=LMCP_HUB_BACKENDS=/opt/herding/etc/hub-backends.conf +# Bearer token file (key `.godparticle=`). +Environment=LMCP_HUB_CONF=/opt/herding/etc/lmcp-hub.conf +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/hub.lua b/hub.lua new file mode 100644 index 0000000..30c4f7c --- /dev/null +++ b/hub.lua @@ -0,0 +1,455 @@ +#!/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 +-- 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 = tonumber(os.getenv("LMCP_HUB_PROBE_TTL") or "30") +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 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" + local cmd = string.format( + "ssh -o ConnectTimeout=%d -o BatchMode=yes -o StrictHostKeyChecking=accept-new %s 'bash -s' < %s > %s 2>&1; echo $? > %s.rc", + SSH_TIMEOUT, shell_quote(ssh_host), shell_quote(spath), + shell_quote(outpath), shell_quote(spath) + ) + os.execute(cmd) + 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") + return output, nil, rc +end + +-- ---- Health probe ------------------------------------------------------ + +local function probe(name, force) + local b = backends[name] + if not b then return { up = false, err = "unknown host" } end + local now = os.time() + local s = status[name] + if not force and s and (now - s.checked) < PROBE_TTL then + return s + end + local new_s = { checked = now, up = false, via = nil, err = nil } + if b.lmcp_url then + local result, err = jsonrpc_call(b.lmcp_url, b.token, "tools/list", {}) + if result and result.tools then + new_s.up = true + new_s.via = "lmcp" + new_s.tool_count = #result.tools + else + new_s.err = "lmcp: " .. tostring(err) + end + end + if not new_s.up and b.ssh_host then + local _, _, rc = ssh_run_script(b.ssh_host, "exit 0\n") + if rc == 0 then + new_s.up = true + new_s.via = "ssh" + new_s.err = nil + else + new_s.err = (new_s.err or "") .. "; ssh: rc=" .. tostring(rc) + end + end + status[name] = new_s + return new_s +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 host = args.host + if type(host) ~= "string" or host == "" then + return "Error: missing `host` parameter" + end + local b = backends[host] + if not b then + return string.format("Error: unknown host %q (registry: %s)", host, CONF_PATH) + end + + local errs = {} + + -- 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 + -- 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 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 + + 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.0", + 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 + 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 = probe(n, force) + 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 +) + +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 +) + +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 +) + +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 +) + +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 +) + +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 +) + +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 +) + +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()