3 Commits

Author SHA1 Message Date
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
test0r 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
test0r 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
4 changed files with 182 additions and 7 deletions
+20 -1
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,
+40
View File
@@ -1066,6 +1066,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
+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"