Compare commits

..

24 Commits

Author SHA1 Message Date
marfrit cb4f4834c2 tests: dual-protocol acceptance, covering the transport the suite cannot see
tests/konformitaet_2026_07_28.py speaks HTTP only. Review finding #1 lived on
stdio, so 27/27 green and the defect coexisted happily: on stdio `initialize`
answered -32601 Method not found, which is the first message any client sends.

This test crosses both protocols with both transports and echoes "hello world"
through the shell tool in each of the four cells. It also carries its own
controls, because a suite that cannot go red measures nothing:

  R1  an invented version (1999-01-01) must be REJECTED, not served.
  R2  running it against the code from BEFORE the fix must FAIL. That is the
      echo guard, and it was exercised: against 4ac9296 exactly one cell goes
      red -- 3a stdio/alt initialize, -32601 -- while all four HTTP cells stay
      green. Against 175244a all eight pass.

The two protocols are deliberately NOT tested symmetrically, because they are
not symmetric: 2026-07-28 has no `initialize` (SEP-2575 removed it and the new
path drops it to -32601 on purpose), and the envelope is enforced in the HTTP
dispatcher only, so over stdio the version is announced through _meta alone. A
test that sent the same flow to both surfaces would pass for the wrong reason.

Run: python3 tests/dual_protocol_acceptance.py <lmcp-dir> [port]
Measured 2026-08-11 in a throwaway Incus container on boltzmann (Debian 13,
lua5.4 + lua-socket), the sources checked by sha256 on both ends before the run.
A finer point than the review had: `tools/call` was never version-gated, so it
worked on stdio even before the fix. Only `initialize` was broken.
2026-08-11 17:21:29 +02:00
marfrit 175244ab89 lmcp: review fixes 1, 4 and 6 — legacy routing that survives the transport
From @deus, bundle sha256 f38898d3…, verified byte-identical on arrival;
lmcp.lua sha256 57fb2413…, the checksum quoted in the handover. Only lmcp.lua
changes; versions.lua and envelope.lua are byte-identical to what is already on
this branch. luac -p passes on all three.

What the three fixes do, checked in the file rather than taken on trust — the
conformance suites are HTTP-only and structurally cannot see finding 1:

  #1  handle_request now DERIVES _legacy when it arrives unset, instead of
      relying on the HTTP transport to have pre-set it. Previously req._legacy
      was written in exactly one place (_dispatch_post) and read in three, and
      run_stdio calls handle_request directly — so on stdio initialize, ping and
      logging/setLevel fell through to -32601 Method not found. That is the
      first message any client sends, and it worked unconditionally on master.

  #6  An empty protocol version now counts as "none announced" rather than as
      an unknown one. Router and versions.check had disagreed: check("") returns
      true by rule a, while the router excluded "" from the legacy set and sent
      it down the new path to be rejected by the envelope.

  #4  Legacy replies go through jsonrpc_result_legacy, which does NOT inject
      resultType. The claim that these methods "keep the old shape" was not true
      on the wire before: legacy ping answered {"resultType":"complete"} where
      master answered {}.

Still open from the same review, deliberately not in this commit: #2 (the
server-initiated SSE machinery is unreachable — nothing sets sess.sse_conn since
the GET branch was removed), #3 (GET/DELETE answer 405 to legacy clients too,
because _legacy is consulted at method dispatch and not at HTTP verb routing),
#5 (routing accepts a header-only or _meta-only announcement, envelope.check
then demands both), #7 (-32021 mapped but never produced, -32600 unmapped).

Still NOT on master.
2026-08-11 14:21:41 +02:00
marfrit 4ac9296f08 lmcp: dual protocol 2025-06-18 + 2026-07-28, and envelope checking
The result of the bullpen campaign, carried out by @deus as operator with
@coder on versions.lua (job 1158). Both conformance suites green, attested with
0 open points; the record is on branch kampagne/2026-07-28 in this repo.

What it does:
  versions.lua  M.SUPPORTED gains 2026-07-28, old version first so an existing
                client keeps negotiating what it always did.
  lmcp.lua      requires envelope; routes on req._legacy, so initialize, ping
                and logging/setLevel keep answering the old shape while the new
                protocol gets the envelope path. resultType defaults to
                "complete" on every result.
  envelope.lua  new -- header and body checks, returns the -32020 family.

104 of the 180 added lines in lmcp.lua are comments: the campaign also carried
the German-to-English translation, so the diff looks larger than the behaviour
change is.

NOT yet on master. lmcp runs on around ten hosts in this fleet; a green suite
says the spec is satisfiable, not that it is complete or true. This branch
exists so a second pair of eyes reads it before it reaches any of them.

The copy fix in versions.lua (err.data.supported handed out as a COPY, so a
caller cannot hijack the module table) predates this work -- it was the
uncommitted German state, archived on wip/lmcp-deutsch-2026-08-09.
2026-08-11 00:08:32 +02:00
marfrit 6530d9d318 tests: conformance suite for MCP 2026-07-28, cut from the specification
Answering "do we have a test suite that tests conformance with 2026-07-28?" —
until now, no. What existed tested that lmcp enforces 2025-06-18, the revision
we are migrating away from.

Cut from the specification pages themselves, fetched 2026-08-09, not from the
changelog and not from any second-hand delta:

    /specification/2026-07-28/                                (index)
    /specification/2026-07-28/basic                           (messages, _meta, error codes)
    /specification/2026-07-28/basic/versioning                (version negotiation)
    /specification/2026-07-28/server/discover                 (DiscoverResult shape)
    /specification/2026-07-28/basic/transports/streamable-http (headers, statuses)

That mattered: the delta-derived draft had two field names wrong. It is
`supportedVersions`, not `protocolVersions`, and `serverInfo` lives inside
`_meta`, not at the top level. The spec also carries requirements the changelog
never mentioned — a request missing a required `_meta` field must be refused
with -32602 AND HTTP 400, and the version rejection carries `data.requested`
alongside `data.supported`.

Every check quotes the normative sentence it comes from, so a failure names its
source rather than my reading of it. Both the JSON-RPC code and the HTTP status
are asserted, because the spec normalises both: a body-only check would pass a
server answering 200 where 400 is required.

