11 Commits

Author SHA1 Message Date
noether (claude) 6fa98dd655 shell: kill process group on timeout/cancel (no orphans) + shell_bg job registry + kill_job/list_jobs
Root cause of the shell->shell_bg thrash: run() backgrounded POSIX commands
with a bare & and never captured the pid, so on timeout it returned an error
while the children kept running. Now: setsid (own process group), capture the
leader pid, SIGTERM+SIGKILL the whole group on timeout/cancel, and a message
telling the model it was killed + to use shell_bg. Plus shell_bg registers jobs
to /tmp/lmcp-bg-jobs.tsv and new kill_job/list_jobs let a runaway job be reaped
without a reboot.
2026-07-12 14:28:52 +02:00
noether (claude) a7b3c44f1c reconcile: sync repo to live deployment (wake_fleet + apropos tools, stash_recall helper) 2026-07-12 14:25:30 +02:00
marfrit 840341d2dd fix: replace LXC/LXD with Incus
Replace all /snap/bin/lxc references with incus in the
hertz-specific tool definitions. Tool names changed from
lxc_exec/lxc_list to incus_exec/incus_list.

Context: LXC snap was removed from hertz; the system
now uses incus (Debian package) for container management.
2026-06-12 23:18:58 +02:00
marfrit 8748fe53bc Merge pull request 'Add nash memory tools as lmcp plugin' (#26) from williams/lmcp:master into master
Reviewed-on: #26
2026-06-05 15:54:26 +00:00
williams 8d8d8fac65 Add nash memory tools (nash_add/search/list/delete) 2026-06-05 15:51:51 +00:00
marfrit 3dd01e5313 Merge pull request 'fix: case-insensitive Bearer token parsing in auth header' (#25) from williams/lmcp:fix/case-insensitive-bearer-auth into master
Reviewed-on: #25
2026-05-30 14:43:37 +00:00
williams d2c2962ad1 fix: case-insensitive Bearer token parsing in auth header 2026-05-30 12:55:02 +00:00
Markus Fritsche c5375b8a77 v1.2.1/#22: LMCP_HOST + LMCP_CONF env support
Adds two env vars to the packaged server.lua so hosts can switch
fully to the packaged entrypoint (combined with v1.2.0's tools.d/
plugin scan):

  LMCP_HOST — interface to bind on (default 0.0.0.0). Hosts that
              need .18-only binding (hertz) or similar single-NIC
              constraints set this. Threaded into lmcp.new opts.host.
  LMCP_CONF — path to a conf file with bearer-token entries (e.g.
              /opt/herding/etc/hertz-tools.conf). Read by lmcp.lua's
              read_conf; the `.godparticle` entry becomes the bearer
              token. Threaded into lmcp.new opts.conf.

Both unset → unchanged behavior (binds 0.0.0.0, no conf file).

Together with v1.2.0's tools.d/ scan, this lets a host like hertz
ship NO override server.lua — just an /opt/lmcp/tools.d/hertz.lua
plugin file and a systemd unit that points at the packaged
server.lua with LMCP_HOST=192.168.88.18 + LMCP_CONF=/opt/herding/
etc/hertz-tools.conf. apt upgrade then delivers all packaged
improvements automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:33:30 +00:00
Markus Fritsche e05438f0e3 v1.2.0/#22: tools.d/ plugin scan — host-local tool extensions
Adds a directory-scan plugin mechanism to the packaged server.lua
so hosts can drop their own tools alongside the packaged generics
without forking server.lua.

Mechanism:
- After all packaged tool registrations + before transport selection,
  the server scans LMCP_TOOLS_DIR (default /opt/lmcp/tools.d on POSIX,
  %ProgramData%\lmcp\tools.d on Windows) for *.lua files.
- Each plugin file is invoked as a function receiving (server, run):
    local server, run = ...
    server:tool("my_local_tool", "...", {...}, function(a) return ... end)
- Load errors and runtime errors are reported on stderr and skipped;
  the server continues with the tools it successfully loaded.

Why:
Hosts like hertz and ampere have always carried local /opt/lmcp/server.lua
overrides containing both packaged-overlap tools (shell, read_file, …)
AND host-specific tools (fritz, ha_api, mqtt_*, lxc_exec, …). When the
override drifts, the host either loses packaged improvements (the v1.1.1
fetch/web_search regression on hertz/ampere) or accumulates hand-merged
patches that vanish on shutdown (the original symptom in issue #22).
With tools.d/, hosts drop ONLY their custom tools as plugin files; the
packaged server.lua stays canonical. apt upgrade picks up new packaged
tools automatically.

Smoke-tested:
  $ mkdir -p /tmp/probe && cat > /tmp/probe/p.lua <<E
  local server, run = ...
  server:tool("plugin_probe", "test", {type="object"},
              function() return "ok" end)
  E
  $ LMCP_TOOLS_DIR=/tmp/probe lua server.lua
  lmcp: loaded plugin /tmp/probe/p.lua
  $ curl POST tools/list → plugin_probe present in the 10 tools listed

Existing single-file server deployments (no /opt/lmcp/tools.d/) keep
working unchanged — io.popen on a non-existent directory returns nil
and the plugin loop no-ops. Backwards compatible.

Closes the structural side of #22 (the ad-hoc-override pattern); ampere
+ hertz migration to use tools.d/ for their custom tools is the operator
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:32:12 +00:00
Markus Fritsche 9707f7ae93 v1.1.1: omit empty inputSchema.properties at registration
Same json.lua empty-table → [] gotcha that bit `ping` in v1.0.0-rc1
(project_json_empty_table_gotcha memory) bit again — this time on
tool inputSchemas with `properties = {}`. Symptom: spec-strict MCP
clients (Zod et al.) reject tools/list with:

  expected: record, code: invalid_type,
  path: [tools, N, inputSchema, properties],
  message: "Invalid input: expected record, received array"

Fix: in `lmcp:tool()`, normalise the registered inputSchema —
when `properties` is an empty Lua table, drop the key entirely.
JSON Schema permits omitting `properties` on `type: "object"`
(means "any object, no constraints" — exactly what a no-arg tool
wants).

Clone-before-mutate so the caller's table isn't trampled (matters
when a server author shares one schema across multiple
registrations).

Smoke tested locally with 3 tools (empty, default-nil, populated):
- `properties = {}` → emitted as `{"type":"object"}`
- nil schema → same default, same output
- populated properties → emitted intact with full shape

Discovered against hertz-tools live (lxc_list, network_status had
`properties = {}` — hertz hotfixed by hand before this commit;
this protects every future tool author from the same trap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:39:56 +00:00
Markus Fritsche 9e53b23b11 windows/build-msi.sh: cross-build the MSI on Linux via wixl + mingw-w64
Discovered building v1.1.0 that the MSI can be produced entirely on
Linux — no Windows VM, no manual WiX install, no GUI babysitting:

  apt install wixl unzip gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
              mingw-w64-x86-64-dev curl

The new build-msi.sh script:
  1. Runs sync.sh to refresh pkg/{lmcp,server,json}.lua from root.
  2. Downloads Lua 5.4.2 Win64 binaries from LuaBinaries (Tools +
     Library zips — interpreter + headers + import lib).
  3. Cross-compiles LuaSocket 3.1.0 via x86_64-w64-mingw32-gcc
     (produces socket-3.0.0.dll + mime-1.0.3.dll for Win64).
  4. Stages pkg/lua/{lua.exe, lua54.dll, socket/, mime/, *.lua} per
     the WiX manifest layout.
  5. Invokes wixl on the lmcp.wxs manifest (with sed for the
     Windows backslash path separators → forward slashes).

Output: lmcp-<version>.msi. Version is read from lmcp.wxs
Version="…", so bump that before each release.

Cold build: ~30s. Warm cache: ~5s. The artifact contains all 17
files the WiX manifest expects, ProductVersion matches lmcp.wxs.

README updated to point at build-msi.sh as the recommended path;
the Windows-side candle/light recipe kept as an alternative.

Reproducibility note (deferred): the MSI is not yet bit-reproducible
across builds — file mtimes in the Lua binaries' zip propagate to
the cab inside the MSI. The debian/lmcp/build-deb.sh in marfrit-
packages uses SOURCE_DATE_EPOCH to fix this; same pattern would
apply here. Out of scope for the first cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:27:32 +00:00
7 changed files with 681 additions and 14 deletions
+29
View File
@@ -0,0 +1,29 @@
import urllib.request, json, threading, queue, sys
BASE="http://192.168.88.184:8080"
q=queue.Queue()
def sse():
try:
r=urllib.request.urlopen(BASE+"/sse", timeout=30)
for raw in r:
s=raw.decode(errors="replace").strip()
if s.startswith("data:"): q.put(s[5:].strip())
except Exception as e: q.put("ERR:"+str(e))
threading.Thread(target=sse,daemon=True).start()
try:
ep=q.get(timeout=10)
if ep.startswith("ERR:"): print("stash unreachable:",ep); sys.exit(1)
purl=BASE+ep if ep.startswith("/") else ep
def post(o):
urllib.request.urlopen(urllib.request.Request(purl,data=json.dumps(o).encode(),headers={"Content-Type":"application/json"}),timeout=15).read()
post({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"apropos","version":"1"}}})
q.get(timeout=10)
post({"jsonrpc":"2.0","method":"notifications/initialized"})
query=sys.argv[1] if len(sys.argv)>1 else ""
limit=int(sys.argv[2]) if len(sys.argv)>2 else 3
post({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"recall","arguments":{"query":query,"limit":limit}}})
d=json.loads(q.get(timeout=25))
txt=d.get("result",{}).get("content",[{}])[0].get("text","[]")
facts=json.loads(txt)
if not facts: print("(no memory found for: %s)"%query); sys.exit(0)
for f in facts: print("- %s (score %.2f)" % (f.get("content","").strip(), f.get("score",0)))
except Exception as e: print("recall error:",e); sys.exit(1)
+21 -2
View File
@@ -163,10 +163,29 @@ end
-- structuredContent (issue #13; spec-strict clients get first-class -- structuredContent (issue #13; spec-strict clients get first-class
-- structured access) -- structured access)
function lmcp:tool(name, description, params_schema, handler, opts) function lmcp:tool(name, description, params_schema, handler, opts)
-- Normalise empty inputSchema.properties → nil. JSON Schema allows
-- omitting `properties` on a `type: "object"` schema (means "any
-- object, no constraints"). Without this, an empty Lua properties
-- table goes through json.lua's is_array → emitted as `[]` →
-- spec-strict clients (Zod et al.) reject with
-- `expected: record, received: array`. The same gotcha already
-- bit `ping` in v1.0.0-rc1 (project_json_empty_table_gotcha
-- memory). v1.1.1 fix.
local schema = params_schema or { type = "object" }
if type(schema.properties) == "table" and next(schema.properties) == nil then
-- Clone the schema and drop the empty `properties` key. Avoids
-- mutating the caller's table (in case they re-use it across
-- registrations).
local clean = {}
for k, v in pairs(schema) do
if k ~= "properties" then clean[k] = v end
end
schema = clean
end
self.tools[name] = { self.tools[name] = {
name = name, name = name,
description = description, description = description,
inputSchema = params_schema or { type = "object", properties = {} }, inputSchema = schema,
handler = handler, handler = handler,
annotations = opts and opts.annotations or nil, annotations = opts and opts.annotations or nil,
outputSchema = opts and opts.outputSchema or nil, outputSchema = opts and opts.outputSchema or nil,
@@ -920,7 +939,7 @@ local function _check_auth(self, conn)
if not self._auth_token then return true end if not self._auth_token then return true end
if conn.method == "OPTIONS" then return true end if conn.method == "OPTIONS" then return true end
local auth = conn.headers["authorization"] or "" local auth = conn.headers["authorization"] or ""
local token = auth:match("^Bearer%s+(.+)$") local token = auth:match("^[Bb]earer%s+(.+)$")
return token == self._auth_token return token == self._auth_token
end end
+114 -6
View File
@@ -174,22 +174,39 @@ local function run(cmd, timeout_sec)
end end
return output and output ~= "" and output or "(no output)" return output and output ~= "" and output or "(no output)"
else else
-- POSIX: use shell backgrounding + wait with timeout -- POSIX: run in its OWN session/process group (setsid) so a
-- sh -c '(cmd > out 2>&1; echo $? > done) &' then poll -- timeout or cancel can kill the WHOLE tree instead of orphaning
-- backgrounded children (the classic "shell timed out, children
-- kept thrashing" bug). $! is the setsid leader pid == pgid.
local pid_file = base .. ".pid"
local sh_cmd = string.format( local sh_cmd = string.format(
"(%s) > '%s' 2>&1; echo $? > '%s'", "(%s) > '%s' 2>&1; echo $? > '%s'",
cmd, out_file, done_file cmd, out_file, done_file
) )
os.execute("sh -c '" .. sh_cmd:gsub("'", "'\\''") .. "' &") os.execute("setsid sh -c '" .. sh_cmd:gsub("'", "'\\''")
.. "' & echo $! > '" .. pid_file .. "'")
local pgid = (read_file(pid_file) or ""):match("(%d+)")
remove_silent(pid_file)
local completed = poll_loop() local completed = poll_loop()
-- Timeout or cancel -> kill the entire process group. No orphans.
if not completed and pgid then
os.execute("kill -TERM -" .. pgid .. " 2>/dev/null")
sleep_ms(300)
os.execute("kill -KILL -" .. pgid .. " 2>/dev/null")
end
local output = read_file(out_file) local output = read_file(out_file)
remove_silent(out_file) remove_silent(out_file)
remove_silent(done_file) remove_silent(done_file)
if not completed then if not completed then
if cancelled then return "(cancelled)" end if cancelled then return "(cancelled -- process group killed)" end
return output or ("Error: command timed out after " .. timeout_sec .. "s") return (output and output ~= "" and (output .. "\n") or "")
.. "Error: command timed out after " .. timeout_sec
.. "s -- the process group was KILLED (nothing is still running). "
.. "For a long-running command, re-run it with shell_bg."
end end
return output and output ~= "" and output or "(no output)" return output and output ~= "" and output or "(no output)"
end end
@@ -200,6 +217,13 @@ end
local server_name = os.getenv("LMCP_NAME") or (WINDOWS and "windows-tools" or "linux-tools") local server_name = os.getenv("LMCP_NAME") or (WINDOWS and "windows-tools" or "linux-tools")
local server = lmcp.new(server_name, { local server = lmcp.new(server_name, {
port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8080, port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8080,
-- LMCP_HOST: bind interface (default 0.0.0.0). Hosts that need
-- single-interface binding (hertz: 192.168.88.18 only) set this.
host = os.getenv("LMCP_HOST"),
-- LMCP_CONF: path to a conf file with bearer-token entries
-- (e.g. /opt/herding/etc/hertz-tools.conf). Read by lmcp.lua's
-- read_conf; the `.godparticle` entry becomes the bearer token.
conf = os.getenv("LMCP_CONF"),
}) })
-- ---- Tools ---- -- ---- Tools ----
@@ -276,7 +300,15 @@ server:tool("shell_bg",
f:close() f:close()
os.remove(pid_file) os.remove(pid_file)
end end
return string.format("launched pid=%s log=%s", pid, log) -- register so list_jobs/kill_job can see and reap it (no more reboots)
if pid ~= "?" then
local reg = io.open("/tmp/lmcp-bg-jobs.tsv", "a")
if reg then
reg:write(pid.."\t"..log.."\t"..os.date("%Y-%m-%dT%H:%M:%S").."\t"..inner:gsub("[\t\n]"," ").."\n")
reg:close()
end
end
return string.format("launched pid=%s log=%s (kill with kill_job pid=%s)", pid, log, pid)
end, { end, {
annotations = { annotations = {
title = "Run shell (background)", title = "Run shell (background)",
@@ -287,6 +319,42 @@ server:tool("shell_bg",
}, },
}) })
server:tool("kill_job",
"Kill a runaway background job by PID. SIGKILLs the whole process group of a shell_bg/setsid job so no children survive. Use when a background job is thrashing a machine.",
{ type = "object", properties = { pid = { type = "integer", description = "PID from shell_bg / list_jobs" } }, required = { "pid" } },
function(a)
if WINDOWS then return "Error: kill_job is Linux-only" end
local pid = tostring(a.pid or ""):match("(%d+)")
if not pid then return "Error: numeric pid required" end
os.execute("kill -KILL -"..pid.." 2>/dev/null; kill -KILL "..pid.." 2>/dev/null")
sleep_ms(200)
local alive = os.execute("kill -0 "..pid.." 2>/dev/null")
if alive == true or alive == 0 then return "pid "..pid.." may still be alive (uninterruptible?)" end
return "killed pid "..pid.." (process group)"
end,
{ annotations = { title = "Kill background job", destructiveHint = true } })
server:tool("list_jobs",
"List background jobs started via shell_bg and whether each is still running. Use to find runaway jobs to kill_job.",
{ type = "object", properties = {} },
function()
if WINDOWS then return "Error: list_jobs is Linux-only" end
local reg = io.open("/tmp/lmcp-bg-jobs.tsv", "r")
if not reg then return "(no background jobs recorded)" end
local out = {}
for line in reg:lines() do
local pid, log, ts, cmd = line:match("^(%d+)\t([^\t]*)\t([^\t]*)\t(.*)$")
if pid then
local alive = os.execute("kill -0 "..pid.." 2>/dev/null")
local st = (alive == true or alive == 0) and "RUNNING" or "done"
table.insert(out, string.format("pid=%s [%s] %s log=%s\n %s", pid, st, ts, log, (cmd or ""):sub(1,100)))
end
end
reg:close()
return #out>0 and table.concat(out, "\n") or "(no background jobs recorded)"
end,
{ annotations = { title = "List background jobs", readOnlyHint = true } })
server:tool("read_file", "Read a file.", { server:tool("read_file", "Read a file.", {
type = "object", type = "object",
properties = { path = { type = "string" } }, properties = { path = { type = "string" } },
@@ -1066,6 +1134,46 @@ if WINDOWS then
}) })
end end
-- ---- host-local tool plugins (issue #22) ----
-- Load every .lua file in LMCP_TOOLS_DIR (default /opt/lmcp/tools.d on POSIX,
-- %ProgramData%\lmcp\tools.d on Windows). Each file is invoked as a function
-- receiving the configured `server` instance and the `run` helper:
--
-- local server, run = ...
-- server:tool("my_local_tool", "...", {...}, function(a) return run(...) end)
--
-- This is the standard plugin pattern (nginx conf.d/, systemd-tmpfiles.d, …).
-- Hosts can ship their own tools alongside the packaged generics without
-- forking the upstream server.lua.
local plugin_dir = os.getenv("LMCP_TOOLS_DIR")
or (WINDOWS and (os.getenv("ProgramData") or "C:\\ProgramData") .. "\\lmcp\\tools.d"
or "/opt/lmcp/tools.d")
local list_cmd = WINDOWS
and ('dir /b "' .. plugin_dir .. '\\*.lua" 2>nul')
or ('ls -1 "' .. plugin_dir .. '"/*.lua 2>/dev/null')
local lh = io.popen(list_cmd)
if lh then
for path in lh:lines() do
-- On Windows `dir /b` emits bare filenames; prefix the dir.
local full = path:match("[/\\]") and path
or (plugin_dir .. (WINDOWS and "\\" or "/") .. path)
local chunk, err = loadfile(full)
if chunk then
local ok, perr = pcall(chunk, server, run)
if ok then
io.stderr:write("lmcp: loaded plugin " .. full .. "\n")
else
io.stderr:write("lmcp: plugin " .. full .. " errored: "
.. tostring(perr) .. "\n")
end
else
io.stderr:write("lmcp: plugin " .. full .. " load error: "
.. tostring(err) .. "\n")
end
end
lh:close()
end
local transport = os.getenv("LMCP_TRANSPORT") or "http" local transport = os.getenv("LMCP_TRANSPORT") or "http"
if transport == "stdio" then if transport == "stdio" then
if os.getenv("LMCP_PORT") then if os.getenv("LMCP_PORT") then
+292
View File
@@ -0,0 +1,292 @@
-- /opt/lmcp/tools.d/hertz.lua — hertz-specific tool registrations.
--
-- Invoked by the packaged server.lua's tools.d scan (lmcp ≥ v1.2.0, issue #22).
-- Receives (server, run) — the configured lmcp instance and the
-- coroutine-aware run() helper. We add hertz-only tools on top of the
-- packaged generics (shell, read_file, write_file, edit_file, list_dir,
-- search_files, fetch, web_search, shell_bg).
--
-- Previously these all lived in /opt/lmcp/server.lua (a copy-paste fork of
-- the packaged server.lua). That pattern drifted on every release; now the
-- packaged file stays canonical and only the genuine hertz-specifics live
-- here.
local server, run = ...
-- Local helpers — single-host scoped, not worth lifting into the packaged lib.
local function read_file_raw(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 farad(cmd, timeout)
return run("incus exec farad -- sh -c " .. string.format("%q", cmd),
timeout or 15)
end
-- ---- LXD tools ----
server:tool("incus_exec", "Execute a command inside an Incus container on hertz.", {
type = "object",
properties = {
container = { type = "string", description = "Container name" },
command = { type = "string", description = "Command to execute" },
timeout = { type = "integer", default = 30 },
},
required = { "container", "command" },
}, function(a)
return run(string.format("incus exec %s -- sh -c %q",
a.container:gsub("[^%w%-]", ""), a.command), a.timeout or 30)
end)
server:tool("incus_list", "List all Incus containers on hertz.",
{ type = "object" },
function() return run("incus list -c ns4 -f csv", 10) end)
-- ---- Fritz!Box tools ----
server:tool("fritz", "Execute Fritz!Box TR-064 command (info, hosts, wan, "
.. "wol <mac>, reconnect, reboot, reboot-repeater <ip>, routes, route-add, "
.. "route-del, services, actions, call).",
{
type = "object",
properties = {
command = { type = "string", description = "fritz subcommand and args" },
},
required = { "command" },
},
function(a) return run("sudo /root/.local/bin/fritz " .. a.command, 15) end)
-- ---- Network tools ----
server:tool("ping_host", "Check if a host is reachable (1 ICMP ping, 2s timeout).", {
type = "object",
properties = { host = { type = "string" } },
required = { "host" },
}, function(a)
local host = a.host:gsub("[^%w%.%-:]", "")
return run("ping -c1 -W2 " .. host, 5)
end)
server:tool("network_status",
"Check reachability of all infrastructure hosts and MCP endpoints.",
{ type = "object" },
function()
local script = [[
hosts="hertz:localhost boltzmann:boltzmann tesla:tesla data:192.168.88.30 broglie:192.168.88.160 higgs:10.170.16.10"
for entry in $hosts; do
name="${entry%%:*}"
ip="${entry#*:}"
if ping -c1 -W2 "$ip" >/dev/null 2>&1; then
status="UP"
if [ "$name" != "data" ]; then
if timeout 3 bash -c "echo >/dev/tcp/${ip}/8080" 2>/dev/null; then
status="UP (MCP ok)"
else
status="UP (no MCP)"
fi
fi
else
status="DOWN"
fi
printf "%-12s %-20s %s\n" "$name" "$ip" "$status"
done
]]
return run(script, 30)
end)
server:tool("wol_and_wait",
"Wake the data server via Fritz!Box WoL and wait until it (or broglie MCP) is reachable.",
{
type = "object",
properties = {
mac = { type = "string", default = "", description = "MAC address (auto-detected if empty)" },
wait_for_mcp = { type = "boolean", default = true, description = "Wait for broglie MCP instead of just ping" },
timeout = { type = "integer", default = 120 },
},
},
function(a)
local mac = a.mac
if not mac or mac == "" then
local hosts_out = run("sudo /root/.local/bin/fritz hosts 2>/dev/null | grep -i 'data\\|192.168.88.30'", 10)
mac = hosts_out and hosts_out:match("(%x%x:%x%x:%x%x:%x%x:%x%x:%x%x)") or ""
if mac == "" then return "Error: could not detect MAC for data. Provide it manually." end
end
run("sudo /root/.local/bin/fritz wol " .. mac:gsub("[^%x:]", ""), 10)
local timeout = a.timeout or 120
local check
if a.wait_for_mcp == false then
check = "ping -c1 -W2 192.168.88.30"
else
check = "timeout 3 bash -c 'echo >/dev/tcp/192.168.88.160/8080'"
end
local target = (a.wait_for_mcp == false) and "data" or "broglie:8080"
local elapsed = 0
while elapsed < timeout do
os.execute("sleep 5")
elapsed = elapsed + 5
local rc = os.execute(check)
if rc == true or rc == 0 then
return string.format("WoL sent to %s. %s reachable after %ds.",
mac, target, elapsed)
end
end
return string.format("WoL sent to %s but %s not reachable after %ds.",
mac, target, timeout)
end)
-- ---- Proxmox fallback ----
server:tool("pct_exec",
"Execute a command in a Proxmox CT on data (SSH fallback when broglie is down).",
{
type = "object",
properties = {
ctid = { type = "string", description = "Container ID (e.g. 108, 165)" },
command = { type = "string" },
timeout = { type = "integer", default = 30 },
},
required = { "ctid", "command" },
},
function(a)
local ctid = a.ctid:gsub("[^%d]", "")
return run(string.format("ssh -o ConnectTimeout=5 root@data 'pct exec %s -- sh -c %q'",
ctid, a.command), a.timeout or 30)
end)
-- ---- Home Assistant ----
server:tool("ha_cli",
"Run Home Assistant CLI command inside farad (e.g. 'core restart', 'backups list', 'info').",
{
type = "object",
properties = {
command = { type = "string", description = "ha subcommand, e.g. 'core restart', 'supervisor info', 'backups list'" },
raw_json = { type = "boolean", default = false, description = "Return raw JSON output" },
},
required = { "command" },
},
function(a)
local flags = a.raw_json and " --raw-json" or ""
return farad("ha " .. a.command .. flags)
end)
server:tool("ha_api",
"Call Home Assistant Core REST API. Requires token in /opt/lmcp/ha_token on hertz.",
{
type = "object",
properties = {
method = { type = "string", default = "GET" },
endpoint = { type = "string", description = "/api/states, /api/services/climate/set_temperature, etc." },
body = { type = "string", description = "JSON body for POST" },
},
required = { "endpoint" },
},
function(a)
local token = read_file_raw("/opt/lmcp/ha_token")
if not token or token == "" then
return "Error: no HA token. Create a long-lived token in HA UI → Profile → "
.. "Long-Lived Access Tokens, then: echo '<token>' | sudo tee /opt/lmcp/ha_token"
end
token = token:gsub("%s+", "")
local method = (a.method or "GET"):upper()
local cmd = string.format(
"curl -sf -X %s -H 'Authorization: Bearer %s' -H 'Content-Type: application/json'",
method, token)
if a.body and a.body ~= "" then
cmd = cmd .. " -d " .. string.format("'%s'", a.body:gsub("'", "'\\''"))
end
cmd = cmd .. " http://localhost:8123" .. a.endpoint
return farad(cmd, 15)
end)
-- ---- MQTT (Eurotronic thermostats + general) ----
server:tool("mqtt_pub", "Publish an MQTT message (via Mosquitto in farad).", {
type = "object",
properties = {
topic = { type = "string" },
message = { type = "string" },
retain = { type = "boolean", default = false },
},
required = { "topic", "message" },
}, function(a)
local retain = a.retain and "-r " or ""
return farad(string.format("mosquitto_pub %s-t '%s' -m '%s'",
retain, a.topic:gsub("'", "'\\''"), a.message:gsub("'", "'\\''")))
end)
server:tool("mqtt_sub",
"Subscribe to MQTT topics and collect messages (via Mosquitto in farad).",
{
type = "object",
properties = {
topic = { type = "string", description = "Topic pattern, e.g. 'eurotronic/#' or '#'" },
count = { type = "integer", default = 10, description = "Number of messages to collect" },
timeout = { type = "integer", default = 5, description = "Seconds to wait" },
verbose = { type = "boolean", default = true, description = "Show topics with messages" },
},
required = { "topic" },
},
function(a)
local v = a.verbose ~= false and "-v " or ""
return farad(string.format("mosquitto_sub %s-t '%s' -C %d -W %d",
v, a.topic:gsub("'", "'\\''"), a.count or 10, a.timeout or 5),
(a.timeout or 5) + 5)
end)
-- ---- Mediagrab (kids show downloader) ----
server:tool("mediagrab",
"Manage kids show downloads in doppler container. Commands: list, weekly, "
.. "archive, test <url>, add '<json>'",
{
type = "object",
properties = {
command = { type = "string", description = "Command: list, weekly, archive, 'test <url>', 'add <json>'" },
},
required = { "command" },
},
function(a)
return run("incus exec doppler -- python3 /opt/mediagrab/mediagrab.py "
.. a.command, 120)
end)
-- ---- Fleet wake (pipi: wake-only, no power-off) ----
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("sudo /root/.local/bin/wake-pve " .. node .. " 2>&1", 15)
end)
-- ---- apropos: lean facade for stash recall (read-only memory) ----
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("python3 /opt/lmcp/helpers/stash_recall.py '" .. q .. "' " .. lim, 30)
end)
+103
View File
@@ -0,0 +1,103 @@
-- /opt/lmcp/tools.d/nash.lua -- nash shared memory tools
--
-- Provides add/search/list/delete tools for the nash memory service.
-- Requires NASH_URL env var (e.g. http://192.168.88.143:8000).
-- When unset, tools return an error guiding the user to set it.
--
-- SPDX-License-Identifier: MIT
local server, run = ...
local function nash_url()
local url = os.getenv("NASH_URL")
if not url or url == "" then return nil end
return url:gsub("/+$", "")
end
local function curl_json(url, method, body, timeout)
timeout = timeout or 10
local out = "/tmp/lmcp-nash-" .. os.time() .. "-" .. math.random(10000, 99999) .. ".json"
local fmt = "http_code=%{http_code}"
local cmd
if body then
local tmp = out .. ".req"
local f = io.open(tmp, "w")
if f then f:write(body); f:close() end
cmd = string.format(
"curl -sS -X %s --max-time %d -H 'Content-Type: application/json' --data-binary '@%s' -o '%s' -w '%s' '%s'",
method, timeout, tmp, out, fmt, url
)
os.execute("rm -f '" .. tmp .. "' 2>/dev/null")
else
cmd = string.format(
"curl -sS -X %s --max-time %d -o '%s' -w '%s' '%s'",
method, timeout, out, fmt, url
)
end
local raw = run(cmd, timeout + 5) or ""
local http_code = tonumber(raw:match("(%d+)$")) or 0
local body_out = ""
local f = io.open(out, "r")
if f then body_out = f:read("*a") or ""; f:close() end
os.execute("rm -f '" .. out .. "' 2>/dev/null")
return body_out, http_code
end
server:tool("nash_add", "Store a text entry in shared nash memory.", {
type = "object",
properties = {
text = { type = "string", description = "Text content to remember" },
},
required = { "text" },
}, function(a)
local base = nash_url()
if not base then return "Error: set NASH_URL env var (e.g. http://192.168.88.143:8000)" end
local json, code = curl_json(base .. "/add", "POST", '{"text":' .. require("json").encode(a.text) .. '}')
if code ~= 200 then return "Error: nash API HTTP " .. code .. ": " .. json end
return json
end)
server:tool("nash_search", "Semantic search across shared nash memory.", {
type = "object",
properties = {
query = { type = "string", description = "Search query" },
limit = { type = "integer", description = "Max results (default 5)", default = 5 },
},
required = { "query" },
}, function(a)
local base = nash_url()
if not base then return "Error: set NASH_URL env var (e.g. http://192.168.88.143:8000)" end
local body = '{"query":' .. require("json").encode(a.query) .. ',"limit":' .. (a.limit or 5) .. '}'
local json, code = curl_json(base .. "/search", "POST", body)
if code ~= 200 then return "Error: nash API HTTP " .. code .. ": " .. json end
return json
end)
server:tool("nash_list", "List all entries in shared nash memory.", {
type = "object",
properties = {
limit = { type = "integer", description = "Max results (default 100)", default = 100 },
},
}, function(a)
local base = nash_url()
if not base then return "Error: set NASH_URL env var (e.g. http://192.168.88.143:8000)" end
local body = '{"limit":' .. (a.limit or 100) .. '}'
local json, code = curl_json(base .. "/scroll", "POST", body)
if code ~= 200 then return "Error: nash API HTTP " .. code .. ": " .. json end
return json
end)
server:tool("nash_delete", "Delete an entry from shared nash memory by ID.", {
type = "object",
properties = {
id = { type = "string", description = "Entry ID to delete" },
},
required = { "id" },
}, function(a)
local base = nash_url()
if not base then return "Error: set NASH_URL env var (e.g. http://192.168.88.143:8000)" end
local body = '{"id":' .. require("json").encode(a.id) .. '}'
local json, code = curl_json(base .. "/delete", "POST", body)
if code ~= 200 then return "Error: nash API HTTP " .. code .. ": " .. json end
return json
end)
+22 -6
View File
@@ -3,14 +3,30 @@
This directory contains the WiX manifest and packaging files for the This directory contains the WiX manifest and packaging files for the
Windows MSI build of lmcp. Windows MSI build of lmcp.
## Workflow ## Recommended: cross-build on Linux (one command)
```sh ```sh
# 1. Pull the Lua + LuaSocket runtime into pkg/lua/ (one-time, see below). ./build-msi.sh /path/to/output/dir
# 2. Sync the lmcp .lua sources from the root of the repo: ```
./sync.sh
# 3. Bump windows/lmcp.wxs `Version="…"` to match the release tag. Downloads Lua 5.4 Win64 binaries from LuaBinaries, cross-compiles
# 4. Invoke WiX: LuaSocket via `mingw-w64`, stages `pkg/lua/`, and runs `wixl` to
produce `lmcp-<version>.msi`. No Windows VM required.
Prereqs on a Debian/Ubuntu builder:
```sh
sudo apt install wixl unzip gcc-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 mingw-w64-x86-64-dev curl
```
Version comes from `lmcp.wxs` `Version="…"`. Bump that before
building a release.
## Alternative: build on Windows via WiX toolset
```cmd
sync.sh REM see "tracked vs. generated"
REM ensure pkg/lua/ has the runtime — see below
candle.exe lmcp.wxs candle.exe lmcp.wxs
light.exe lmcp.wixobj -o lmcp-1.x.y.msi light.exe lmcp.wixobj -o lmcp-1.x.y.msi
``` ```
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# windows/build-msi.sh — produce lmcp-<ver>.msi on Linux via wixl.
#
# This is the first-time-discovered cross-build path: download Lua 5.4
# Win64 binaries from LuaBinaries, cross-compile LuaSocket with mingw-w64,
# stage windows/pkg/lua/, then invoke wixl on the WiX manifest.
#
# Avoids the VM106-clone + WiX-on-Windows path entirely. ~1 minute on a
# warm cache; ~3-5 minutes cold (downloads ~700 KB + cross-compiles).
#
# Prereqs (apt install on Debian aarch64):
# apt-get install -y wixl unzip gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
# mingw-w64-x86-64-dev
#
# Usage: ./build-msi.sh [output_dir]
# Output: $output_dir/lmcp-<ver>.msi (default: $PWD)
#
# Version comes from windows/lmcp.wxs Version="…" attribute.
set -eu
here=$(dirname "$(readlink -f "$0")")
root=$(cd "$here/.." && pwd)
out_dir=${1:-$PWD}
work=$(mktemp -d /tmp/lmcp-msi-XXXXXX)
trap "rm -rf $work" EXIT
# Versions — bump as upstream releases.
LUA_VER=5.4.2
LUASOCKET_VER=3.1.0
# Pull current lmcp version from the WiX manifest.
lmcp_ver=$(sed -n 's/.*Version="\([^"]*\)".*/\1/p' "$here/lmcp.wxs" | head -1)
[ -n "$lmcp_ver" ] || { echo "build-msi.sh: cannot parse Version from lmcp.wxs" >&2; exit 1; }
echo "build-msi.sh: lmcp $lmcp_ver, lua $LUA_VER, luasocket $LUASOCKET_VER"
echo "==> 1/5 sync lmcp .lua sources into pkg/"
"$here/sync.sh"
echo "==> 2/5 fetch lua $LUA_VER win64 binaries + dev library"
cd "$work"
curl -sSLf -o lua-bin.zip \
"https://downloads.sourceforge.net/project/luabinaries/${LUA_VER}/Tools%20Executables/lua-${LUA_VER}_Win64_bin.zip"
curl -sSLf -o lua-lib.zip \
"https://downloads.sourceforge.net/project/luabinaries/${LUA_VER}/Windows%20Libraries/Dynamic/lua-${LUA_VER}_Win64_dllw6_lib.zip"
mkdir -p luabin lualib include/lua/54 include/lua54 bin/lua/54 bin/lua54 lib/lua/54 lib/lua54
unzip -q -o lua-bin.zip -d luabin
unzip -q -o lua-lib.zip -d lualib
cp lualib/include/*.h include/lua/54/
cp lualib/include/*.h include/lua54/
cp lualib/liblua54.a lib/lua/54/
cp lualib/liblua54.a lib/lua54/
cp lualib/lua54.dll bin/lua/54/
cp lualib/lua54.dll bin/lua54/
echo "==> 3/5 cross-compile LuaSocket $LUASOCKET_VER for win64"
curl -sSLf -o luasocket.tar.gz \
"https://github.com/lunarmodules/luasocket/archive/refs/tags/v${LUASOCKET_VER}.tar.gz"
tar xzf luasocket.tar.gz
cd "luasocket-${LUASOCKET_VER}"
make -s PLAT=mingw \
CC=x86_64-w64-mingw32-gcc \
LD=x86_64-w64-mingw32-gcc \
LUAV=54 \
LUAINC_mingw_base="$work/include" \
LUALIB_mingw_base="$work/bin" \
> /dev/null
echo "==> 4/5 stage pkg/lua/"
pkg_lua="$here/pkg/lua"
rm -rf "$pkg_lua"
mkdir -p "$pkg_lua/socket" "$pkg_lua/mime"
# WiX manifest expects "lua.exe" (not "lua54.exe").
cp "$work/luabin/lua54.exe" "$pkg_lua/lua.exe"
cp "$work/luabin/lua54.dll" "$pkg_lua/lua54.dll"
cp src/socket.lua "$pkg_lua/"
cp src/mime.lua "$pkg_lua/"
cp src/ltn12.lua "$pkg_lua/"
cp src/socket-3.0.0.dll "$pkg_lua/socket/core.dll"
cp src/ftp.lua "$pkg_lua/socket/"
cp src/headers.lua "$pkg_lua/socket/"
cp src/http.lua "$pkg_lua/socket/"
cp src/smtp.lua "$pkg_lua/socket/"
cp src/tp.lua "$pkg_lua/socket/"
cp src/url.lua "$pkg_lua/socket/"
cp src/mime-1.0.3.dll "$pkg_lua/mime/core.dll"
echo "==> 5/5 wixl: produce MSI"
# wixl wants forward slashes; rewrite Windows-style backslashes in Source=.
wxs_tmp="$work/lmcp.wxs"
sed 's|Source="pkg\\|Source="pkg/|g; s|\\\([a-zA-Z]\)|/\1|g' "$here/lmcp.wxs" > "$wxs_tmp"
mkdir -p "$out_dir"
out_msi="$out_dir/lmcp-${lmcp_ver}.msi"
(cd "$here" && wixl -v "$wxs_tmp" -o "$out_msi")
echo ""
echo "==> done: $out_msi"
ls -la "$out_msi"
sha256sum "$out_msi"