Compare commits

..

22 Commits

Author SHA1 Message Date
Markus Fritsche e8c18f1e3b tests: Phase B an der Wirklichkeit verankern
Der Test prueft M.SUPPORTED bisher nur gegen sich selbst: erster Eintrag
wird angenommen, Unbekanntes abgelehnt, data.supported entspricht
M.SUPPORTED. Ein Modul mit {"2025-06-18","2025-11-25"} besteht ihn mit
14/14 -- obwohl lmcp 2025-11-25 nicht spricht. Genau das ist am
2026-08-08 passiert: die Gesellschaft hat sauber gearbeitet, der Test gab
Rueckgabe 0, und abgenommen wurde der falsche Vertrag.

Drei Instanzen liessen es durch, und keine war nachlaessig: @coder riet
eine echte MCP-Fassung, @foreman konnte es aus dem Vertragstext nicht
widerlegen und bestaetigte es zweimal ausdruecklich, und der Test hatte
die Luecke geerbt. Die Wurzel war der Vertrag: er verlangt "die
Fassungen, die DIESER Server spricht" und nennt sie nicht. Damit war die
Liste eine freie Variable statt einer Tatsache.

DER NAHELIEGENDE FIX WAERE FALSCH GEWESEN. Die Liste im Vertrag zu
NENNEN ersetzt eine ungebundene Variable durch eine zweite Kopie der
Tatsache -- die veraltet, sobald lmcp eine Fassung dazulernt, und dann
schreibt der Vertrag wieder etwas fest, das der Server nicht spricht,
nur andersherum. (Dank an Fable fuer den Widerspruch.)

Stattdessen misst der Test jetzt: er schickt dem laufenden lmcp ein
`initialize` und haelt M.SUPPORTED gegen die gemeldete protocolVersion.
Die Bindung sitzt damit an der Quelle, und keiner der Beteiligten kann
mehr raten.

Ohne erreichbaren Server FAELLT der Test, er ueberspringt nicht. Eine
Abnahme, die ihre zentrale Eigenschaft nicht pruefen kann, ist keine
Abnahme -- und ein stiller Uebersprung ist genau die Art Gruen, gegen die
diese Phase antritt. Gemessen: ohne LMCP_PROBE_URL Rueckgabe 1.

Damit hat die Regel drei Richtungen statt zwei:
  rot gegen eine kaputte Fassung,
  gruen gegen eine korrekte,
  und gruen gegen die WIRKLICHKEIT.

Nachgemessen gegen hertz-tools:8080:
  SUPPORTED = {"2025-06-18"}                 -> 0 Fehlschlaege, Rueckgabe 0
  SUPPORTED = {"2025-06-18","2025-11-25"}    -> 1 Fehlschlag,  Rueckgabe 1
Das zweite ist das Modul, das heute Vormittag als GRUEN abgenommen
wurde. Die Abnahme ist damit rueckwirkend rot -- und das ist das
Ergebnis, nicht der Fehler.

Aufruf: LMCP_PROBE_URL=http://<host>:8080/mcp LMCP_PROBE_TOKEN=<token> \
        lua5.4 tests/phase_b_acceptance.lua <impl.lua>
2026-08-08 13:41:16 +02:00
Markus Fritsche 611a047bef lmcp: LMCP_TOOL_ALLOW — Erlaubnisliste je Instanz
Eine lmcp-Instanz konnte ihren Werkzeugsatz bisher nur ERWEITERN.
tools.d-Dateien fuegen hinzu; der Grundstock aus server.lua bringt
shell, shell_bg, write_file, edit_file, read_file, fetch und web_search
mit, und eine Plugin-Datei kann nichts wegnehmen.

Gemessen am 2026-08-08 im bullpen: jeder Agent-Container haelt den
Raum-Token, also hatte JEDER von ihnen eine Wurzelschale im
Raum-Container. Nachgewiesen aus dem foreman-Container -> uid=0(root),
hostname room, Schreibzugriff auf room.jsonl. Das ist keine
Einbruchsluecke (alle Container sind eine Sicherheitsdomaene), aber es
macht jede Aussage ueber Rollentrennung unbelegbar: der Orchestrator,
der laut Entwurf KEINE Schale haben darf ("Raumtext ist nicht
vertrauenswuerdig"), hat eine — und hat sie im ersten Durchlauf
unaufgefordert benutzt, um die Abnahme seiner eigenen Koordination
auszufuehren.

GEPRUEFT WIRD BEI DER REGISTRIERUNG, nicht nachtraeglich loeschend.
Die Alternative waere eine Plugin-Datei, die nach dem Laden aus
server.tools entfernt — die muss jeden kuenftigen Grundstock-Eintrag
kennen und ist damit wieder eine Liste, die jemand pflegen muss. Genau
solche Listen laufen auseinander (siehe PRIVILEGED, zwei Kopien in zwei
Dateien). An der Registrierung ist die Regel EINE Aussage: was nicht auf
der Liste steht, entsteht nicht — Built-ins wie Plugins, heute wie fuer
alles, was morgen dazukommt.

Rueckwaertsvertraeglich: ohne LMCP_TOOL_ALLOW aendert sich nichts.
Verweigern ist stumm und wirft nicht, damit ein Plugin, das ein
gesperrtes Werkzeug anbietet, weiterlaeuft statt abzustuerzen; tool()
bleibt verkettbar.

tests/test_tool_allow.lua weist es in beide Richtungen nach: Rueckgabe 0
gegen diese Fassung, Rueckgabe 1 mit sieben Fehlschlaegen gegen die
vorige. Der Test prueft ausdruecklich, dass ein verweigertes Werkzeug
auch nicht AUFRUFBAR ist — tools/list und tools/call lesen dasselbe
Register, ein reiner Anzeigefilter waere wertlos gewesen.

Zwei Fallen im Test selbst, als Kommentar festgehalten, weil sie mich
zwei Runden gekostet haben: package.path muss VORNE ergaenzt werden,
sonst gewinnt die installierte /usr/share/lua/5.4/lmcp.lua und der Test
misst den falschen Baum; und die Rueckwaerts-Pruefung darf nur im
Elternlauf laufen, im Kind ist die Liste gesetzt.
2026-08-08 13:26:05 +02:00
Markus Fritsche e44b35b0e0 tests: Abnahmetest fuer Phase B — Fassungen durchsetzen
Phase A stellte fest, welche Fassungen verlangt werden. Phase B lehnt
ab, was der Server nicht spricht. Gemessen am 2026-08-08 an
hertz-tools:8080 antwortet lmcp mit HTTP 200 auf JEDE Fassungsangabe,
auch auf 1999-01-01 - eine Fassung, die es nie gab. Es liest den Kopf
nicht; `Mcp-Protocol-Version` kommt im Quelltext genau einmal vor, in
einer CORS-Erlaubnisliste.

Vertrag im Kopf der Datei. Vier Punkte, die Handfassungen regelmaessig
verfehlen und die deshalb einzeln geprueft werden:

  * Der FEHLENDE Kopf ist zulaessig. Die sitzungslose Abkuerzung
    schickt ihn oft nicht, und sie traegt den meisten Verkehr - eine
    Ablehnung dort legt mehr lahm als sie schuetzt.
  * Die Ablehnung muss die unterstuetzten Fassungen NENNEN. Ohne die
    Liste kann die Gegenstelle nicht nachverhandeln, nur aufgeben.
  * Sie wirft nie. Zahl, Tabelle, Funktion - alles ergibt false. Ein
    Server, der an einem fremden Kopf stirbt, ist schlechter als einer,
    der ihn ignoriert.
  * 2026-07-28 wird abgelehnt, SOLANGE sie nicht in SUPPORTED steht.
    Wer die Zielfassung durchwinkt, bevor er sie spricht, hat den
    Fehler nur verschoben.

Unterscheidungsfaehig nachgewiesen, in beide Richtungen: Rueckgabe 0
gegen eine korrekte Fassung, Rueckgabe 1 mit fuenf Fehlschlaegen gegen
eine absichtlich kaputte.

Aufruf: lua5.4 tests/phase_b_acceptance.lua <impl.lua>
2026-08-08 08:23:08 +02:00
Markus Fritsche 8196f6fb8e tools.d: nash entfernt — mneme ist der Nachfolger
nash-mem0 gibt es nicht mehr. Der Dienst dahinter antwortet nicht
(192.168.88.143:8000, HTTP 000), der Zielcontainer `nash` existiert
auf hertz nicht mehr, und die Deploy-Automatisierung ist heute
ausser Dienst gestellt. Geblieben war das Werkzeug-Modul: das Paket
lieferte weiterhin nash_add/nash_search/nash_list/nash_delete auf
JEDEN Wirt aus, der lmcp installiert.

Aktiv war es nirgends - auf hertz als nash.lua.disabled abgehaengt,
auf boltzmann und dcw2 gar nicht vorhanden, kein lmcp im Netz bietet
ein nash_*-Werkzeug an. Also kein Ausfall, sondern Ballast: vier
Werkzeuge, die ein Modell waehlen KANN und die dann ins Leere laufen.
Ein angebotenes Werkzeug ist ein Versprechen.

Die Nachfolge steht schon: hertz-tools bietet `apropos` und `recall`
gegen mneme, gemessen 26 Werkzeuge, keines davon nash.

Damit faellt es beim naechsten Release von selbst aus dem Paket.
Installierte Kopien bleiben liegen, bis der jeweilige Wirt aktualisiert
- das ist unschaedlich, weil sie ohne Symlink in tools.d nicht geladen
werden.
2026-08-08 08:08:23 +02:00
Markus Fritsche 08e0075d14 tests: Abnahmetest fuer Phase A der Fassungs-Meldung
Phase A des Umstiegs auf MCP 2026-07-28 ist "observe-only": erst
feststellen, welche Protokollfassungen die Gegenstellen ueberhaupt
verlangen, bevor irgendetwas durchgesetzt wird. Der Vertrag dafuer
steht im Kopf der Datei; dies ist der getrennte Test dazu.

GETRENNT ist hier die Bedingung, nicht die Beschreibung. An derselben
Aufgabe war der Defekt an vier aufeinanderfolgenden Laeufen der
SELBSTTEST, nie der Code - wer implementiert, schreibt den Test nicht.
Deshalb ist er hier im Repo und nicht in der Werkstatt der Erzeuger.

Nachgewiesen unterscheidungsfaehig, in beide Richtungen:
  * Rueckgabe 1 und zwei Fehlschlaege gegen eine absichtlich kaputte
    Fassung,
  * Rueckgabe 0 gegen eine korrekte.
Ein Test, der nur besteht, misst nichts.

Zehn Pruefungen, darunter die zwei, die in Handfassungen zuerst
fallen: der Deckel bei 50 darf nicht zu frueh zuschlagen, und das
Modul darf keine neue globale Variable hinterlassen.

Aufruf: lua5.4 tests/phase_a_acceptance.lua <impl.lua>
2026-08-08 08:02:21 +02:00
Markus Fritsche 250f3d38f0 hub: probe ssh-only backends with a TCP connect instead of leaving them unknown
The design note says the probe is lmcp-only because checking ssh "is expensive (3-6s per
offline host) and the hub exists specifically to absorb lots of offline hosts". That holds
for an ssh SESSION. A bare TCP connect to port 22 answers the only question a host card
asks — is the box there — with no handshake and no auth.