Measured against today's lmcp: 26 of 27 open. The one pass is -32601 for an
unknown method, and even that returns HTTP 200 instead of 404.

Notable among the open points: initialize, ping and logging/setLevel still
answer though they are removed; -32002 is still emitted where the spec says MUST
NOT; no result carries the now-mandatory resultType; no header is validated; GET
on the endpoint returns 200 instead of 405.

A high number here is the measurement, not a defect of the suite: it is the
distance to the target.
2026-08-09 16:29:18 +02:00
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
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
15 changed files with 1954 additions and 108 deletions
+54
View File
@@ -0,0 +1,54 @@
local M = {}
function M.check(header, body)
if not header or not body then
return false, -32600, "Invalid Request"
end
-- Rule 1: header['mcp-protocol-version'] missing
if not header['mcp-protocol-version'] then
return false, -32020, 'Missing required header: MCP-Protocol-Version'
end
-- Rule 2: header['mcp-method'] missing
if not header['mcp-method'] then
return false, -32020, 'Missing required header: Mcp-Method'
end
-- Rule 2: header['mcp-method'] ~= body.method
if header['mcp-method'] ~= body.method then
return false, -32020, 'Mcp-Method header does not match body'
end
-- Rule 3: Mcp-Name header required for tools/call, resources/read, prompts/get
local method = body.method
local params = body.params or {}
if method == 'tools/call' or method == 'prompts/get' then
if not header['mcp-name'] or header['mcp-name'] ~= params.name then
return false, -32020, 'Mcp-Name header does not match body'
end
elseif method == 'resources/read' then
if not header['mcp-name'] or header['mcp-name'] ~= params.uri then
return false, -32020, 'Mcp-Name header does not match body'
end
end
-- Rule 4: _meta required fields (located under params._meta)
local meta = (body.params or {})._meta or {}
if not meta['io.modelcontextprotocol/protocolVersion'] then
return false, -32602, 'Missing required _meta field: io.modelcontextprotocol/protocolVersion'
end
if not meta['io.modelcontextprotocol/clientCapabilities'] then
return false, -32602, 'Missing required _meta field: io.modelcontextprotocol/clientCapabilities'
end
-- Rule 5: header['mcp-protocol-version'] ~= meta['io.modelcontextprotocol/protocolVersion']
if header['mcp-protocol-version'] ~= meta['io.modelcontextprotocol/protocolVersion'] then
return false, -32020, 'Header MCP-Protocol-Version does not match _meta protocolVersion'
end
-- Success
return true, nil, nil
end
return M
+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()
+301 -89
View File
@@ -3,6 +3,16 @@
-- SPDX-License-Identifier: MIT
local json = require('json')
-- The version check. Phase B built it but never wired it up: measured on
-- 2026-08-09, `versions` occurred zero times in this file, and the running
-- server answered `initialize` with "1999-01-01" with HTTP 200 and
-- "2025-06-18" -- verbatim the defect it was written against.
local versions = require('versions')
-- The envelope (envelope check 2026-07-28): required headers (Mcp-Method,
-- MCP-Protocol-Version, Mcp-Name) + required _meta fields are checked before
-- method dispatch. Based on job667.lua (coder run 5), two review fixes
-- (mcp-name header, _meta under params._meta).
local envelope = require('envelope')
local lmcp = {}
lmcp.__index = lmcp
@@ -37,7 +47,12 @@ local function read_conf(path)
end
-- Protocol constants
local MCP_VERSION = "2025-06-18"
-- ONE truth. The version used to live twice in the tree -- here as a
-- constant and in versions.M.SUPPORTED -- and the response line took
-- this one. Changing the list therefore did NOT change what the server
-- answers: measured with {"2025-06-18","2025-03-26"}, a call to
-- initialize(2025-03-26) returned a 200 with "2025-06-18".
local MCP_VERSION = versions.SUPPORTED[1]
local JSONRPC = "2.0"
function lmcp.new(name, opts)
@@ -48,6 +63,19 @@ function lmcp.new(name, opts)
self.host = opts.host or "0.0.0.0"
self.port = opts.port or 8080
self.tools = {}
-- Per-instance allowlist (LMCP_TOOL_ALLOW, comma-separated). When set,
-- `tool()` registers ONLY those names -- built-ins like plugins.
-- When unset: everything as before. This is the only place where a
-- tool is created, hence the only place where it can be prevented;
-- removing one later means every future entry must know and stale out.
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
@@ -163,10 +191,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
-- Allow-list: silently refuse, so a plugin offering a tool that is not
-- allowed does not crash -- it simply does not exist. `tools/list` and
-- `tools/call` both read the same registry, so an unregistered tool is
-- neither visible nor callable.
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,
@@ -414,18 +468,75 @@ function lmcp:log(level, logger, data)
end
-- JSON-RPC response helpers
-- MCP 2026-07-28: "The result MUST include a resultType field to indicate the
-- type of the result." Hence here and not at the fourteen call sites:
-- there should not be a single place that could forget it.
--
-- NEVER mutate the passed object. `json.empty_object` is a singleton
-- (json.lua:235), two callers pass it straight through, and the encoder
-- recognizes it by IDENTITY (json.lua:63) -- a write would have poisoned it
-- process-wide AND the field would still have been swallowed.
local function jsonrpc_result(id, result)
local copy = {}
if type(result) == "table" then
for k, v in pairs(result) do copy[k] = v end
end
-- "input_required" is set by the caller itself (MRTR); everything else
-- is "complete". An existing field is not overwritten.
if copy.resultType == nil then copy.resultType = "complete" end
return json.encode({ jsonrpc = JSONRPC, id = id, result = copy })
end
-- Legacy (2025-06-18) result encoder: the old server's jsonrpc_result did
-- NOT inject resultType -- that field belongs to the 2026-07-28 result
-- envelope (review finding #4: the legacy branches must keep the old wire
-- shape; a strict old client that validates ping's empty result would
-- break on a foreign resultType).
local function jsonrpc_result_legacy(id, result)
return json.encode({ jsonrpc = JSONRPC, id = id, result = result })
end
local function jsonrpc_error(id, code, message)
local function jsonrpc_error(id, code, message, data)
-- `data` is optional and only set when present: all existing callers
-- pass three arguments and do not change.
-- Without this field, `data.supported` of the version check would fall
-- away on the way, and the rejection would be formally correct and
-- practically useless -- the client would not learn which version the
-- server speaks.
local err = { code = code, message = message }
if data ~= nil then err.data = data end
return json.encode({
jsonrpc = JSONRPC,
id = id,
error = { code = code, message = message },
error = err,
})
end
-- What this server can do. ONE computation for `initialize` and
-- `server/discover`. Two places deriving the same thing are the disease
-- this campaign found three times today (TRUST vs. roster,
-- MCP_VERSION vs. M.SUPPORTED, the delta's stage boundary).
function lmcp:_capabilities()
local caps = { tools = { listChanged = false } }
if self._force_resources_cap
or next(self.resources)
or self.resource_templates[1] then
caps.resources = { listChanged = true, subscribe = false }
end
if self._force_prompts_cap or next(self.prompts) then
caps.prompts = { listChanged = true }
end
if self._force_completions_cap or next(self.completions) then
-- Spec uses an empty object as the "supported" marker.
-- json.empty_object → {} (not [] from the empty-table gotcha).
caps.completions = json.empty_object
end
if self._force_logging_cap then
caps.logging = json.empty_object
end
return caps
end
-- Handle a single JSON-RPC request
function lmcp:handle_request(req)
local method = req.method
@@ -464,42 +575,78 @@ function lmcp:handle_request(req)
return nil
end
if method == "initialize" then
self._session_id = self._session_id or tostring(os.time())
-- Dual-protocol routing, transport-agnostic (review fix #1): the HTTP
-- path pre-sets _announced/_legacy in _dispatch_post (it can see the
-- MCP-Protocol-Version header); stdio reaches handle_request directly
-- with NO headers, so derive here from _meta only. Empty string counts
-- as "no version announced" (versions.check rule a, review fix #6).
if req._legacy == nil then
local meta_v = ((req.params or {})._meta or {})
["io.modelcontextprotocol/protocolVersion"]
if meta_v == "" then meta_v = nil end
req._announced = meta_v
req._legacy = (meta_v == nil or meta_v == "2025-06-18")
end
-- Legacy surface (2025-06-18), reachable only when the router announced
-- the old version or none at all (E2: no version = old). SEP-2575 removed
-- these from 2026-07-28; on the NEW path they fall through to the default
-- branch (-32601 + 404) exactly as the suite expects. Capabilities come
-- from the ONE `_capabilities()` computation, not a second copy.
if req._legacy and method == "initialize" then
-- Capture client capabilities (MCP issue #9 — sampling needs to
-- know if the client supports it before issuing a request).
local p = req.params or {}
self._client_caps = p.capabilities or {}
self._client_info = p.clientInfo or {}
local caps = { tools = { listChanged = false } }
if self._force_resources_cap
or next(self.resources)
or self.resource_templates[1] then
caps.resources = { listChanged = true, subscribe = false }
end
if self._force_prompts_cap or next(self.prompts) then
caps.prompts = { listChanged = true }
end
if self._force_completions_cap or next(self.completions) then
-- Spec uses an empty object as the "supported" marker.
-- json.empty_object → {} (not [] from the empty-table gotcha).
caps.completions = json.empty_object
end
if self._force_logging_cap then
caps.logging = json.empty_object
end
return jsonrpc_result(id, {
return jsonrpc_result_legacy(id, {
protocolVersion = MCP_VERSION,
capabilities = caps,
capabilities = self:_capabilities(),
serverInfo = {
name = self.name,
version = self.version,
},
})
elseif req._legacy and method == "ping" then
return jsonrpc_result_legacy(id, json.empty_object)
elseif req._legacy and method == "logging/setLevel" then
local lvl = (req.params or {}).level
if type(lvl) ~= "string" or not LOG_LEVELS[lvl] then
return jsonrpc_error(id, -32602,
"level must be one of: debug, info, notice, warning, error, critical, alert, emergency")
end
self._log_level = lvl
return jsonrpc_result_legacy(id, json.empty_object)
end
elseif method == "ping" then
return jsonrpc_result(id, json.empty_object)
-- MCP 2026-07-28, /server/discover: "Servers MUST implement it."
-- Additive: a client of the old version never calls it. Placed BEFORE
-- `initialize` so it stays reachable should the latter fall away.
if method == "server/discover" then
-- Field names from the spec's example, not from the changelog: it is
-- `supportedVersions` (not protocolVersions), and serverInfo sits IN
-- `_meta`, not at the top. Both were guessed wrong in the draft.
local supported = {}
for i = 1, #versions.SUPPORTED do supported[i] = versions.SUPPORTED[i] end
local caps = self:_capabilities()
return jsonrpc_result(id, {
resultType = "complete",
supportedVersions = supported,
-- An empty Lua table encodes as `[]`, not `{}` -- the spec
-- requires an object. (Finding @testdesigner, room #622.)
capabilities = next(caps) and caps or json.empty_object,
_meta = {
["io.modelcontextprotocol/serverInfo"] = {
name = self.name,
version = self.version,
},
},
})
-- SEP-2575: `initialize`, `ping` and `logging/setLevel` are gone from
-- spec 2026-07-28 and fall into the default branch here
-- (jsonrpc_error -32601, HTTP 404). Version negotiation moves into
-- the envelope (envelope check, last block).
elseif method == "tools/list" then
local tool_list = {}
for _, t in pairs(self.tools) do
@@ -655,10 +802,15 @@ function lmcp:handle_request(req)
end
local entry, args = _resolve_resource(self, uri)
if not entry then
-- -32002 is the MCP-conventional "Resource not found" code;
-- distinct from -32602 "Invalid params" so retry/UX logic
-- can tell a malformed URI from a missing one.
return jsonrpc_error(id, -32002, "Resource not found: " .. uri)
-- MCP 2026-07-28: "Implementations of this protocol version MUST
-- NOT emit these codes: -32002 — resource not found … replaced by
-- -32602." The earlier objection recorded here (32002 distinguishing
-- a missing from a malformed URI) is thereby settled: the spec has
-- deliberately merged both into -32602, and a receiver "MUST NOT
-- assume any specific meaning" for the old range. The difference
-- lives on in the message text, where it can no longer be confused
-- with a protocol promise.
return jsonrpc_error(id, -32602, "Resource not found: " .. uri)
end
local item, err = _read_resource(entry, args, uri)
if not item then
@@ -686,7 +838,11 @@ function lmcp:handle_request(req)
end
local entry = self.prompts[name]
if not entry then
return jsonrpc_error(id, -32002, "Prompt not found: " .. name)
-- Second occurrence of the same forbidden code. The suite does
-- NOT check it (it only asks resources/read) -- which is why it is
-- listed here: fixing only the tested one would make the counter
-- green and the server non-conformant.
return jsonrpc_error(id, -32602, "Prompt not found: " .. name)
end
local result, err = _get_prompt(entry, params.arguments)
if not result then
@@ -694,15 +850,6 @@ function lmcp:handle_request(req)
end
return jsonrpc_result(id, result)
elseif method == "logging/setLevel" then
local lvl = (req.params or {}).level
if type(lvl) ~= "string" or not LOG_LEVELS[lvl] then
return jsonrpc_error(id, -32602,
"level must be one of: debug, info, notice, warning, error, critical, alert, emergency")
end
self._log_level = lvl
return jsonrpc_result(id, json.empty_object)
elseif method == "completion/complete" then
local params = req.params or {}
local ref = params.ref or {}
@@ -920,7 +1067,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
@@ -980,6 +1127,36 @@ end
local _drive_handler_co
local _finalise_dispatch
-- MCP 2026-07-28 standardizes the HTTP status, not just the body. This one
-- mapping decides it, so each branch does not choose it itself -- and so the
-- following parts (header, _meta, version) get their 400s without anyone
-- having to add to it again.
--
-- -32601 unknown method -> 404 („MUST respond with 404 Not
-- Found and … -32601")
-- -32020 HeaderMismatch -> 400
-- -32021 MissingRequiredClientCapability -> 400
-- -32022 UnsupportedProtocolVersion -> 400
-- -32602 Invalid params -> 400 (missing required _meta fields)
local _error_status = {
[-32601] = "404 Not Found",
[-32020] = "400 Bad Request",
[-32021] = "400 Bad Request",
[-32022] = "400 Bad Request",
[-32602] = "400 Bad Request",
}
local function _status_for_error_code(encoded)
-- `encoded` is the finished JSON response. Decoding once is cheaper
-- and more honest than searching the string for numbers: a string
-- can contain `-32601` inside a message too.
local ok, obj = pcall(json.decode, encoded)
if not ok or type(obj) ~= "table" or type(obj.error) ~= "table" then
return "200 OK"
end
return _error_status[obj.error.code] or "200 OK"
end
local function _dispatch_post(self, conn)
local body = conn.body
if body == "" then
@@ -1011,6 +1188,67 @@ local function _dispatch_post(self, conn)
end
end
-- Dual-protocol routing (Phase 2). The announced version comes from the
-- MCP-Protocol-Version header OR _meta.protocolVersion (E2: either source
-- is authoritative). Both present and differing -> -32020, checked BEFORE
-- routing so a mixed announcement never reaches either protocol path.
-- An empty string counts as "no version" (versions.check rule a; review
-- fix #6) — it is treated as absent, exactly like a missing channel.
local header_v = conn.headers['mcp-protocol-version']
if header_v == "" then header_v = nil end
local meta_v = ((rpc_req.params or {})._meta or {})
["io.modelcontextprotocol/protocolVersion"]
if meta_v == "" then meta_v = nil end
if header_v and meta_v and header_v ~= meta_v then
return _build_http_response(
_error_status[-32020] or "400 Bad Request",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
jsonrpc_error(rpc_req.id, -32020,
"Header MCP-Protocol-Version does not match _meta protocolVersion"),
nil)
end
local announced = header_v or meta_v
rpc_req._announced = announced
-- Route: legacy (nil or 2025-06-18) skips the envelope entirely (B5:
-- a legacy request with no Mcp-Method/Version headers must NOT get
-- -32020). New (2026-07-28) runs the envelope + version gate as before.
local legacy = (announced == nil or announced == "2025-06-18")
rpc_req._legacy = legacy
-- Version gate (keystone): `versions.check` rejects unknown versions
-- with -32022 + 400 and names data.supported (a copy of the list,
-- copy-protection unchanged) + data.requested (the requested version).
-- Allows nil/"" (contract rule a). Runs on the announced version (header
-- OR _meta, B6: header-only bogus version must still fire -32022).
if announced ~= nil and not legacy then
local allowed, err = versions.check(announced)
if not allowed then
err.data.requested = announced
return _build_http_response(
_error_status[err.code] or "400 Bad Request",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
jsonrpc_error(rpc_req.id, err.code,
"Unsupported protocol version", err.data), nil)
end
end
-- Envelope check (2026-07-28): required headers + _meta + version BEFORE
-- method dispatch. Errors carry their JSON-RPC code and their HTTP status
-- from _error_status (-32020/-32602 -> 400). Legacy requests skip it.
if not legacy then
local e_ok, e_code, e_message = envelope.check(conn.headers, rpc_req)
if not e_ok then
return _build_http_response(
_error_status[e_code] or "400 Bad Request",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
jsonrpc_error(rpc_req.id, e_code, e_message), nil)
end
end
-- Session resolution (deferred from header-parse time so we can detect
-- `initialize`). Rules:
-- - `initialize`: always mint a fresh session, ignoring any client sid
@@ -1133,11 +1371,19 @@ _finalise_dispatch = function(self, conn, rok, result, co)
-- 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
-- Errors are NEVER streamed. The spec requires the client to send an
-- Accept header with text/event-stream (streamable-http, "MUST include
-- an Accept header listing both"), so the SSE branch only hit exactly
-- the prescribed request shape -- and _build_sse_headers wires 200.
-- The status mapping was therefore unreachable for the normal case. An
-- error has nothing to stream: no notifications before it, nothing to
-- keep open.
local status = _status_for_error_code(result)
if status == "200 OK" and accept:find("text/event%-stream") then
local hdrs = _build_sse_headers(session_id)
return hdrs .. _sse_event(result)
end
return _build_http_response("200 OK",
return _build_http_response(status,
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
result, session_id)
@@ -1249,52 +1495,18 @@ local function _conn_read(self, conn)
if conn.method == "OPTIONS" then
conn.write_buf = _dispatch_options(conn)
conn.state = "writing"
elseif conn.method == "DELETE" then
-- Resolve session: no auto-issue for DELETE; unknown sid → 404.
if not conn.requested_sid then
conn.write_buf = _build_http_response("400 Bad Request",
{ ["Content-Type"] = "text/plain",
["Access-Control-Allow-Origin"] = "*" },
"Mcp-Session-Id required for DELETE", nil)
conn.state = "writing"
return
end
conn.session_id = conn.requested_sid
conn.write_buf = _dispatch_delete(self, conn)
elseif conn.method == "GET" or conn.method == "DELETE" then
-- MCP 2026-07-28: „HTTP GET or DELETE to the MCP endpoint: respond
-- with 405 Method Not Allowed." Both carried the mechanics of the
-- old version — GET opened a separate SSE stream, DELETE ended
-- a session. Spec quote for both: „None of these mechanisms are
-- part of this revision."
conn.write_buf = _build_http_response("405 Method Not Allowed",
{ ["Content-Type"] = "text/plain",
["Allow"] = "POST, OPTIONS",
["Access-Control-Allow-Origin"] = "*" },
"Method Not Allowed", nil)
conn.state = "writing"
elseif conn.method == "GET" then
-- Resolve session (auto-issue if missing, 404 if unknown).
local sess, serr = _resolve_session(self, conn.requested_sid)
if not sess then
conn.write_buf = _build_http_response("404 Not Found",
{ ["Content-Type"] = "text/plain",
["Access-Control-Allow-Origin"] = "*" },
"Session not found: " .. tostring(conn.requested_sid), nil)
conn.state = "writing"
return
end
conn.session_id = sess.id
-- Persistent SSE stream. Enforce one per session.
if sess.sse_conn and sess.sse_conn ~= conn then
conn.write_buf = _build_http_response("409 Conflict",
{ ["Content-Type"] = "text/plain",
["Access-Control-Allow-Origin"] = "*" },
"Session already has an open SSE stream", nil)
conn.state = "writing"
return
end
sess.sse_conn = conn
local hdrs = _build_sse_headers(conn.session_id)
-- For backwards compat with the old SDK probe shape, emit a one-shot
-- 'endpoint' event so clients can self-discover; modern clients
-- ignore it and just await message events.
local endpoint_data = json.encode({
endpoint = "/mcp", sessionId = conn.session_id,
})
conn.write_buf = hdrs ..
"event: endpoint\r\ndata: " .. endpoint_data .. "\r\n\r\n"
conn.state = "sse_open"
conn.last_heart = os.time()
elseif conn.method == "POST" then
-- _dispatch_post may return nil (issue #20) if the handler
-- coroutine yielded. In that case it set conn.state =
+114 -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
@@ -200,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 ----
@@ -276,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)",
@@ -287,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" } },
@@ -1066,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
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Acceptance test for lmcp's dual protocol: does a shell tool echo "hello world"
over BOTH the 2025-06-18 and the 2026-07-28 surface, on BOTH transports?
WHY THIS SHAPE
--------------
The two protocols are not symmetric, and a test that treats them as such would
pass for the wrong reason:
* 2025-06-18 has `initialize`. 2026-07-28 does NOT -- SEP-2575 removed it, and
on the new path it deliberately falls through to -32601. So the old flow is
initialize -> tools/call and the new flow is tools/call alone.
* The envelope (headers Mcp-Method / Mcp-Name / MCP-Protocol-Version plus
params._meta) is checked in the HTTP dispatcher only. Over stdio there are
no headers at all, so there the version is announced through _meta only.
CONTROLS
--------
A green result means nothing unless the same harness can produce a red one, so:
RED-1 an invented version (1999-01-01) must be REJECTED, not served. If the
server answers "hello world" to that, the version gate is decorative.
RED-2 the same stdio/old-protocol case run against the code from BEFORE the
review fix (4ac9296) must FAIL with -32601. That is the echo guard: it
proves this harness can see the very defect the fix was for. Without
it, a green run only proves the harness is polite.
Usage: lmcp-dual-test.py <lmcp-dir> [http-port]
"""
import json
import os
import subprocess
import sys
import time
import urllib.error
import urllib.request
DIR = sys.argv[1] if len(sys.argv) > 1 else "/opt/lmcp"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8099
ALT, NEU = "2025-06-18", "2026-07-28"
BEFEHL = "echo hello world"
ERWARTET = "hello world"
ergebnisse = []
def merke(name, ok, detail=""):
ergebnisse.append((name, ok, detail))
print(f" [{'ok ' if ok else '!! '}] {name}" + (f"{detail}" if detail else ""))
def env_fuer(transport):
e = dict(os.environ)
e["LMCP_TRANSPORT"] = transport
e["LMCP_TOOLS_DIR"] = "/tmp/leer" # keine Flotten-Werkzeuge laden
e["LMCP_PORT"] = str(PORT)
return e
# --------------------------------------------------------------------------
# stdio: eine Zeile rein, eine Zeile raus. Jede Anfrage bekommt einen frischen
# Prozess -- so kann kein Zustand aus dem vorigen Aufruf ein Ergebnis tragen.
# --------------------------------------------------------------------------
def stdio_ruf(verzeichnis, anfragen):
p = subprocess.run(
["lua5.4", os.path.join(verzeichnis, "server.lua")],
input="\n".join(json.dumps(a) for a in anfragen) + "\n",
capture_output=True, text=True, timeout=60,
env=env_fuer("stdio"), cwd=verzeichnis)
antworten = []
for zeile in (p.stdout or "").splitlines():
zeile = zeile.strip()
if not zeile:
continue
try:
antworten.append(json.loads(zeile))
except json.JSONDecodeError:
pass
return antworten, (p.stderr or "")
def http_ruf(koerper, kopf=None):
daten = json.dumps(koerper).encode()
h = {"Content-Type": "application/json"}
h.update(kopf or {})
req = urllib.request.Request(f"http://127.0.0.1:{PORT}/mcp", daten, h)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read().decode())
except urllib.error.HTTPError as e:
roh = e.read().decode("utf-8", "replace")
try:
return e.code, json.loads(roh)
except json.JSONDecodeError:
return e.code, {"raw": roh[:200]}
def ruf_alt(n):
return {"jsonrpc": "2.0", "id": n, "method": "tools/call",
"params": {"name": "shell", "arguments": {"command": BEFEHL}}}
def ruf_neu(n, fassung=NEU):
return {"jsonrpc": "2.0", "id": n, "method": "tools/call",
"params": {"name": "shell", "arguments": {"command": BEFEHL},
"_meta": {
"io.modelcontextprotocol/protocolVersion": fassung,
"io.modelcontextprotocol/clientCapabilities": {}}}}
def kopf_neu(methode, name=None, fassung=NEU):
h = {"MCP-Protocol-Version": fassung, "Mcp-Method": methode}
if name:
h["Mcp-Name"] = name
return h
def text_aus(antwort):
"""Der Werkzeugtext, egal ob als content[] oder als blanker String."""
r = (antwort or {}).get("result") or {}
c = r.get("content")
if isinstance(c, list):
return " ".join(str(t.get("text", "")) for t in c if isinstance(t, dict))
return json.dumps(r)
def fehler_von(antwort):
e = (antwort or {}).get("error") or {}
return e.get("code"), e.get("message")
# ==========================================================================
print(f"\n=== lmcp aus {DIR} ===")
# --- 1+2: HTTP -------------------------------------------------------------
srv = subprocess.Popen(["lua5.4", os.path.join(DIR, "server.lua")],
env=env_fuer("http"), cwd=DIR,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
time.sleep(3)
try:
if srv.poll() is not None:
merke("HTTP-Server startet", False, (srv.stderr.read() or "")[:200])
else:
merke("HTTP-Server startet", True, f"Port {PORT}")
# 1) ALT ueber HTTP: initialize, dann tools/call. Keine Fassungs-Kopfzeile
# -- "keine Fassung" MUSS als alt gelten (E2).
st, a = http_ruf({"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": ALT, "capabilities": {},
"clientInfo": {"name": "test", "version": "1"}}})
pv = ((a.get("result") or {}).get("protocolVersion"))
merke("1a HTTP/alt: initialize", pv is not None, f"HTTP {st}, protocolVersion={pv}")
st, a = http_ruf(ruf_alt(2))
t = text_aus(a)
merke("1b HTTP/alt: shell echo", ERWARTET in t, f"HTTP {st}, {t.strip()[:60]!r}")
# 2) NEU ueber HTTP: kein initialize (SEP-2575), voller Umschlag.
st, a = http_ruf(ruf_neu(3), kopf_neu("tools/call", "shell"))
t = text_aus(a)
merke("2 HTTP/neu: shell echo", ERWARTET in t, f"HTTP {st}, {t.strip()[:60]!r}")
# RED-1: erfundene Fassung muss abgewiesen werden.
st, a = http_ruf(ruf_neu(4, "1999-01-01"), kopf_neu("tools/call", "shell", "1999-01-01"))
code, msg = fehler_von(a)
abgewiesen = code is not None and ERWARTET not in text_aus(a)
merke("R1 HTTP: erfundene Fassung wird abgewiesen", abgewiesen,
f"HTTP {st}, code={code} {str(msg)[:50]!r}")
finally:
srv.terminate()
try:
srv.wait(timeout=10)
except subprocess.TimeoutExpired:
srv.kill()
# --- 3+4: stdio ------------------------------------------------------------
# 3) ALT: initialize + tools/call in EINEM Prozess, wie ein echter Klient.
antw, err = stdio_ruf(DIR, [
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": ALT, "capabilities": {},
"clientInfo": {"name": "test", "version": "1"}}},
ruf_alt(2)])
nach_id = {a.get("id"): a for a in antw}
c1, m1 = fehler_von(nach_id.get(1))
merke("3a stdio/alt: initialize", c1 is None and nach_id.get(1) is not None,
f"code={c1} {str(m1)[:60]!r}" if c1 else "ohne Fehler")
t = text_aus(nach_id.get(2))
merke("3b stdio/alt: shell echo", ERWARTET in t, f"{t.strip()[:60]!r}")
# 4) NEU ueber stdio: kein Umschlag (nur HTTP prueft ihn), Fassung im _meta.
antw, err = stdio_ruf(DIR, [ruf_neu(1)])
nach_id = {a.get("id"): a for a in antw}
t = text_aus(nach_id.get(1))
merke("4 stdio/neu: shell echo", ERWARTET in t, f"{t.strip()[:60]!r}")
# ==========================================================================
schlecht = [n for n, ok, _ in ergebnisse if not ok]
print(f"\n {len(ergebnisse) - len(schlecht)} von {len(ergebnisse)} bestanden")
if schlecht:
print(" offen: " + ", ".join(schlecht))
sys.exit(1 if schlecht else 0)
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env python3
"""Conformance suite for MCP 2026-07-28, cut from the specification itself.
Sources fetched 2026-08-09 and quoted per check:
/specification/2026-07-28/ (index)
/specification/2026-07-28/basic (messages, _meta, error codes)
/specification/2026-07-28/basic/versioning (version negotiation)
/specification/2026-07-28/server/discover (DiscoverResult shape)
/specification/2026-07-28/basic/transports/streamable-http (headers, statuses)
NOT derived from the changelog or from any second-hand delta: two field names in
the earlier draft were guessed wrong (`protocolVersions` instead of
`supportedVersions`, `serverInfo` at top level instead of inside `_meta`).
The spec normalises BOTH the JSON-RPC error code and the HTTP status, so both
are checked. A check that only looks at the body would pass a server that
answers 200 where the spec demands 400.
Expected result on an unmigrated server: almost everything OFFEN. That is the
measurement the distance to the target not a failure of the suite.
python3 konf2026.py <pfad/zu/lmcp.lua>
"""
import json
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
ZIEL = "2026-07-28"
KLON = (os.path.dirname(os.path.abspath(sys.argv[1])) if len(sys.argv) > 1
else "/home/mfritsche/src/lmcp-work")
_offen = 0
def pruefe(punkt, was, ok, beleg=None, bekommen=None):
global _offen
if not ok:
_offen += 1
print(f" [{'ok ' if ok else 'OFFEN '}] {punkt:<7} {was}")
if not ok and bekommen is not None:
print(f" bekommen: {bekommen}")
if not ok and beleg:
print(f" Spec: {beleg}")
META = {"io.modelcontextprotocol/protocolVersion": ZIEL,
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": {"name": "konf", "version": "1"}}
def freier_port():
s = socket.socket(); s.bind(("127.0.0.1", 0)); p = s.getsockname()[1]; s.close(); return p
def ruf(port, token, methode, params=None, kopf=None, meta=META, art="POST"):
"""Gibt (jsonrpc-objekt, http-status) zurueck. Beides zaehlt."""
p = dict(params or {})
if meta is not None:
p["_meta"] = meta
rumpf = {"jsonrpc": "2.0", "id": 1, "method": methode}
if p:
rumpf["params"] = p
k = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
"Authorization": "Bearer " + token,
"MCP-Protocol-Version": ZIEL,
"Mcp-Method": methode}
if kopf is not None:
for name, wert in kopf.items():
if wert is None:
k.pop(name, None)
else:
k[name] = wert
req = urllib.request.Request(f"http://127.0.0.1:{port}/mcp",
json.dumps(rumpf).encode(), k, method=art)
try:
a = urllib.request.urlopen(req, timeout=20)
roh, status = a.read().decode(), a.status
except urllib.error.HTTPError as e:
roh, status = e.read().decode(), e.code
except Exception as e:
return {"_transport": f"{type(e).__name__}: {e}"}, 0
for z in roh.splitlines():
if z.startswith("data: "):
roh = z[6:]; break
try:
return json.loads(roh), status
except Exception:
return {"_roh": roh[:160]}, status
def code(a):
return (a.get("error") or {}).get("code")
port, token = freier_port(), "konf-2026-07-28"
umg = dict(os.environ, LMCP_PORT=str(port), LMCP_TOKEN=token, LMCP_HOST="127.0.0.1",
LUA_PATH=f"{KLON}/?.lua;;")
srv = subprocess.Popen(["lua5.4", f"{KLON}/server.lua"], cwd=KLON, env=umg,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
try:
for _ in range(60):
time.sleep(0.5)
try:
socket.create_connection(("127.0.0.1", port), 1).close(); break
except OSError:
if srv.poll() is not None:
print(" Server startete nicht:", (srv.stderr.read() or b"").decode()[:300])
sys.exit(2)
else:
print(" Server kam nicht hoch"); sys.exit(2)
print(f" MCP {ZIEL} — Konformitaet von {KLON}\n")
# ---- server/discover ---------------------------------------------------
a, st = ruf(port, token, "server/discover")
r = a.get("result") or {}
pruefe("discover", "server/discover wird beantwortet", "result" in a,
"server/discover: „Servers MUST implement it.\"", json.dumps(a)[:130])
pruefe("discover", "result.supportedVersions ist eine nicht-leere Liste",
isinstance(r.get("supportedVersions"), list) and bool(r.get("supportedVersions")),
"DiscoverResult: supportedVersions", repr(r.get("supportedVersions")))
pruefe("discover", "result.capabilities ist ein Objekt",
isinstance(r.get("capabilities"), dict), None, repr(r.get("capabilities")))
pruefe("discover", "_meta traegt io.modelcontextprotocol/serverInfo",
isinstance((r.get("_meta") or {}).get("io.modelcontextprotocol/serverInfo"), dict),
"„Servers SHOULD include this field.\"", repr((r.get("_meta") or {}))[:90])
pruefe("discover", f"supportedVersions enthaelt {ZIEL}",
ZIEL in (r.get("supportedVersions") or []), None,
repr(r.get("supportedVersions")))
# ---- resultType auf JEDEM Ergebnis -------------------------------------
for m in ("server/discover", "tools/list"):
a, _ = ruf(port, token, m)
rt = (a.get("result") or {}).get("resultType")
pruefe("result", f"{m}: result.resultType == \"complete\"", rt == "complete",
"„The result MUST include a resultType field.\"", repr(rt))
# ---- _meta ist Pflicht --------------------------------------------------
a, st = ruf(port, token, "tools/list", meta=None)
pruefe("_meta", "fehlendes _meta -> -32602", code(a) == -32602,
"„A request missing any required field is malformed; the server MUST "
"reject it with -32602.\"", f"code={code(a)}")
pruefe("_meta", "fehlendes _meta -> HTTP 400", st == 400,
"„On HTTP, the response status MUST be 400 Bad Request.\"", f"HTTP {st}")
ohne_caps = {k: v for k, v in META.items()
if k != "io.modelcontextprotocol/clientCapabilities"}
a, st = ruf(port, token, "tools/list", meta=ohne_caps)
pruefe("_meta", "fehlende clientCapabilities -> -32602", code(a) == -32602,
"clientCapabilities: Required = Yes", f"code={code(a)}")
# ---- Fassungsaushandlung ------------------------------------------------
schlecht = dict(META, **{"io.modelcontextprotocol/protocolVersion": "1900-01-01"})
a, st = ruf(port, token, "tools/list", meta=schlecht,
kopf={"MCP-Protocol-Version": "1900-01-01"})
pruefe("version", "unbekannte Fassung -> -32022", code(a) == -32022,
"„it MUST respond with an UnsupportedProtocolVersionError\"", f"code={code(a)}")
pruefe("version", "unbekannte Fassung -> HTTP 400", st == 400,
"„MUST respond with 400 Bad Request and an UnsupportedProtocolVersionError\"",
f"HTTP {st}")
d = (a.get("error") or {}).get("data") or {}
pruefe("version", "Fehler nennt data.supported", isinstance(d.get("supported"), list),
None, repr(d)[:90])
pruefe("version", "Fehler nennt data.requested == angefragte Fassung",
d.get("requested") == "1900-01-01",
"Beispiel der Spec: data.requested: \"1900-01-01\"", repr(d.get("requested")))
# ---- Koepfe -------------------------------------------------------------
a, st = ruf(port, token, "tools/list", kopf={"Mcp-Method": None})
pruefe("header", "fehlender Mcp-Method -> -32020", code(a) == -32020,
"„A required standard header … is missing.\" -> HeaderMismatch", f"code={code(a)}")
pruefe("header", "fehlender Mcp-Method -> HTTP 400", st == 400,
"„servers MUST return HTTP status 400 Bad Request\"", f"HTTP {st}")
a, st = ruf(port, token, "tools/list", kopf={"Mcp-Method": "prompts/list"})
pruefe("header", "Mcp-Method != method -> -32020", code(a) == -32020,
"„values specified in the headers do not match … MUST reject\"", f"code={code(a)}")
a, st = ruf(port, token, "tools/list", kopf={"MCP-Protocol-Version": None})
pruefe("header", "fehlender MCP-Protocol-Version -> -32020", code(a) == -32020,
"„Every POST request … MUST include an MCP-Protocol-Version header.\"",
f"code={code(a)}")
a, st = ruf(port, token, "tools/list", kopf={"MCP-Protocol-Version": "2025-06-18"})
pruefe("header", "Kopf-Fassung != _meta-Fassung -> -32020", code(a) == -32020,
"„If the values do not match, the server MUST reject … HeaderMismatch\"",
f"code={code(a)}")
# ---- unbekannte Methode -------------------------------------------------
a, st = ruf(port, token, "gibt/esnicht")
pruefe("method", "unbekannte Methode -> -32601", code(a) == -32601, None, f"code={code(a)}")
pruefe("method", "unbekannte Methode -> HTTP 404", st == 404,
"„it MUST respond with 404 Not Found and a JSON-RPC error with code -32601\"",
f"HTTP {st}")
# ---- gestrichene Methoden ----------------------------------------------
for m in ("initialize", "ping", "logging/setLevel"):
a, _ = ruf(port, token, m)
pruefe("entfernt", f"{m} existiert nicht mehr (-32601)", code(a) == -32601,
"SEP-2575: entfernt", f"code={code(a)}")
# ---- verbotene Fehlercodes ---------------------------------------------
a, _ = ruf(port, token, "resources/read", {"uri": "file:///gibtsnicht"},
kopf={"Mcp-Name": "file:///gibtsnicht"})
pruefe("codes", "unbekannte Ressource -> -32602, NICHT -32002", code(a) == -32602,
"„Implementations of this protocol version MUST NOT emit … -32002\"",
f"code={code(a)}")
# ---- GET/DELETE auf dem Endpunkt ---------------------------------------
for art in ("GET", "DELETE"):
try:
req = urllib.request.Request(f"http://127.0.0.1:{port}/mcp", method=art,
headers={"Authorization": "Bearer " + token})
st = urllib.request.urlopen(req, timeout=10).status
except urllib.error.HTTPError as e:
st = e.code
except Exception:
st = 0
pruefe("transport", f"HTTP {art} auf dem Endpunkt -> 405", st == 405,
"„respond with 405 Method Not Allowed\"", f"HTTP {st}")
print()
print(f" {_offen} Punkte OFFEN")
sys.exit(1 if _offen else 0)
finally:
srv.terminate()
try:
srv.wait(5)
except Exception:
srv.kill()
+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)
+53
View File
@@ -0,0 +1,53 @@
local M = {}
-- versions.lua
-- Module for version checking according to LMCP contract
-- Both versions are supported. The old version (2025-06-18) is retained
-- for backward compatibility, while the new version (2026-07-28) is added.
-- Dual protocol (old + new) is a later phase.
M.SUPPORTED = {'2025-06-18', '2026-07-28'}
--- Checks whether a version is supported.
-- @param version The version to check (string or nil)
-- @return boolean true if supported, otherwise false
-- @return table|nil error details if not supported
function M.check(version)
-- (a) version == nil or '' -> true, nil
if version == nil or version == '' then
return true, nil
end
-- (b) exact match in M.SUPPORTED -> true, nil
for _, supported_version in ipairs(M.SUPPORTED) do
if version == supported_version then
return true, nil
end
end
-- (c) everything else -> false, { code = -32022, data = { supported = <copy> } }
--
-- COPY, not M.SUPPORTED itself. Previously the module table leaked out:
-- a caller that keeps the error object and err.data.supported[1]
-- overwrites, changes the module's list for EVERY subsequent
-- call -- demonstrated on 2026-08-09: after
-- e.data.supported[1] = 'CAPTURED' check('2025-06-18') is false and
-- check('CAPTURED') is true. Contract rule 5 ('A call must not shift the list of
-- the next') was thereby broken, and test 5 did not see it
-- because it never writes over the returned reference.
-- As long as no one called the module, this was theoretical. Since it is attached to
-- initialize, it is reachable.
local copy = {}
for i = 1, #M.SUPPORTED do
copy[i] = M.SUPPORTED[i]
end
return false, {
code = -32022,
data = {
supported = copy
}
}
end
-- No new globals, no side effects, M.SUPPORTED is not modified
return M
+22 -6
View File
@@ -3,14 +3,30 @@
This directory contains the WiX manifest and packaging files for the
Windows MSI build of lmcp.
## Workflow
## Recommended: cross-build on Linux (one command)
```sh
# 1. Pull the Lua + LuaSocket runtime into pkg/lua/ (one-time, see below).
# 2. Sync the lmcp .lua sources from the root of the repo:
./sync.sh
# 3. Bump windows/lmcp.wxs `Version="…"` to match the release tag.
# 4. Invoke WiX:
./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
```
+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"