Compare commits

...

16 Commits

Author SHA1 Message Date
marfrit 3409a41d85 versions.lua: MCP protocol-version gate, built by the bullpen society
Phase B, third attempt. The contract is the head of tests/phase_b_acceptance.lua;
this is the module that satisfies it.

M.SUPPORTED = {"2025-06-18"} -- and that list is not a guess. The acceptance test
anchors it against a LIVE server: it calls initialize and compares M.SUPPORTED to
the protocolVersion the server actually reports. The first attempt shipped
{"2025-06-18","2025-11-25"} and passed 14/14, because the test then only compared
the list against itself; lmcp does not speak 2025-11-25. Answering HTTP 200 to a
version header proves nothing -- the same endpoint answers 200 to 1999-01-01.

Provenance, all of it in marfrit/bullpen room.jsonl:
  #218/#234  @coder wrote the module (qwen3.6-coding), two passes rejected first:
             pass 1 implemented M.is_supported instead of the contract's M.check
             and embedded its own self-test; pass 2 arrived without a certificate.
  #246       @testdesigner refused to materialise the file in the clone root --
             writing the subject and then greening it yourself collapses the
             implementer/tester split. The refusal was correct.
  #256       two certificates, both from container `testdesign`, same test
             (sha256 78a4758d706a2539, commit e8c18f1e3b):
               empty state -> ROT rc 2 (subject absent)
               these bytes -> GRUEN rc 0, 17/17, live anchor included
  #283       @deus lifted it out through the room; sha256 byte-identical.

sha256 of this file: e5bb0739b84a2354422842cc1be28d2d8ad407307b84e3f05e79faa30e68e5da
2026-08-09 00:06:46 +02:00
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
10 changed files with 999 additions and 14 deletions
+29
View File
@@ -0,0 +1,29 @@
import urllib.request, json, threading, queue, sys
BASE="http://192.168.88.184:8080"
q=queue.Queue()
def sse():
try:
r=urllib.request.urlopen(BASE+"/sse", timeout=30)
for raw in r:
s=raw.decode(errors="replace").strip()
if s.startswith("data:"): q.put(s[5:].strip())
except Exception as e: q.put("ERR:"+str(e))
threading.Thread(target=sse,daemon=True).start()
try:
ep=q.get(timeout=10)
if ep.startswith("ERR:"): print("stash unreachable:",ep); sys.exit(1)
purl=BASE+ep if ep.startswith("/") else ep
def post(o):
urllib.request.urlopen(urllib.request.Request(purl,data=json.dumps(o).encode(),headers={"Content-Type":"application/json"}),timeout=15).read()
post({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"apropos","version":"1"}}})
q.get(timeout=10)
post({"jsonrpc":"2.0","method":"notifications/initialized"})
query=sys.argv[1] if len(sys.argv)>1 else ""
limit=int(sys.argv[2]) if len(sys.argv)>2 else 3
post({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"recall","arguments":{"query":query,"limit":limit}}})
d=json.loads(q.get(timeout=25))
txt=d.get("result",{}).get("content",[{}])[0].get("text","[]")
facts=json.loads(txt)
if not facts: print("(no memory found for: %s)"%query); sys.exit(0)
for f in facts: print("- %s (score %.2f)" % (f.get("content","").strip(), f.get("score",0)))
except Exception as e: print("recall error:",e); sys.exit(1)
+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()
+21 -1
View File
@@ -48,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
@@ -182,6 +195,13 @@ function lmcp:tool(name, description, params_schema, handler, opts)
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,
@@ -939,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
+67 -6
View File
@@ -174,22 +174,39 @@ local function run(cmd, timeout_sec)
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 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 not completed then
if cancelled then return "(cancelled)" end
return output or ("Error: command timed out after " .. timeout_sec .. "s")
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
@@ -283,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)",
@@ -294,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" } },
+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)
+36
View File
@@ -0,0 +1,36 @@
-- versions.lua
-- Modul zur Versionspruefung gemaess LMCP-Vertrag
local M = {}
M.SUPPORTED = {"2025-06-18"}
--- Prueft, ob eine Version unterstuetzt wird.
-- @param version Die zu pruefende Version (String oder nil)
-- @return boolean true wenn unterstuetzt, sonst false
-- @return table|nil Fehlerdetails bei Nichtunterstuetzung
function M.check(version)
-- (a) version == nil oder "" -> true, nil
if version == nil or version == "" then
return true, nil
end
-- (b) exakter Treffer in M.SUPPORTED -> true, nil
for _, supported_version in ipairs(M.SUPPORTED) do
if version == supported_version then
return true, nil
end
end
-- (c) alles andere -> false, { code = -32022, data = { supported = M.SUPPORTED } }
return false, {
code = -32022,
data = {
supported = M.SUPPORTED
}
}
end
-- Keine neuen Globalen, keine Seiteneffekte, M.SUPPORTED wird nicht geaendert
return M