Measured on the room host: a dead target costs 1.05s, a live one milliseconds, and riding
the existing parallel fan-out keeps wall clock at one budget window — 3.07s for 14 lmcp
plus 9 ssh probes together, against 3.10s for the 14 lmcp probes alone. The cost objection
is answered rather than ignored.

Eight reachable hosts had been reported as "no probe result": dcw2, deus, escher, hermes,
nash, noether, orca, pipi. They now report UP via=ssh, and a host whose port 22 refuses
gets a real reason ("ssh port unreachable") instead of silence.

Deliberately unchanged: a backend with BOTH paths whose lmcp is down still reads DOWN, per
the existing note that remote_* falls through to ssh regardless. Only ssh-ONLY backends
get the new probe. Without nc the probe reports nothing rather than guessing DOWN — an
unprobed host is honest, a wrongly-asserted one is not.

Two detours worth recording, since both were self-inflicted and cost a restart each:

  * The first attempt edited /opt/lmcp/hub.lua and restarted the service, which loads
    /usr/share/lua/5.4/hub.lua — a separate copy. Nothing keeps the two in step; the log
    line still showed the old format string, which is the only reason it was noticed.
  * The second attempt compared this repository against the deployed file and concluded
    that 120 lines of MCP tool annotations had never been committed. They had. The working
    copy was 17 commits stale, so the "drift" was entirely an artefact of the comparison.
    Against the current tree the real change is 36 lines, and the patched file now hashes
    identical to the running one.
2026-08-02 21:35:15 +02:00
noether (claude) 2bb7b94a66 tools.d: add boltzmann stash tool (fleet-memory CLI wrapper)
Wraps incus exec memory -- docker exec stash-stash-1 /stash <command> behind one
MCP tool so pi-agents call stash command:="recall ... -n /infra/hosts" instead of
hand-typing the incus/docker chain. Loaded via LMCP_TOOLS_DIR=/opt/lmcp/tools.d.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-07-18 13:23:29 +02:00
noether (claude) 80fb60c60f hub: serve apropos + wake_fleet (fleet-central tools) alongside remote_*
Consolidate the two fleet-central local tools (stash recall + pve WoL) onto
the hub broker so :8090 is a complete fleet-management endpoint. They run
locally on hertz via a new run_local() helper (no ssh backend hop). Still
also served by hertz-tools (:8080) for now; the hertz-side removal is the
follow-up once every client (pi-agents) has a @hub session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWpfhDgYNA21tETDP9ueBE
2026-07-18 08:51:16 +02:00
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: marfrit/lmcp#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: marfrit/lmcp#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
Markus Fritsche 7e62f71931 v1.1.0/#18: windows MSI build — sync.sh + tracked manifest
windows/ was previously an untracked working tree with manually-
copied .lua files that drifted ~6 months out of date (missed every
feature added since April 2026). #18 introduces Option 1 from the
issue body: build-time sync.

New tracked files:
  - windows/sync.sh — copies root {lmcp,server,json}.lua to pkg/.
    Idempotent; run before WiX. Catches missing source files; logs
    each sync.
  - windows/README.md — workflow doc + tracked-vs-generated map.
  - windows/lmcp.wxs — MSI manifest (Version bumped 0.1.0 → 1.1.0).
  - windows/pkg/{install_service,start}.bat — Windows service
    installer + launcher (now tracked; they were already in pkg/).

New .gitignore at repo root:
  - windows/pkg/{lmcp,server,json}.lua — regenerated by sync.sh
  - windows/pkg/lua/ — bundled Lua + LuaSocket runtime (downloaded
    separately, not in git)
  - editor noise (*.swp, *.swo, .DS_Store)

Verification (Phase 7):
  $ ./windows/sync.sh
    synced lmcp.lua
    synced server.lua
    synced json.lua
  $ diff lmcp.lua windows/pkg/lmcp.lua  → empty
  $ git ls-files -o --exclude-standard windows/
    windows/README.md
    windows/lmcp.wxs
    windows/pkg/install_service.bat
    windows/pkg/start.bat
    windows/sync.sh
  $ git check-ignore windows/pkg/{lmcp,server,json}.lua  → all 3 ignored

The "missed every feature since April" failure mode this fixes:
running sync.sh before each MSI build now guarantees pkg/ matches
master. Forgetting to run it is failure-loud (the MSI ships the
last sync's snapshot, easy to spot in QA), not silent (the manifest
points at fresh files that mismatch root).

Closes v1.1.0 milestone with #11, #20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:27 +00:00
Markus Fritsche 55ead8041f v1.1.0/#11: progress + cancellation notifications
ctx augmentation:
- ctx.progress(p, total?, message?) emits notifications/progress on
  the session's notify_q. No-op when the original request omitted
  _meta.progressToken (per spec: only emit when client opted in).
  Type-checks numeric args; passes progressToken through unchanged
  (spec allows number OR string keys).
- ctx.cancelled() returns true once the client has sent a
  notifications/cancelled for this request's id.

handle_request:
- New side-effect in the id==nil branch: notifications/cancelled
  scans the module-level _ctx_by_co for an in-flight ctx whose
  request_id matches; flips self._cancelled_ids[rid_str] only when
  found. Unknown rids drop silently (no map growth).
- Pre-handler short-circuit: if cancel arrived before dispatch
  reached tools/call, skip the handler entirely.

Cross-module ctx lookup:
- Module-level weak _ctx_by_co table in lmcp.lua keyed by
  coroutine. lmcp.current_ctx() returns the ctx of the running
  coroutine. server.lua's run() lazy-requires lmcp and uses it
  to opt into auto-cancellation without depending on lmcp internals.

server.lua:run():
- After each sleep_ms cycle, check ctx.cancelled(); exit poll loop
  with cancelled=true if set.
- Poll interval capped at 500ms when a ctx is present so worst-case
  cancel latency stays ≤500ms (vs. 2s default growth).
- Returns "(cancelled)" sentinel; handler propagates normally.

_finalise_dispatch:
- Single cleanup site for both _cancelled_ids and _ctx_by_co (per
  Phase 5 review).
- When was_cancelled: emit JSON-RPC -32800 "Request cancelled"
  (deviation from Phase 4 plan; documented).

Phase 4 deviation explained: plan was silent TCP close (per spec
"SHOULD NOT respond"). Empirically: os.execute's fork+exec
inherits the parent's TCP socket FD into the spawned shell, so
sock:close() doesn't actually deliver FIN until the subshell exits
(i.e. the long-running command completes anyway). Verified
luasocket close() works on bare sockets (curl exits with RST in
511ms). The fix would be FD_CLOEXEC on accepted sockets, which
luasocket doesn't expose — needs a C shim or luaposix. Deferred.
Captured in memory project_fd_inheritance_in_run.

Practical UX with the deviation: client receives a structured
-32800 error within ~420ms of POSTing the cancel notification.

Measurements (Phase 7):
  cancel timing (3 runs, sleep 10 with cancel at 0.4s):
    run 1: t=0.42s code=-32800
    run 2: t=0.42s code=-32800
    run 3: t=0.42s code=-32800
  progress: 3/3 events arrived on SSE; spec-shaped payload
  concurrent fast+slow (#20 regression): unchanged (fast 0.01s)
  all previously-closed issues regression-test green

Zero handler source-code changes. Existing tools (shell, fetch,
web_search, hub remote_*) get cancellation for free via run().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:29:00 +00:00
Markus Fritsche 2ac502e50f v1.1.0/#20: concurrent handler dispatch
Replaces the synchronous tools/call path with a coroutine-wrapped
dispatch. The select()-based event loop from v1.0.0-rc1 already
multiplexes I/O; this change extends the same single-thread
cooperative scheduling to tool handler execution.

How:
- server.lua:sleep_ms detects coroutine context and yields with
  { wake_at = gettime() + ms/1000 } instead of blocking. Falls back
  to today's busy-blocking sleep when on the main thread (stdio
  dispatch, init code).
- server.lua:run() now uses gettime() deltas for timeout accounting
  (Phase 5 review fix — the prior interval-accumulator diverged
  from wall-clock when scheduler delayed resumes).
- lmcp.lua wraps the handle_request call inside _dispatch_post in a
  coroutine. Synchronous completion (no yield) takes the inline-
  response path; if the handler yields, the coroutine parks in
  self._pending_handlers and the conn enters dispatching_async.
- New _scheduler_tick services pending coroutines whose wake_at has
  passed; on completion calls the shared _finalise_dispatch helper
  to build the deferred HTTP response (Accept-aware: SSE or JSON).
- select() timeout tightens to the next pending wake_at so short
  yields don't pay the full 100ms tick.

Measurement (Phase 7):
  before: fast ping during slow shell sleep 3 = 4.28s
  after:  fast ping during slow shell sleep 3 = 0.01s   (~400×)
  3 parallel slow shells: 3.77s total wall (was ~9s).

Zero handler source-code changes. Every existing tool that goes
through run() (shell, shell_bg, fetch, web_search, list_dir,
search_files, systeminfo, hub remote_*) gets concurrency for free.
Pure-Lua handlers (ping, read_file, write_file, edit_file) continue
to complete inline. stdio transport stays serialised by design
(single-client per stdio process).

