2 Commits

Author SHA1 Message Date
test0r 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
test0r 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
+47
View File
@@ -200,6 +200,13 @@ end
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,
-- 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 ----
@@ -1066,6 +1073,46 @@ if WINDOWS then
})
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"
if transport == "stdio" then
if os.getenv("LMCP_PORT") then