From e05438f0e377a43f365584ff9fec5682509bca0d Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 17 May 2026 23:32:12 +0000 Subject: [PATCH] =?UTF-8?q?v1.2.0/#22:=20tools.d/=20plugin=20scan=20?= =?UTF-8?q?=E2=80=94=20host-local=20tool=20extensions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 < --- server.lua | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/server.lua b/server.lua index 531b2e7..f2457f1 100644 --- a/server.lua +++ b/server.lua @@ -1066,6 +1066,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