Known limits documented in memory project_handler_coroutines:
- socket.gettime() is wall-clock not monotonic; large NTP steps may
  bunch resumes. Acceptable on chrony-slewed fleet.
- Cancellation (#11) is now tractable since the scheduler can flip a
  flag between resumes — implementation pending.
- Server-initiated request await (sampling/roots from inside a
  handler) still requires a future yield-on-pending helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:03:06 +00:00
16 changed files with 1736 additions and 54 deletions
+13
View File
@@ -0,0 +1,13 @@
# Generated by windows/sync.sh — see windows/README.md
windows/pkg/lmcp.lua
windows/pkg/server.lua
windows/pkg/json.lua
# Bundled Lua + LuaSocket runtime for the Windows MSI; downloaded
# separately, not in git.
windows/pkg/lua/
# Editor / OS noise
*.swp
*.swo
.DS_Store
+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)
+92 -7
View File
@@ -29,6 +29,8 @@ local PROBE_TTL_UP = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_UP") or "30")
local PROBE_TTL_DOWN_MIN = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_DOWN_MIN") or "60")
local PROBE_TTL_DOWN_MAX = tonumber(os.getenv("LMCP_HUB_PROBE_TTL_DOWN_MAX") or "900")
local PROBE_BUDGET = tonumber(os.getenv("LMCP_HUB_PROBE_BUDGET") or "3")
-- TCP port that answers "is this host there" for ssh-only backends.
local SSH_PROBE_PORT = os.getenv("LMCP_HUB_SSH_PORT") or "22"
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 SSH_HARD_TIMEOUT = tonumber(os.getenv("LMCP_HUB_SSH_HARD_TIMEOUT") or "30")
@@ -272,13 +274,19 @@ end
-- bash fan-out of curl calls. Total wall clock ≈ PROBE_BUDGET.
local function probe_all_parallel(force)
local now = os.time()
local need = {}
local need, need_ssh = {}, {}
for name, b in pairs(backends) do
if b.lmcp_url and (force or not cache_fresh(status[name], now)) then
need[#need+1] = b
if force or not cache_fresh(status[name], now) then
if b.lmcp_url then
need[#need+1] = b
elseif b.ssh_host then
-- ssh-only: no lmcp endpoint to ask, but "is the box there" is still
-- answerable cheaply. See the SSH probe note below.
need_ssh[#need_ssh+1] = b
end
end
end
if #need == 0 then return end
if #need == 0 and #need_ssh == 0 then return end
local script_parts = {}
for _, b in ipairs(need) do
@@ -289,6 +297,21 @@ local function probe_all_parallel(force)
PROBE_BUDGET, b.name, auth, url, b.name
)
end
-- SSH probe. The design note above rejects checking ssh because a session costs
-- 3-6s per offline host — true for a SESSION. A bare TCP connect to 22 answers the
-- only question a host card asks ("is it there") with no handshake and no auth:
-- measured on this host, a dead target costs 1.05s and a live one milliseconds, and
-- it rides the same parallel fan-out, so wall clock stays one budget window.
-- Without nc we report nothing rather than guessing DOWN — a wrong claim is worse
-- than the "no probe result" the dashboard already renders as unknown.
for _, b in ipairs(need_ssh) do
local host = b.ssh_host:gsub("'", "'\\''")
script_parts[#script_parts+1] = string.format(
"(if command -v nc >/dev/null 2>&1; then " ..
"nc -z -w%d '%s' %s >/dev/null 2>&1 && echo '%s SSHUP 0' || echo '%s SSHDOWN 0'; " ..
"else echo '%s SSHSKIP 0'; fi) &",
PROBE_BUDGET, host, SSH_PROBE_PORT, b.name, b.name, b.name)
end
script_parts[#script_parts+1] = "wait"
local t0 = monotonic()
@@ -302,8 +325,14 @@ local function probe_all_parallel(force)
local name, code, t = line:match("^(%S+)%s+(%S+)%s+([%d%.]+)")
if name then
seen[name] = true
local is_up = (code == "200")
if is_up then
if code == "SSHUP" then
apply_probe_result(name, true, nil, "ssh", nil)
elseif code == "SSHDOWN" then
apply_probe_result(name, false, "ssh port unreachable", nil, nil)
elseif code == "SSHSKIP" then
-- nc missing: leave it unprobed rather than assert a state.
seen[name] = nil
elseif code == "200" then
apply_probe_result(name, true, nil, "lmcp", nil)
else
apply_probe_result(name, false, "lmcp code=" .. code, nil, nil)
@@ -316,7 +345,7 @@ local function probe_all_parallel(force)
apply_probe_result(b.name, false, "probe fan-out missing", nil, nil)
end
end
logreq("probe_all_parallel n=%d elapsed=%.2fs", #need, dt)
logreq("probe_all_parallel lmcp=%d ssh=%d elapsed=%.2fs", #need, #need_ssh, dt)
end
-- ---- Call-tool dispatcher ----------------------------------------------
@@ -631,6 +660,62 @@ server:tool("remote_search_files", "find-by-pattern on a fleet host.",
} }
)
-- ---- Fleet-central local tools (migrated from tools.d/hertz.lua, 2026-07-18) ----
-- These run LOCALLY on hertz (where the hub process also lives), so no ssh
-- backend hop. Consolidated here so the hub is the single fleet-management
-- endpoint. Still also served by hertz-tools (:8080) for now — remove there
-- once every client (pi-agents etc.) has a @hub session.
local function run_local(cmd, timeout)
local full = timeout and ("timeout " .. tostring(timeout) .. " " .. cmd) or cmd
local p = io.popen(full .. " 2>&1")
if not p then return "Error: popen failed" end
local out = p:read("*a")
p:close()
return out or ""
end
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_local("python3 /opt/lmcp/helpers/stash_recall.py '" .. q .. "' " .. lim, 30)
end,
{ annotations = {
title = "Apropos (fleet memory)",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
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_local("sudo /root/.local/bin/wake-pve " .. node, 15)
end,
{ annotations = {
title = "Wake fleet NUC",
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true,
} }
)
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()
+326 -23
View File
@@ -7,6 +7,22 @@ local json = require('json')
local lmcp = {}
lmcp.__index = lmcp
-- Module-level coroutine→ctx registry (issue #11). Weak keys so
-- coroutines that die without explicit cleanup get GC'd out.
-- Each ctx table carries a `server` back-reference, so any code with
-- a coroutine handle can find both ctx and its owning lmcp instance.
local _ctx_by_co = setmetatable({}, { __mode = "k" })
-- server.lua and any other library code can call lmcp.current_ctx() to
-- access the ctx of the currently-running dispatch coroutine. Returns
-- nil outside coroutine context. Used by server.lua:run() to do
-- transparent auto-cancellation of long-running shell-out polls.
function lmcp.current_ctx()
local co = coroutine.running()
if co == nil then return nil end
return _ctx_by_co[co]
end
-- Read auth token from config file if present
local function read_conf(path)
local conf = {}
@@ -32,6 +48,19 @@ function lmcp.new(name, opts)
self.host = opts.host or "0.0.0.0"
self.port = opts.port or 8080
self.tools = {}
-- Erlaubnisliste je Instanz (LMCP_TOOL_ALLOW, kommagetrennt). Ist sie
-- gesetzt, registriert `tool()` NUR diese Namen -- Built-ins wie Plugins.
-- Nicht gesetzt: alles wie bisher. Das ist die einzige Stelle, an der ein
-- Werkzeug entsteht, also die einzige, an der man es verhindern kann;
-- nachtraeglich loeschen muss jeden kuenftigen Eintrag kennen und veraltet.
self.tool_allow = nil
do
local roh = os.getenv("LMCP_TOOL_ALLOW")
if roh and roh:match("%S") then
self.tool_allow = {}
for n in roh:gmatch("[^,%s]+") do self.tool_allow[n] = true end
end
end
-- Resources primitive (MCP 2025-06-18 §Server/Resources). Storage is
-- always present; capability is advertised iff `opts.resources` is
-- truthy OR at least one resource/template has been registered by
@@ -69,6 +98,16 @@ function lmcp.new(name, opts)
-- server calls `:roots(session_id, ...)`; invalidated when the client
-- sends notifications/roots/list_changed.
self._roots_cache = {}
-- Pending handler coroutines (issue #20 — concurrent dispatch).
-- Each entry: { co, conn, wake_at, finalise }. The scheduler tick
-- resumes any whose wake_at has passed and runs `finalise` on the
-- coroutine's return value to build the deferred response.
self._pending_handlers = {}
-- Cancellation flags (issue #11). Keyed by stringified JSON-RPC
-- request id. Only ever holds in-flight ids — see the
-- notifications/cancelled handler in handle_request which checks
-- for in-flight before inserting. Cleared by _finalise_dispatch.
self._cancelled_ids = {}
-- Notification queue: drained by Streamable HTTP transport (issue #16).
-- Today delivery is a no-op; we still enqueue so the emission code
-- path is exercised. Capped + deduped to keep the queue useful.
@@ -137,10 +176,36 @@ end
-- structuredContent (issue #13; spec-strict clients get first-class
-- structured access)
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
-- Erlaubnisliste: stumm verweigern, damit ein Plugin, das ein nicht
-- erlaubtes Werkzeug anbietet, nicht abstuerzt -- es existiert einfach
-- nicht. `tools/list` und `tools/call` lesen beide dasselbe Register,
-- ein nicht registriertes Werkzeug ist also weder sichtbar noch rufbar.
if self.tool_allow and not self.tool_allow[name] then
return self
end
self.tools[name] = {
name = name,
description = description,
inputSchema = params_schema or { type = "object", properties = {} },
inputSchema = schema,
handler = handler,
annotations = opts and opts.annotations or nil,
outputSchema = opts and opts.outputSchema or nil,
@@ -413,9 +478,28 @@ function lmcp:handle_request(req)
if method == "notifications/roots/list_changed" then
-- Invalidate cached roots for the session that sent this.
if req._session_id then self._roots_cache[req._session_id] = nil end
elseif method == "notifications/cancelled" then
-- Issue #11 — flip cancel flag for the named request id,
-- but ONLY if the request is actually in-flight. Cancels
-- for unknown/already-completed ids drop silently (per Phase
-- 5 review fix #2 — prevents unbounded map growth).
local rid = (req.params or {}).requestId
if rid ~= nil then
local rid_str = tostring(rid)
local in_flight = false
-- Scan _ctx_by_co for a matching live request.
for _, c in pairs(_ctx_by_co) do
if c.request_id ~= nil
and tostring(c.request_id) == rid_str then
in_flight = true; break
end
end
if in_flight then
self._cancelled_ids[rid_str] = true
end
end
end
-- (Other client→server notifications: cancelled, message — no
-- action today; add side-effects here as needed.)
-- (Other client→server notifications drop silently.)
return nil
end
@@ -484,15 +568,59 @@ function lmcp:handle_request(req)
if not tool then
return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name))
end
-- ctx exposes the request's _meta (issue #13) and the session_id
-- (issue #9 — so handlers can call self:sample(ctx.session_id, …)).
-- Handlers that don't declare a second parameter ignore it (Lua
-- call discards extras).
local ctx = {
-- ctx exposes the request's _meta (issue #13), the session_id
-- (issue #9 — handlers can call self:sample(ctx.session_id, …)),
-- progress() and cancelled() (issue #11), and a `server` back-ref
-- (so lmcp.current_ctx() can find the right server instance
-- without a singleton). Handlers that don't declare a second
-- parameter ignore it (Lua call discards extras).
local rid_str = tostring(id)
local ptoken = (params._meta or {}).progressToken -- nil if absent
local ctx
ctx = {
_meta = params._meta,
request_id = id,
session_id = req._session_id,
server = self,
-- progress(p, total?, message?): emits notifications/progress
-- on session's notify_q. No-op if client didn't supply a
-- progressToken. Type-checks; rejects non-numeric progress.
progress = function(p, total, message)
if ptoken == nil then return false end
if type(p) ~= "number" then return false end
if total ~= nil and type(total) ~= "number" then return false end
local sess = self._sessions[req._session_id]
if not sess then return false end
local np = { progressToken = ptoken, progress = p }
if total ~= nil then np.total = total end
if message ~= nil then np.message = tostring(message) end
sess.notify_q[#sess.notify_q + 1] = {
jsonrpc = JSONRPC, method = "notifications/progress",
params = np,
}
return true
end,
-- cancelled(): true if a notifications/cancelled for this
-- request id has been received.
cancelled = function()
return self._cancelled_ids[rid_str] == true
end,
}
-- Register on the currently-running coroutine so lmcp.current_ctx()
-- (and thus server.lua:run()'s auto-cancel) can find this ctx.
-- Pure-Lua handlers also get this registration; harmless.
local co = coroutine.running()
if co ~= nil then _ctx_by_co[co] = ctx end
-- Pre-handler cancellation short-circuit (Phase 5 review fix #9).
-- If a notifications/cancelled landed for this id before dispatch
-- reached here, skip the handler entirely. _finalise_dispatch
-- will see `not result` and suppress the response.
if self._cancelled_ids[rid_str] then
return nil
end
local ok, result = pcall(tool.handler, arguments, ctx)
if ok then
local resp = { isError = false }
@@ -831,7 +959,7 @@ local function _check_auth(self, conn)
if not self._auth_token then return true end
if conn.method == "OPTIONS" then return true end
local auth = conn.headers["authorization"] or ""
local token = auth:match("^Bearer%s+(.+)$")
local token = auth:match("^[Bb]earer%s+(.+)$")
return token == self._auth_token
end
@@ -887,6 +1015,10 @@ end
-- ---- Dispatch a fully-parsed POST body ----
-- Forward declarations: used by _dispatch_post, defined below.
local _drive_handler_co
local _finalise_dispatch
local function _dispatch_post(self, conn)
local body = conn.body
if body == "" then
@@ -942,28 +1074,112 @@ local function _dispatch_post(self, conn)
-- expose it to handler ctx (issue #9 — sampling needs to know which
-- session to push the request onto).
rpc_req._session_id = sess.id
-- Stash the JSON-RPC id on the conn so _finalise_dispatch can clear
-- the cancellation flag for this request after building the response
-- (issue #11). Notifications have nil id; that's fine — the
-- nil-guard in _finalise_dispatch keeps tostring(nil) out of the
-- cancel map.
conn.dispatch_id = rpc_req.id
-- Normal client request / notification. Dispatch via handle_request.
local response = self:handle_request(rpc_req)
if not response then
-- Concurrent handler dispatch (issue #20). Wrap the dispatch call in
-- a coroutine so any tool handler that goes through server.lua:run()
-- (which yields when polling its sentinel file) can return control to
-- the event loop while it waits. Other connections continue making
-- progress.
--
-- The coroutine resumes itself synchronously the first time. If it
-- completes without yielding (pure-Lua handlers, ping, etc.) the
-- response is built inline as before. If it yields, we park it in
-- self._pending_handlers and return nil — the conn enters
-- dispatching_async, the scheduler tick resumes when wake_at passes.
local co = coroutine.create(function()
return self:handle_request(rpc_req)
end)
return _drive_handler_co(self, conn, co)
end
-- Resume a handler coroutine until it completes or yields. On completion,
-- build the deferred HTTP response (preserving the Accept-aware shape).
-- On yield, register in self._pending_handlers and return nil — the conn
-- is parked in dispatching_async until the scheduler resumes it.
_drive_handler_co = function(self, conn, co)
local rok, ryield = coroutine.resume(co)
if coroutine.status(co) == "dead" then
return _finalise_dispatch(self, conn, rok, ryield, co)
end
-- Suspended. Parse the yield payload.
local wake_at = (type(ryield) == "table" and ryield.wake_at) or 0
self._pending_handlers[#self._pending_handlers + 1] = {
co = co, conn = conn, wake_at = wake_at,
}
conn.state = "dispatching_async"
return nil -- no write_buf change; conn parks
end
-- Build the HTTP response for a completed dispatch. `rok` is the coroutine.resume
-- success flag; `result` is the handler/dispatch return (a JSON-RPC string when
-- rok=true; an error message when rok=false). Used by both the sync path
-- (_dispatch_post tail) and the async resume path (_scheduler_tick).
-- Also: clears cancellation flag and ctx-by-co registry entry for this
-- request (issue #11 — single cleanup site per Phase 5 review fix #7).
_finalise_dispatch = function(self, conn, rok, result, co)
local session_id = conn.session_id
-- Cleanup (always): drop the coroutine's ctx entry and any
-- cancellation flag for this request id.
if co ~= nil then _ctx_by_co[co] = nil end
local rid = conn.dispatch_id
local was_cancelled = false
if rid ~= nil then
local rid_str = tostring(rid)
if self._cancelled_ids[rid_str] then
was_cancelled = true
self._cancelled_ids[rid_str] = nil
end
end
-- Issue #11: cancelled requests get a -32800 JSON-RPC error response.
-- The MCP spec wording is "SHOULD NOT respond" (not MUST NOT). A silent
-- TCP-close would be cleaner but the spawned shell subprocess in
-- server.lua:run() inherits the socket FD via fork(), so the kernel
-- keeps the connection alive until that shell exits (i.e. the
-- underlying long-running command completes anyway). The error
-- response gives the client a structured signal and exits curl
-- immediately, which is the practical UX they want. JSON-RPC 2.0
-- code -32800 is the convention for "Request cancelled."
if was_cancelled then
return _build_http_response("200 OK",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
jsonrpc_error(rid, -32800, "Request cancelled"),
session_id)
end
if not rok then
-- Internal dispatch error — surface as a JSON-RPC error response.
return _build_http_response("500 Internal Server Error",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
jsonrpc_error(nil, -32603, "Internal error: " .. tostring(result)),
session_id)
end
if not result then
-- Notification → 202 Accepted, no body.
return _build_http_response("202 Accepted",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
"", conn.session_id)
"", session_id)
end
-- If client accepts SSE, respond as a single-event SSE stream.
-- Otherwise plain JSON body.
-- Accept-aware response shape (re-checked at finalise time; survives
-- parking because conn.headers is captured by the closure scope).
local accept = conn.headers["accept"] or ""
if accept:find("text/event%-stream") then
local hdrs = _build_sse_headers(conn.session_id)
return hdrs .. _sse_event(response)
local hdrs = _build_sse_headers(session_id)
return hdrs .. _sse_event(result)
end
return _build_http_response("200 OK",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
response, conn.session_id)
result, session_id)
end
local function _dispatch_options(conn)
@@ -1119,8 +1335,19 @@ local function _conn_read(self, conn)
conn.state = "sse_open"
conn.last_heart = os.time()
elseif conn.method == "POST" then
conn.write_buf = _dispatch_post(self, conn)
conn.state = "writing"
-- _dispatch_post may return nil (issue #20) if the handler
-- coroutine yielded. In that case it set conn.state =
-- "dispatching_async" itself and parked the coroutine.
local resp = _dispatch_post(self, conn)
if resp then
conn.write_buf = resp
-- _finalise_dispatch sets conn.state = "closing" for
-- cancelled requests (issue #11); only override if not.
if conn.state ~= "closing" then
conn.state = "writing"
end
end
-- else: conn already parked; scheduler tick will finalise.
else
conn.write_buf = _build_http_response("405 Method Not Allowed",
{ ["Content-Type"] = "text/plain",
@@ -1195,6 +1422,73 @@ local function _heartbeat_tick(self)
end
end
-- Issue #20 — scheduler tick. Resume any parked dispatch coroutine whose
-- wake_at has passed. On completion, build the deferred response and
-- queue it for write. If the connection died while the handler was
-- parked, drop the coroutine.
--
-- gettime() is wall-clock (luasocket uses gettimeofday) — NOT monotonic.
-- A large NTP step backwards could delay resumes; forwards could bunch
-- them. Acceptable for the deployment fleet (chrony slews); revisit if
-- a use case appears that needs CLOCK_MONOTONIC.
local function _scheduler_tick(self)
if not self._pending_handlers[1] then return end
local socket = require("socket")
local now = socket.gettime()
local i = 1
while i <= #self._pending_handlers do
local p = self._pending_handlers[i]
if p.conn.state == "closing" then
-- Connection died mid-handler; drop the coroutine entirely
-- and free its ctx entry (issue #11 cleanup discipline).
_ctx_by_co[p.co] = nil
if p.conn.dispatch_id ~= nil then
self._cancelled_ids[tostring(p.conn.dispatch_id)] = nil
end
table.remove(self._pending_handlers, i)
elseif now >= p.wake_at then
-- Time to resume. Remove from pending BEFORE resume so a
-- re-yielding handler re-adds itself cleanly via _drive_handler_co.
table.remove(self._pending_handlers, i)
local rok, ryield = coroutine.resume(p.co)
if coroutine.status(p.co) == "dead" then
local resp = _finalise_dispatch(self, p.conn, rok, ryield, p.co)
p.conn.write_buf = (p.conn.write_buf or "") .. resp
-- _finalise_dispatch may set conn.state = "closing" for
-- cancelled requests; only transition to writing if it
-- didn't already pick the closing path.
if p.conn.state ~= "closing" then
p.conn.state = "writing"
end
else
-- Yielded again — re-park.
local wake_at = (type(ryield) == "table" and ryield.wake_at) or 0
self._pending_handlers[#self._pending_handlers + 1] = {
co = p.co, conn = p.conn, wake_at = wake_at,
}
end
else
i = i + 1
end
end
end
-- Returns the earliest pending wake_at as an offset from now, or nil if
-- no handlers are parked. Used to tighten the select() timeout so the
-- scheduler wakes on the right beat.
local function _next_pending_delay(self)
if not self._pending_handlers[1] then return nil end
local socket = require("socket")
local now = socket.gettime()
local earliest = math.huge
for _, p in ipairs(self._pending_handlers) do
if p.wake_at < earliest then earliest = p.wake_at end
end
local d = earliest - now
if d < 0 then return 0 end
return d
end
-- ---- Public: server-initiated request (for sampling/roots/etc.) ----
-- Enqueues a JSON-RPC request on the session's SSE stream. The callback
-- fires when the client POSTs back the response (matched by id).
@@ -1322,7 +1616,14 @@ function lmcp:run()
end
end
local ready_r, ready_w = socket.select(reads, writes, SELECT_TIMEOUT)
-- Tighten select timeout if a parked handler is due sooner.
-- Otherwise a 100ms tick adds 100ms latency to short shell-tool runs.
local select_timeout = SELECT_TIMEOUT
local next_pend = _next_pending_delay(self)
if next_pend and next_pend < select_timeout then
select_timeout = next_pend
end
local ready_r, ready_w = socket.select(reads, writes, select_timeout)
for _, sock in ipairs(ready_r or {}) do
if sock == server_sock then
@@ -1365,9 +1666,11 @@ function lmcp:run()
-- Per-tick maintenance.
_drain_notifications(self)
_heartbeat_tick(self)
_scheduler_tick(self) -- issue #20: resume due dispatch coroutines
-- After draining, attempt immediate writes on conns whose write_buf
-- just got bytes (so list_changed / heartbeat appears within one tick).
-- just got bytes (so list_changed / heartbeat / async-completed
-- responses appear within one tick).
for sock, conn in pairs(self._conns) do
if conn.write_buf ~= "" and conn.state ~= "closing" then
pcall(_conn_write, conn)
+190 -24
View File
@@ -35,7 +35,51 @@ local function tmpname()
end
end
-- Lazy-required luasocket — only needed in the coroutine path for
-- gettime(). Avoids forcing luasocket as a hard dep at server.lua
-- load time (callers like example_server already require it via lmcp).
local _socket = nil
local function gettime()
if not _socket then _socket = require("socket") end
return _socket.gettime()
end
-- Lazy access to the lmcp module for cross-module ctx lookup (issue #11).
-- server.lua doesn't statically require lmcp (it's an example/runtime
-- server, not the library); but lmcp must already be loaded when we run.
-- Defensive: if the lookup fails for any reason, current_ctx returns nil
-- and run() falls back to non-cancellable behaviour.
local _lmcp_mod = nil
local function current_ctx()
if _lmcp_mod == false then return nil end
if _lmcp_mod == nil then
local ok, mod = pcall(require, "lmcp")
_lmcp_mod = ok and mod or false
if _lmcp_mod == false then return nil end
end
return _lmcp_mod.current_ctx and _lmcp_mod.current_ctx() or nil
end
-- in_coroutine() — true if we're running inside an lmcp dispatch
-- coroutine (issue #20). Handles both Lua 5.4 (coroutine.running →
-- (co, isMain)) and LuaJIT 5.1 (coroutine.running → nil on main).
local function in_coroutine()
local co, is_main = coroutine.running()
if co == nil then return false end -- 5.1 / LuaJIT main
if is_main then return false end -- 5.4 main thread
return true
end
local function sleep_ms(ms)
-- Coroutine-aware: yield with a wake deadline instead of busy-blocking.
-- The lmcp event loop services I/O for other connections while this
-- coroutine sleeps, then resumes it once the deadline elapses.
-- (Issue #20: gives concurrent tool dispatch without changing handler
-- source code — tools that go through run() get it for free.)
if in_coroutine() then
coroutine.yield({ wake_at = gettime() + (ms / 1000) })
return
end
if WINDOWS then
-- ping loopback: ~1s per -n count. For sub-second, use busy-wait.
if ms < 500 then
@@ -78,6 +122,35 @@ local function run(cmd, timeout_sec)
local out_file = base .. ".out"
local done_file = base .. ".done"
-- Wall-clock deadline rather than an accumulated interval-counter:
-- when we're inside a dispatch coroutine (issue #20), the scheduler
-- may delay our resume by more than `interval`, so an accumulator
-- diverges from real elapsed. gettime() comparison stays honest in
-- both busy-poll and yield-resume modes.
--
-- Auto-cancellation (issue #11): if a ctx is available on the
-- running coroutine AND it has been cancelled, exit the polling
-- loop early. The interval is capped at 500ms when a ctx is
-- present so worst-case cancel latency is ~0.5s, not ~2s.
local started = gettime()
local cancelled = false
local function poll_loop()
local interval = WINDOWS and 100 or 50 -- ms
while gettime() - started < timeout_sec do
if file_exists(done_file) then return true end
local ctx = current_ctx()
if ctx and ctx.cancelled and ctx.cancelled() then
cancelled = true
return false
end
sleep_ms(interval)
if interval < 2000 then interval = math.floor(interval * 1.5) end
-- When cancellable, cap so we can respond to cancel quickly.
if ctx and interval > 500 then interval = 500 end
end
return false
end
if WINDOWS then
-- Write a batch wrapper that runs the command and signals completion
local bat_file = base .. ".bat"
@@ -89,49 +162,51 @@ local function run(cmd, timeout_sec)
bf:close()
os.execute('start /B cmd /C "' .. bat_file .. '"')
-- Poll for sentinel
local elapsed = 0
local interval = 100 -- ms
while elapsed < timeout_sec * 1000 do
if file_exists(done_file) then break end
sleep_ms(interval)
elapsed = elapsed + interval
if interval < 2000 then interval = math.floor(interval * 1.5) end
end
local completed = poll_loop()
local output = read_file(out_file)
remove_silent(bat_file)
remove_silent(out_file)
remove_silent(done_file)
if elapsed >= timeout_sec * 1000 then
if not completed then
if cancelled then return "(cancelled)" end
return output or ("Error: command timed out after " .. timeout_sec .. "s")
end
return output and output ~= "" and output or "(no output)"
else
-- POSIX: use shell backgrounding + wait with timeout
-- sh -c '(cmd > out 2>&1; echo $? > done) &' then poll
-- POSIX: run in its OWN session/process group (setsid) so a
-- 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(
"(%s) > '%s' 2>&1; echo $? > '%s'",
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 elapsed = 0
local interval = 50 -- ms
while elapsed < timeout_sec * 1000 do
if file_exists(done_file) then break end
sleep_ms(interval)
elapsed = elapsed + interval
if interval < 2000 then interval = math.floor(interval * 1.5) end
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)
remove_silent(out_file)
remove_silent(done_file)
if elapsed >= timeout_sec * 1000 then
return output or ("Error: command timed out after " .. timeout_sec .. "s")
if not completed then
if cancelled then return "(cancelled -- process group killed)" end
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
return output and output ~= "" and output or "(no output)"
end
@@ -142,6 +217,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 ----
@@ -218,7 +300,15 @@ server:tool("shell_bg",
f:close()
os.remove(pid_file)
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, {
annotations = {
title = "Run shell (background)",
@@ -229,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.", {
type = "object",
properties = { path = { type = "string" } },
@@ -1008,6 +1134,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
+113
View File
@@ -0,0 +1,113 @@
-- Abnahmetest fuer lmcp Phase A (nur beobachten, nie ablehnen).
--
-- Vom Vertrag geschrieben, NICHT von der Implementierung: der Autor dieses
-- Tests hat den zu pruefenden Code nicht gesehen. Genau daran sind die
-- letzten vier Runden gescheitert - der Implementierer hat seine eigenen
-- Hausaufgaben korrigiert, und der Test prueft dann verlaesslich das, was
-- der Code ohnehin tut.
--
-- VERTRAG
-- Eine einzelne, in sich geschlossene Lua-5.4-Datei stellt eine Tabelle M
-- bereit mit:
-- M.report(version, peer, ua) -- Produktionseinstieg
-- M.sink(version, peer, ua) -- ueberschreibbar, wird beim ERSTEN
-- -- Auftreten eines Tripels gerufen
-- * jedes verschiedene Tripel (version, peer, ua) genau EINMAL
-- * version nil oder "" -> nichts
-- * der Entprellungszustand ist ein privates Upvalue: KEINE globale
-- Variable, KEIN Parameter, den der Aufrufer mitgeben muss
-- * gedeckelt bei 50 verschiedenen Tripeln; danach nichts mehr
-- * M.report lehnt NIE etwas ab und liefert immer nil
--
-- Aufruf: lua5.4 phase_a_acceptance.lua <zu-pruefende-datei.lua>
local pfad = arg and arg[1]
if not pfad then
io.stderr:write("usage: lua5.4 phase_a_acceptance.lua <impl.lua>\n")
os.exit(2)
end
local vorher_global = {}
for k in pairs(_G) do vorher_global[k] = true end
local lade = assert(loadfile(pfad))
local M = lade()
if type(M) ~= "table" then
io.stderr:write("FAIL: die Datei liefert keine Tabelle zurueck\n")
os.exit(1)
end
local fehler = 0
local function pruefe(name, bedingung, zusatz)
if bedingung then
print((" [ok ] %s"):format(name))
else
fehler = fehler + 1
print((" [FAIL ] %s%s"):format(name, zusatz and ("" .. zusatz) or ""))
end
end
-- Faenger einhaengen
local gesehen = {}
M.sink = function(v, p, u)
gesehen[#gesehen + 1] = { v = v, p = p, u = u }
end
local function zuruecksetzen() gesehen = {} end
pruefe("M.report existiert und ist aufrufbar", type(M.report) == "function")
if type(M.report) ~= "function" then os.exit(1) end
-- 1. nil und Leerstring melden nichts
zuruecksetzen()
M.report(nil, "peerA", "uaA")
M.report("", "peerA", "uaA")
pruefe("nil und \"\" melden nichts", #gesehen == 0,
("es kamen %d Meldungen"):format(#gesehen))
-- 2. erstes Tripel meldet genau einmal
zuruecksetzen()
M.report("2025-06-18", "peerA", "uaA")
pruefe("erstes Tripel meldet einmal", #gesehen == 1)
-- 3. DER FALL, DER DREI RUNDEN LANG DURCHRUTSCHTE:
-- Entprellung am ECHTEN Einstieg, ohne dass der Aufrufer Zustand mitgibt.
zuruecksetzen()
for _ = 1, 5 do M.report("2025-06-18", "peerA", "uaA") end
pruefe("fuenf gleiche Tripel -> keine weitere Meldung", #gesehen == 0,
("es kamen %d Meldungen; Zustand ueberlebt den Aufruf nicht")
:format(#gesehen))
-- 4. gleiche Fassung, andere Gegenstelle -> meldet wieder
zuruecksetzen()
M.report("2025-06-18", "peerB", "uaA")
pruefe("gleiche Fassung, andere Gegenstelle -> Meldung", #gesehen == 1,
"Entprellung nur auf die Fassung wuerde andere Klienten maskieren")
-- 5. gleiche Fassung und Gegenstelle, anderer User-Agent -> meldet wieder
zuruecksetzen()
M.report("2025-06-18", "peerB", "uaZ")
pruefe("anderer User-Agent -> Meldung", #gesehen == 1)
-- 6. liefert immer nil, lehnt nie ab
local r1 = M.report("2026-07-28", "peerC", "uaC")
local r2 = M.report("2026-07-28", "peerC", "uaC")
pruefe("M.report liefert nil (lehnt nie ab)", r1 == nil and r2 == nil)
-- 7. Deckel bei 50
zuruecksetzen()
for i = 1, 80 do M.report("v" .. i, "peerD", "uaD") end
pruefe("Deckel greift bei 50", #gesehen <= 50,
("es kamen %d Meldungen"):format(#gesehen))
pruefe("Deckel wirft nicht zu frueh", #gesehen >= 40,
("nur %d Meldungen vor dem Deckel"):format(#gesehen))
-- 8. kein globaler Zustand
local neue = {}
for k in pairs(_G) do
if not vorher_global[k] then neue[#neue + 1] = k end
end
pruefe("keine neuen globalen Variablen", #neue == 0,
"hinzugekommen: " .. table.concat(neue, ", "))
print((" %d Pruefungen fehlgeschlagen"):format(fehler))
os.exit(fehler == 0 and 0 or 1)
+195
View File
@@ -0,0 +1,195 @@
-- Abnahmetest fuer Phase B der Angleichung an MCP 2026-07-28.
--
-- Phase A war beobachtend: feststellen, welche Fassungen ueberhaupt verlangt
-- werden. Phase B setzt durch. Gemessen am 2026-08-08 an hertz-tools:8080
-- antwortet lmcp mit HTTP 200 auf JEDE Fassungsangabe - auch auf 1999-01-01,
-- eine Fassung, die es nie gab. Es liest den Kopf schlicht nicht.
--
-- VERTRAG. Eine in sich geschlossene Lua-5.4-Datei, Tabelle M:
--
-- M.SUPPORTED Liste der Fassungen, die dieser Server spricht.
-- M.check(version) -> true, nil wenn zulaessig
-- -> false, <fehlertabelle> sonst
--
-- 1. version == nil oder "" -> zulaessig. Ein fehlender Kopf ist erlaubt;
-- der Server nimmt dann seine Grundfassung an. Das ist kein Sonderfall
-- aus Bequemlichkeit: die sitzungslose Abkuerzung schickt ihn oft nicht,
-- und sie traegt den meisten Verkehr.
-- 2. Genaue Uebereinstimmung mit einem Eintrag in M.SUPPORTED -> zulaessig.
-- 3. Alles andere -> false plus Fehlertabelle mit code == -32022 und einer
-- data.supported-Liste, die GENAU M.SUPPORTED entspricht. Ohne die Liste
-- kann die Gegenstelle nicht nachverhandeln, sie kann nur aufgeben.
-- 4. Wirft nie. Zahl, Tabelle, Wahrheitswert, Funktion - alles beantwortet
-- sie mit false, nicht mit einem Laufzeitfehler. Ein Server, der an
-- einem fremden Kopf stirbt, ist schlechter als einer, der ihn ignoriert.
-- 5. Aendert M.SUPPORTED nicht. Ein Aufruf darf die Liste des naechsten
-- nicht verschieben.
-- 6. Keine neue globale Variable.
--
-- Aufruf: lua5.4 phase_b_acceptance.lua <impl.lua>
local impl_path = arg and arg[1]
if not impl_path then
io.stderr:write("usage: lua5.4 phase_b_acceptance.lua <impl.lua>\n")
os.exit(2)
end
-- Globale Variablen VOR dem Laden festhalten, damit Regel 6 pruefbar ist.
local vorher = {}
for k in pairs(_G) do vorher[k] = true end
local chunk, lerr = loadfile(impl_path)
if not chunk then
io.stderr:write("kann " .. impl_path .. " nicht laden: " .. tostring(lerr) .. "\n")
os.exit(2)
end
local ok_load, M = pcall(chunk)
if not ok_load then
io.stderr:write("Laden warf: " .. tostring(M) .. "\n")
os.exit(2)
end
local fehler = 0
local function pruefe(name, bedingung, detail)
if bedingung then
print(string.format("[ok ] %s", name))
else
fehler = fehler + 1
print(string.format("[FEHLER] %s%s", name, detail and (" -> " .. tostring(detail)) or ""))
end
end
-- 0. Form
pruefe("M ist eine Tabelle", type(M) == "table", type(M))
if type(M) ~= "table" then print(fehler .. " Pruefungen fehlgeschlagen"); os.exit(1) end
pruefe("M.check ist aufrufbar", type(M.check) == "function", type(M.check))
pruefe("M.SUPPORTED ist eine nicht-leere Liste",
type(M.SUPPORTED) == "table" and #M.SUPPORTED >= 1, type(M.SUPPORTED))
if type(M.check) ~= "function" or type(M.SUPPORTED) ~= "table" then
print(fehler .. " Pruefungen fehlgeschlagen"); os.exit(1)
end
local function ruf(v)
local ok, a, b = pcall(M.check, v)
return ok, a, b
end
-- 1. fehlender Kopf
local ok, zulaessig = ruf(nil)
pruefe("nil ist zulaessig", ok and zulaessig == true, ok and tostring(zulaessig) or "warf")
ok, zulaessig = ruf("")
pruefe("leerer String ist zulaessig", ok and zulaessig == true, ok and tostring(zulaessig) or "warf")
-- 2. bekannte Fassung
local bekannt = M.SUPPORTED[1]
ok, zulaessig = ruf(bekannt)
pruefe("bekannte Fassung " .. tostring(bekannt) .. " ist zulaessig",
ok and zulaessig == true, ok and tostring(zulaessig) or "warf")
-- 3. unbekannte Fassung -> -32022 samt Liste
local ok3, zul3, err3 = ruf("1999-01-01")
pruefe("unbekannte Fassung wird abgelehnt", ok3 and zul3 == false,
ok3 and tostring(zul3) or "warf")
pruefe("Ablehnung traegt code -32022",
ok3 and type(err3) == "table" and err3.code == -32022,
ok3 and type(err3) == "table" and tostring(err3.code) or type(err3))
local liste_ok = false
if ok3 and type(err3) == "table" and type(err3.data) == "table"
and type(err3.data.supported) == "table" then
liste_ok = (#err3.data.supported == #M.SUPPORTED)
for i = 1, #M.SUPPORTED do
if err3.data.supported[i] ~= M.SUPPORTED[i] then liste_ok = false end
end
end
pruefe("Ablehnung nennt genau M.SUPPORTED", liste_ok)
-- 3b. die Fassung, auf die wir zuwandern, ist noch NICHT zulaessig.
-- Wer 2026-07-28 durchwinkt, bevor er sie spricht, hat den Fehler nur verschoben.
local ok3b, zul3b, err3b = ruf("2026-07-28")
local spricht_neu = false
for i = 1, #M.SUPPORTED do if M.SUPPORTED[i] == "2026-07-28" then spricht_neu = true end end
if spricht_neu then
pruefe("2026-07-28 steht in SUPPORTED und wird angenommen", ok3b and zul3b == true)
else
pruefe("2026-07-28 wird abgelehnt, solange sie nicht in SUPPORTED steht",
ok3b and zul3b == false and type(err3b) == "table" and err3b.code == -32022,
ok3b and tostring(zul3b) or "warf")
end
-- 3c. VERANKERUNG: M.SUPPORTED gegen den LAUFENDEN Server.
--
-- Ohne diese Pruefung misst der Test die Liste nur an sich selbst -- und ein
-- Modul, das eine Fassung beansprucht, die der Server nicht spricht, besteht
-- ihn glatt. Genau das ist am 2026-08-08 passiert: {"2025-06-18","2025-11-25"}
-- ergab 14/14, obwohl lmcp nur 2025-06-18 meldet.
--
-- Gefragt wird per `initialize`; die Antwort nennt genau EINE protocolVersion,
-- naemlich die, die dieser Server spricht. M.SUPPORTED muss exakt daraus
-- bestehen.
--
-- Kein Uebersprung, wenn der Server fehlt: eine Abnahme, die ihre zentrale
-- Eigenschaft nicht pruefen kann, ist keine Abnahme.
local probe_url = os.getenv("LMCP_PROBE_URL")
local probe_token = os.getenv("LMCP_PROBE_TOKEN")
pruefe("LMCP_PROBE_URL ist gesetzt (ohne Server keine Verankerung)",
probe_url ~= nil and probe_url ~= "", tostring(probe_url))
if probe_url and probe_url ~= "" then
local rumpf = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":' ..
'{"protocolVersion":"2025-06-18","capabilities":{},' ..
'"clientInfo":{"name":"phase-b-acceptance","version":"1"}}}'
local befehl = "curl -s -m 15 -X POST"
.. " -H 'Content-Type: application/json'"
.. " -H 'Accept: application/json, text/event-stream'"
if probe_token and probe_token ~= "" then
befehl = befehl .. " -H 'Authorization: Bearer " .. probe_token .. "'"
end
befehl = befehl .. " -d '" .. rumpf .. "' '" .. probe_url .. "' 2>/dev/null"
local p = io.popen(befehl)
local antwort = p and p:read("*a") or ""
if p then p:close() end
local gemessen = antwort:match('"protocolVersion"%s*:%s*"([^"]+)"')
pruefe("Server nennt eine protocolVersion", gemessen ~= nil,
(#antwort > 0) and antwort:sub(1, 70) or "keine Antwort")
if gemessen then
local passt = (#M.SUPPORTED == 1) and (M.SUPPORTED[1] == gemessen)
pruefe("M.SUPPORTED entspricht GENAU dem, was der Server spricht ("
.. gemessen .. ")", passt, table.concat(M.SUPPORTED, ","))
end
end
-- 4. wirft nie
local fremde = { 42, true, false, {}, print, 0/0 }
local alle_still, welcher = true, nil
for _, v in ipairs({42, true, {}, print}) do
local okx, zulx = pcall(M.check, v)
if not okx or zulx ~= false then alle_still = false; welcher = tostring(v) end
end
pruefe("fremde Typen ergeben false statt Laufzeitfehler", alle_still, welcher)
-- 5. SUPPORTED bleibt unangetastet
local kopie = {}
for i, v in ipairs(M.SUPPORTED) do kopie[i] = v end
ruf("1999-01-01"); ruf(bekannt); ruf(nil)
local unveraendert = (#kopie == #M.SUPPORTED)
for i = 1, #kopie do if kopie[i] ~= M.SUPPORTED[i] then unveraendert = false end end
pruefe("M.SUPPORTED wird durch Aufrufe nicht veraendert", unveraendert)
-- 5b. wiederholte Ablehnung bleibt gleich (kein verbrauchbarer Zustand)
local _, _, e1 = ruf("1999-01-01")
local _, _, e2 = ruf("1999-01-01")
pruefe("zweite Ablehnung ist so vollstaendig wie die erste",
type(e1) == "table" and type(e2) == "table"
and e1.code == e2.code
and type(e2.data) == "table" and type(e2.data.supported) == "table")
-- 6. keine neuen Globalen
local neu = {}
for k in pairs(_G) do if not vorher[k] then neu[#neu + 1] = tostring(k) end end
pruefe("keine neuen globalen Variablen", #neu == 0, table.concat(neu, ","))
print(fehler .. " Pruefungen fehlgeschlagen")
os.exit(fehler == 0 and 0 or 1)
+124
View File
@@ -0,0 +1,124 @@
-- Ausfuehrbare Zusicherung fuer LMCP_TOOL_ALLOW.
--
-- Hintergrund: eine lmcp-Instanz konnte ihren Werkzeugsatz nur ERWEITERN.
-- tools.d-Dateien fuegen hinzu; der Grundstock aus server.lua bringt shell,
-- write_file und Verwandte mit, und eine Plugin-Datei kann nichts wegnehmen.
-- Am 2026-08-08 hatte damit jeder Agent mit dem Raum-Token eine Wurzelschale
-- im Raum-Container -- nachgewiesen: uid=0(root), Schreibzugriff auf
-- room.jsonl. Das ist keine Einbruchsluecke (eine Sicherheitsdomaene), aber
-- es macht jede Aussage ueber Rollentrennung unbelegbar.
--
-- Geprueft wird an der REGISTRIERUNG, nicht nachtraeglich loeschend: was nicht
-- auf der Liste steht, entsteht gar nicht -- fuer Built-ins wie fuer Plugins,
-- heute wie fuer alles, was spaeter dazukommt.
--
-- Aufruf: lua5.4 tests/test_tool_allow.lua
local hier = arg[0]:match('(.*/)') or './'
-- VORNE anhaengen, nicht hinten. Sonst gewinnt die INSTALLIERTE Fassung unter
-- /usr/share/lua/5.4/lmcp.lua, und der Test prueft nicht den Baum, in dem er
-- liegt -- gemessen am 2026-08-08: der Test gab rot, obwohl der Code stimmte.
package.path = hier .. '../?.lua;' .. package.path
local fehler = 0
local function pruefe(name, bedingung, detail)
if bedingung then
print(string.format("[ok ] %s", name))
else
fehler = fehler + 1
print(string.format("[FEHLER] %s%s", name, detail and (" -> " .. tostring(detail)) or ""))
end
end
local function namen(server)
local t = {}
for n in pairs(server.tools) do t[#t + 1] = n end
table.sort(t)
return t
end
local function enthaelt(liste, wert)
for _, v in ipairs(liste) do if v == wert then return true end end
return false
end
-- lmcp frisch laden, damit die Umgebungsvariable beim Anlegen gilt.
local function frisch()
package.loaded['lmcp'] = nil
return require('lmcp')
end
local leer = { type = "object" }
local function nichts() return "x" end
-- 1. Ohne die Variable aendert sich nichts (Rueckwaertsvertraeglichkeit).
-- Nur im ELTERNLAUF: im Kind ist die Liste gesetzt, dort waere die Aussage
-- falsch und der Test wuerde sich selbst widerlegen.
if os.getenv("LMCP_TOOL_ALLOW") == nil then
local lmcp = frisch()
local s = lmcp.new("probe-offen", { port = 0 })
s:tool("room_say", "d", leer, nichts)
s:tool("shell", "d", leer, nichts)
local n = namen(s)
pruefe("ohne LMCP_TOOL_ALLOW bleibt alles registriert",
enthaelt(n, "room_say") and enthaelt(n, "shell"), table.concat(n, ","))
end
-- Ab hier mit Liste. lmcp liest sie beim Anlegen der Instanz, also muss sie
-- VOR lmcp.new() in der Umgebung stehen -- in Lua nur ueber einen Kindprozess
-- setzbar, deshalb startet der Test sich selbst neu.
if os.getenv("LMCP_TOOL_ALLOW") == nil then
local eigen = arg[0]
local rc = os.execute(
'LMCP_TOOL_ALLOW="room_say,room_read,lease_acquire" lua5.4 "' .. eigen .. '" --kind')
local ok = (rc == true or rc == 0)
pruefe("Teillauf mit gesetzter Liste besteht", ok, tostring(rc))
print(fehler .. " Pruefungen fehlgeschlagen")
os.exit(fehler == 0 and 0 or 1)
end
-- --- Kindlauf: LMCP_TOOL_ALLOW ist gesetzt -----------------------------------
do
local lmcp = frisch()
local s = lmcp.new("probe-eng", { port = 0 })
-- erlaubt
s:tool("room_say", "d", leer, nichts)
s:tool("room_read", "d", leer, nichts)
s:tool("lease_acquire", "d", leer, nichts)
-- nicht erlaubt: genau die, die die Wurzelschale ausmachten
s:tool("shell", "d", leer, nichts)
s:tool("shell_bg", "d", leer, nichts)
s:tool("write_file", "d", leer, nichts)
s:tool("edit_file", "d", leer, nichts)
s:tool("read_file", "d", leer, nichts)
local n = namen(s)
pruefe("erlaubte Werkzeuge sind da",
enthaelt(n, "room_say") and enthaelt(n, "room_read") and enthaelt(n, "lease_acquire"),
table.concat(n, ","))
pruefe("shell ist NICHT registriert", not enthaelt(n, "shell"))
pruefe("shell_bg ist NICHT registriert", not enthaelt(n, "shell_bg"))
pruefe("write_file ist NICHT registriert", not enthaelt(n, "write_file"))
pruefe("edit_file ist NICHT registriert", not enthaelt(n, "edit_file"))
pruefe("read_file ist NICHT registriert", not enthaelt(n, "read_file"))
pruefe("genau drei Werkzeuge uebrig", #n == 3, table.concat(n, ","))
-- Der Kern: `tools/list` und `tools/call` lesen DASSELBE Register. Ein
-- nicht registriertes Werkzeug ist also nicht bloss unsichtbar, es ist
-- nicht rufbar. Waere es nur aus der Liste gefiltert, bliebe es erreichbar.
pruefe("verweigertes Werkzeug ist auch nicht aufrufbar",
s.tools["shell"] == nil)
-- Die Registrierung darf nicht werfen: ein Plugin, das ein gesperrtes
-- Werkzeug anbietet, soll weiterlaufen, nicht abstuerzen.
local ok = pcall(function() s:tool("shell", "d", leer, nichts) end)
pruefe("Registrierung eines gesperrten Werkzeugs wirft nicht", ok)
-- Verkettung muss erhalten bleiben (tool() gibt self zurueck).
local zurueck = s:tool("shell", "d", leer, nichts)
pruefe("tool() liefert weiterhin self (verkettbar)", zurueck == s)
end
print(fehler .. " Pruefungen fehlgeschlagen")
os.exit(fehler == 0 and 0 or 1)
+30
View File
@@ -0,0 +1,30 @@
-- boltzmann-tools plugin: `stash` — fleet persistent-memory CLI wrapper.
-- Receives (server, run). Runs the stash CLI inside the memory Incus container's
-- stash-stash-1 docker container (which has NO shell — /stash is invoked directly
-- as argv; docker exec handles that, no `sh -c` inside the container).
local server, run = ...
server:tool("stash",
"Fleet persistent memory (stash knowledge graph on the memory container). "
.. "`command` = a stash subcommand + its args as ONE string. "
.. "Examples: command:=\"recall escher plug AIN\" | command:=\"facts\" | "
.. "command:=\"remember 'the NAS is at 192.168.88.10'\" | command:=\"namespace list\". "
.. "Wrap multi-word text in single quotes. Read subcommands: recall, facts, namespace list, "
.. "goal list, context show. Write: remember, forget, consolidate run.",
{ type = "object", properties = {
command = { type = "string",
description = "stash subcommand + args, e.g. \"recall <query>\" or \"remember '<text>'\"" },
}, required = { "command" } },
function(a)
local c = tostring(a.command or ""):gsub("^%s+", ""):gsub("%s+$", "")
if c == "" then return "Error: command required (e.g. command:=\"recall <query>\")" end
return run("incus exec memory -- docker exec stash-stash-1 /stash " .. c, 60)
end,
{ annotations = {
title = "Stash fleet memory",
readOnlyHint = false,
destructiveHint = false,
idempotentHint = false,
openWorldHint = true,
} }
)
+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)
+57
View File
@@ -0,0 +1,57 @@
# lmcp Windows MSI build
This directory contains the WiX manifest and packaging files for the
Windows MSI build of lmcp.
## Recommended: cross-build on Linux (one command)
```sh
./build-msi.sh /path/to/output/dir
```
Downloads Lua 5.4 Win64 binaries from LuaBinaries, cross-compiles
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
light.exe lmcp.wixobj -o lmcp-1.x.y.msi
```
## What's tracked vs. generated
- **Tracked** (edit in git):
- `lmcp.wxs` — WiX MSI manifest
- `sync.sh` — copies root .lua sources → `pkg/`
- `README.md` — this file
- `pkg/install_service.bat` — Windows service installer
- `pkg/start.bat` — manual launcher
- **Generated / external** (gitignored):
- `pkg/lmcp.lua`, `pkg/server.lua`, `pkg/json.lua` — produced by
`sync.sh`. Never edit directly; edit the root files and re-sync.
- `pkg/lua/` — the Lua + LuaSocket runtime drop-in. Download
separately and place here. Suggested source: the lua-binaries
project (https://github.com/rjpcomputing/luaforwindows) or a
similar pre-built bundle. The MSI expects `pkg/lua/lua.exe`,
`pkg/lua/lua54.dll`, and the `pkg/lua/socket/` + `pkg/lua/mime/`
subdirectories per the manifest.
## Issue history
Issue #18 (closed in v1.1.0) introduced this workflow after the
`pkg/` lua sources had silently drifted ~6 months out of date,
missing every feature added since April 2026.
+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"
+119
View File
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<!-- Bump Version on every release. See windows/README.md. -->
<Product Id="*"
Name="lmcp — Lua MCP Server"
Language="1033"
Version="1.1.0"
Manufacturer="QAP'LA Project"
UpgradeCode="A7F3E2D1-4B5C-6D7E-8F9A-0B1C2D3E4F5A">
<Package InstallerVersion="200"
Compressed="yes"
InstallScope="perMachine"
Description="Lightweight MCP server in Lua. 2MB RSS."
Comments="Zero-dependency MCP server." />
<MediaTemplate EmbedCab="yes" />
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed." />
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="ProgramFiles64Folder">
<Directory Id="INSTALLFOLDER" Name="lmcp">
<Directory Id="LUA_DIR" Name="lua">
<Directory Id="SOCKET_DIR" Name="socket" />
<Directory Id="MIME_DIR" Name="mime" />
</Directory>
</Directory>
</Directory>
</Directory>
<!-- lmcp application files -->
<DirectoryRef Id="INSTALLFOLDER">
<Component Id="JsonLua" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567890">
<File Id="json.lua" Source="pkg\json.lua" KeyPath="yes" />
</Component>
<Component Id="LmcpLua" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567891">
<File Id="lmcp.lua" Source="pkg\lmcp.lua" KeyPath="yes" />
</Component>
<Component Id="ServerLua" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567892">
<File Id="server.lua" Source="pkg\server.lua" KeyPath="yes" />
</Component>
<Component Id="StartBat" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567893">
<File Id="start.bat" Source="pkg\start.bat" KeyPath="yes" />
</Component>
<Component Id="InstallService" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567894">
<File Id="install_service.bat" Source="pkg\install_service.bat" KeyPath="yes" />
</Component>
</DirectoryRef>
<!-- Lua runtime -->
<DirectoryRef Id="LUA_DIR">
<Component Id="LuaExe" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678900">
<File Id="lua.exe" Source="pkg\lua\lua.exe" KeyPath="yes" />
</Component>
<Component Id="LuaDll" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678901">
<File Id="lua54.dll" Source="pkg\lua\lua54.dll" KeyPath="yes" />
</Component>
<Component Id="SocketLua" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678902">
<File Id="socket.lua" Source="pkg\lua\socket.lua" KeyPath="yes" />
</Component>
<Component Id="MimeLua" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678903">
<File Id="mime.lua" Source="pkg\lua\mime.lua" KeyPath="yes" />
</Component>
<Component Id="Ltn12Lua" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678904">
<File Id="ltn12.lua" Source="pkg\lua\ltn12.lua" KeyPath="yes" />
</Component>
</DirectoryRef>
<!-- LuaSocket native DLLs -->
<DirectoryRef Id="SOCKET_DIR">
<Component Id="SocketCoreDll" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789010">
<File Id="socket_core.dll" Name="core.dll" Source="pkg\lua\socket\core.dll" KeyPath="yes" />
</Component>
<Component Id="SocketFtp" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789011">
<File Id="ftp.lua" Source="pkg\lua\socket\ftp.lua" KeyPath="yes" />
</Component>
<Component Id="SocketHeaders" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789012">
<File Id="headers.lua" Source="pkg\lua\socket\headers.lua" KeyPath="yes" />
</Component>
<Component Id="SocketHttp" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789013">
<File Id="http.lua" Source="pkg\lua\socket\http.lua" KeyPath="yes" />
</Component>
<Component Id="SocketTp" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789014">
<File Id="tp.lua" Source="pkg\lua\socket\tp.lua" KeyPath="yes" />
</Component>
<Component Id="SocketUrl" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789015">
<File Id="url.lua" Source="pkg\lua\socket\url.lua" KeyPath="yes" />
</Component>
</DirectoryRef>
<DirectoryRef Id="MIME_DIR">
<Component Id="MimeCoreDll" Guid="E4D5F6A7-B8C9-0123-DEFA-234567890120">
<File Id="mime_core.dll" Name="core.dll" Source="pkg\lua\mime\core.dll" KeyPath="yes" />
</Component>
</DirectoryRef>
<Feature Id="MainFeature" Title="lmcp Server" Level="1">
<ComponentRef Id="JsonLua" />
<ComponentRef Id="LmcpLua" />
<ComponentRef Id="ServerLua" />
<ComponentRef Id="StartBat" />
<ComponentRef Id="InstallService" />
<ComponentRef Id="LuaExe" />
<ComponentRef Id="LuaDll" />
<ComponentRef Id="SocketLua" />
<ComponentRef Id="MimeLua" />
<ComponentRef Id="Ltn12Lua" />
<ComponentRef Id="SocketCoreDll" />
<ComponentRef Id="SocketFtp" />
<ComponentRef Id="SocketHeaders" />
<ComponentRef Id="SocketHttp" />
<ComponentRef Id="SocketTp" />
<ComponentRef Id="SocketUrl" />
<ComponentRef Id="MimeCoreDll" />
</Feature>
</Product>
</Wix>
+24
View File
@@ -0,0 +1,24 @@
@echo off
REM Install lmcp as a Windows service using NSSM (Non-Sucking Service Manager)
REM Download nssm from https://nssm.cc if not present
if not exist "%~dp0nssm.exe" (
echo ERROR: nssm.exe not found in %~dp0
echo Download from https://nssm.cc and place nssm.exe here.
exit /b 1
)
set INSTALL_DIR=%~dp0
set SERVICE_NAME=lmcp
echo Installing lmcp as Windows service...
%INSTALL_DIR%nssm.exe install %SERVICE_NAME% "%INSTALL_DIR%lua\lua.exe" "%INSTALL_DIR%server.lua"
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% AppDirectory "%INSTALL_DIR%"
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% AppEnvironmentExtra "LMCP_PORT=8080"
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% DisplayName "lmcp MCP Server"
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% Description "Lightweight MCP server in Lua"
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% Start SERVICE_AUTO_START
%INSTALL_DIR%nssm.exe start %SERVICE_NAME%
echo Done. Service '%SERVICE_NAME%' installed and started.
echo Check: sc query %SERVICE_NAME%
+7
View File
@@ -0,0 +1,7 @@
@echo off
REM lmcp — Lua MCP Server
REM Start the server on port 8080 (or LMCP_PORT if set)
cd /d "%~dp0"
if not defined LMCP_PORT set LMCP_PORT=8080
echo Starting lmcp on port %LMCP_PORT%...
lua\lua.exe server.lua
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# windows/sync.sh — refresh windows/pkg/ from root .lua sources (issue #18).
#
# Run BEFORE invoking the WiX build so the MSI bundles whatever is in
# master. The .lua files in windows/pkg/ are regenerated on every run
# and are gitignored — never edit them directly.
#
# Idempotent: re-running just re-copies. Safe to call from a Makefile,
# a CI step, or by hand before `candle.exe + light.exe`.
set -eu
here=$(dirname "$(readlink -f "$0")")
root=$(cd "$here/.." && pwd)
for f in lmcp.lua server.lua json.lua; do
if [ ! -f "$root/$f" ]; then
echo "windows/sync.sh: missing source $root/$f" >&2
exit 1
fi
cp "$root/$f" "$here/pkg/$f"
echo " synced $f"
done
echo "windows/sync.sh: done — pkg/ matches root .lua at $(date +%Y-%m-%dT%H:%M:%S)"