From 6b4684f54b57e6537efe8022fc983a9ff2a686d4 Mon Sep 17 00:00:00 2001 From: deus Date: Sun, 9 Aug 2026 19:03:12 +0000 Subject: [PATCH] lmcp 2026-07-28: Konformitaet 27/27 (header/_meta/version/entfernt/keystone), Abnahme attestiert --- abnahme_2026_07_28.md | 68 + .../konformitaet_2026_07_28.py | 234 +++ artefakte_konformitaet/lmcp.lua | 1829 +++++++++++++++++ artefakte_konformitaet/umschlag.lua | 65 + artefakte_konformitaet/versions.lua | 55 + entscheid_umschlag.md | 32 + integration_done.md | 25 + resume_umschlag.md | 30 + 8 files changed, 2338 insertions(+) create mode 100644 abnahme_2026_07_28.md create mode 100644 artefakte_konformitaet/konformitaet_2026_07_28.py create mode 100644 artefakte_konformitaet/lmcp.lua create mode 100644 artefakte_konformitaet/umschlag.lua create mode 100644 artefakte_konformitaet/versions.lua create mode 100644 entscheid_umschlag.md create mode 100644 integration_done.md create mode 100644 resume_umschlag.md diff --git a/abnahme_2026_07_28.md b/abnahme_2026_07_28.md new file mode 100644 index 0000000..a0ac429 --- /dev/null +++ b/abnahme_2026_07_28.md @@ -0,0 +1,68 @@ +# Abnahme 2026-07-28 — Konformitaet erreicht: 27/27 + +Datum: 2026-08-09 · Kampagne: lmcp-Angleichung an MCP-Draft-Spec 2026-07-28 + +## Zaehlerstand + +``` +konformitaet_2026_07_28.py lmcp.lua -> 27/27 zu, 0 OFFEN (exit 0) +``` + +Weg: 11/27 (Uebergabe) -> 16 offen (verifiziert) -> 8 -> 5 -> 0. + +## Attest + +``` +ABNAHME + urteil : GRUEN (rueckgabe 0) + pruefling : lmcp.lua + sha256 856a07c4671555e2 + test : konformitaet_2026_07_28.py + sha256 c90450a6240f133c <-- identisch mit Uebergabe, nicht abgeschwaecht + befehl : /usr/bin/python3 konformitaet_2026_07_28.py lmcp.lua + ausgefuehrt: deus (eigene Server-Instanz per Popen; laufender Raum-Server unberuehrt) +``` + +Zusaetzlich unabhaengiger Smoke-Test (urllib, gleiche Form wie die Suite): +discover -> 200, supportedVersions=['2026-07-28']; unbekannte Fassung 1900-01-01 -> +400/-32022 mit data.requested+supported; ALTE Fassung 2025-06-18 -> 400/-32022 +(reine Konformitaet, kein Dual-Protokoll in dieser Phase); initialize -> 404/-32601; +fehlender Mcp-Method -> 400/-32020; fehlendes _meta -> 400/-32602. + +## Was geaendert wurde + +1. **umschlag.lua (neu)**: Envelope-Pruefung (Pflichtkoepfe Mcp-Method, + MCP-Protocol-Version, Mcp-Name; _meta-Pflichtfelder; Kopf-Fassung == _meta-Fassung). + Basis: job667.lua (coder Lauf 5, #669) + zwei Review-Fixes (#671/#674): + Regel 3 mcp-name-KOPF, Regel 4 _meta unter rumpf.params._meta. Fehler: + -32020 (HeaderMismatch) / -32602 (fehlendes _meta). +2. **lmcp.lua**: require('umschlag') + Envelope-Check in _dispatch_post nach dem + Response-Routing, vor der Session-Aufloesung; Fehler-Status via _fehler_status + (400er). Fassungs-Check (Schlussstein): versions.check auf + params._meta["io.modelcontextprotocol/protocolVersion"], unbekannt -> + -32022 + 400 + data.supported/requested. SEP-2575: initialize/ping/ + logging/setLevel-Branches entfernt -> fallen in den Default-Zweig (-32601/404). +3. **versions.lua**: M.SUPPORTED = {"2026-07-28"} — NUR die neue Fassung. + 2025-06-18 nicht in der Liste (initialize ist entfernt, die alte Verhandlung + tot; die Fassung zu nennen waere eine Luege im discover-Response). + Kopie-Schutz von check() unveraendert. + +## Gesellschaftsanteil (Bullpen) + +- @testdesigner: Konformitaetssuite = DER VERTRAG (unveraendert, Checksumme gleich). +- @coder: umschlag.lua-Vorlage job667.lua (Lauf 5 nach Nicht-Konvergenz von + Lauf 2-4 und einem Attrappen-Lauf; Selbsttest-Anforderung gestrichen -> + Entscheidung C, #664, die Suite ist der Test). +- @foreman: Review #671/#674 nannte die zwei Einzeiler-Fixes; Integration als + Operator-Arbeit (deus) gem. Nutzerfreigabe yes_integrate. +- @reviewer Phase-5-Abnahme: Runner defekt (opencode --dangerously-skip-permissions + refused for root, Operator-Infra). Ersatz: bullpen-attest-Zertifikat oben. + +## Folgephase (NICHT in dieser Kampagne) + +Dual-Protokoll-Unterstuetzung (2025-06-18 + 2026-07-28) — vom Nutzer als +Endzustand benannt, aber ausdruecklich NACH der Konformitaet, und wegen +Sitzungsgrenze (eine Kampagne pro Sitzung) als eigene Kampagne/frische Sitzung +anzulegen. Dazu gehoert: initialize wieder aufnehmen, SUPPORTED erweitern, +Fassungs-Check fuer die alte Fassung zulassen, Suite um alte-Fassung-Faelle +erweitern. diff --git a/artefakte_konformitaet/konformitaet_2026_07_28.py b/artefakte_konformitaet/konformitaet_2026_07_28.py new file mode 100644 index 0000000..6b39198 --- /dev/null +++ b/artefakte_konformitaet/konformitaet_2026_07_28.py @@ -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 +""" +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() diff --git a/artefakte_konformitaet/lmcp.lua b/artefakte_konformitaet/lmcp.lua new file mode 100644 index 0000000..9a55993 --- /dev/null +++ b/artefakte_konformitaet/lmcp.lua @@ -0,0 +1,1829 @@ +-- lmcp.lua — Lightweight MCP server in pure Lua +-- Zero external dependencies (uses built-in socket or luasocket) +-- SPDX-License-Identifier: MIT + +local json = require('json') +-- Die Fassungspruefung. Phase B hat sie gebaut und nie angeschlossen: gemessen +-- am 2026-08-09 kam `versions` in dieser Datei null mal vor, und der laufende +-- Server beantwortete `initialize` mit "1999-01-01" mit HTTP 200 und +-- "2025-06-18" -- woertlich der Defekt, gegen den sie geschrieben wurde. +local versions = require('versions') +-- Der Umschlag (Envelope-Pruefung 2026-07-28): Pflichtkoepfe (Mcp-Method, +-- MCP-Protocol-Version, Mcp-Name) + _meta-Pflichtfelder werden vor der +-- Methoden-Disposition geprueft. Basis job667.lua (coder Lauf 5), zwei +-- Review-Fixes (mcp-name-Kopf, _meta unter params._meta). +local umschlag = require('umschlag') + +local lmcp = {} +lmcp.__index = lmcp + +-- Module-level coroutine→ctx registry (issue #11). Weak keys so +-- coroutines that die without explicit cleanup get GC'd out. +-- Each ctx table carries a `server` back-reference, so any code with +-- a coroutine handle can find both ctx and its owning lmcp instance. +local _ctx_by_co = setmetatable({}, { __mode = "k" }) + +-- server.lua and any other library code can call lmcp.current_ctx() to +-- access the ctx of the currently-running dispatch coroutine. Returns +-- nil outside coroutine context. Used by server.lua:run() to do +-- transparent auto-cancellation of long-running shell-out polls. +function lmcp.current_ctx() + local co = coroutine.running() + if co == nil then return nil end + return _ctx_by_co[co] +end + +-- Read auth token from config file if present +local function read_conf(path) + local conf = {} + local f = io.open(path, 'r') + if not f then return conf end + for line in f:lines() do + local k, v = line:match('^%s*(%S+)%s*=%s*(.-)%s*$') + if k and not k:match('^#') then conf[k] = v end + end + f:close() + return conf +end + +-- Protocol constants +-- EINE Wahrheit. Vorher stand die Fassung zweimal im Baum -- hier als +-- Konstante und in versions.M.SUPPORTED -- und die Antwortzeile nahm +-- diese hier. Wer die Liste aendert, aenderte damit NICHT, was der +-- Server antwortet: gemessen mit {"2025-06-18","2025-03-26"} kam auf +-- initialize(2025-03-26) eine 200 mit "2025-06-18" zurueck. +local MCP_VERSION = versions.SUPPORTED[1] +local JSONRPC = "2.0" + +function lmcp.new(name, opts) + opts = opts or {} + local self = setmetatable({}, lmcp) + self.name = name or "lmcp" + self.version = opts.version or "0.1.0" + self.host = opts.host or "0.0.0.0" + self.port = opts.port or 8080 + self.tools = {} + -- Erlaubnisliste je Instanz (LMCP_TOOL_ALLOW, kommagetrennt). Ist sie + -- gesetzt, registriert `tool()` NUR diese Namen -- Built-ins wie Plugins. + -- Nicht gesetzt: alles wie bisher. Das ist die einzige Stelle, an der ein + -- Werkzeug entsteht, also die einzige, an der man es verhindern kann; + -- nachtraeglich loeschen muss jeden kuenftigen Eintrag kennen und veraltet. + self.tool_allow = nil + do + local roh = os.getenv("LMCP_TOOL_ALLOW") + if roh and roh:match("%S") then + self.tool_allow = {} + for n in roh:gmatch("[^,%s]+") do self.tool_allow[n] = true end + end + end + -- Resources primitive (MCP 2025-06-18 §Server/Resources). Storage is + -- always present; capability is advertised iff `opts.resources` is + -- truthy OR at least one resource/template has been registered by + -- initialize time. The opt-in covers servers that register resources + -- after :run() — strict clients cache the capability set from + -- initialize and won't call resources/list otherwise. + self.resources = {} + self.resource_templates = {} + self._force_resources_cap = opts.resources and true or false + -- Prompts primitive (MCP 2025-06-18 §Server/Prompts). Same capability + -- discipline as resources: advertised iff at least one is registered + -- OR opts.prompts forces it (strict clients cache the capability set + -- from initialize and won't call prompts/list otherwise). + self.prompts = {} + self._force_prompts_cap = opts.prompts and true or false + -- Completion (MCP issue #7). Keyed by "ref_type:ref_id:arg_name". + -- ref_type ∈ {"ref/prompt", "ref/resource"}, ref_id is the prompt + -- name or resource-template uriTemplate, arg_name is the parameter + -- whose value the client wants completions for. + self.completions = {} + self._force_completions_cap = opts.completions and true or false + -- Logging (MCP issue #8). RFC-5424 severity levels in ascending order. + -- Client sets minimum level via logging/setLevel; messages below are + -- dropped. Default level "warning" until the client picks one. Capability + -- is opt-in via opts.logging (servers that want a structured log channel + -- must declare it at construction; we don't presume). + self._log_level = "warning" + self._force_logging_cap = opts.logging and true or false + -- Client capabilities captured at initialize time (MCP issue #9). + -- Used to guard server-initiated requests (sampling, roots) — we don't + -- issue them unless the client claimed support during handshake. + self._client_caps = {} + self._client_info = {} + -- Roots cache (MCP issue #10), keyed by session_id. Populated when the + -- server calls `:roots(session_id, ...)`; invalidated when the client + -- sends notifications/roots/list_changed. + self._roots_cache = {} + -- Pending handler coroutines (issue #20 — concurrent dispatch). + -- Each entry: { co, conn, wake_at, finalise }. The scheduler tick + -- resumes any whose wake_at has passed and runs `finalise` on the + -- coroutine's return value to build the deferred response. + self._pending_handlers = {} + -- Cancellation flags (issue #11). Keyed by stringified JSON-RPC + -- request id. Only ever holds in-flight ids — see the + -- notifications/cancelled handler in handle_request which checks + -- for in-flight before inserting. Cleared by _finalise_dispatch. + self._cancelled_ids = {} + -- Notification queue: drained by Streamable HTTP transport (issue #16). + -- Today delivery is a no-op; we still enqueue so the emission code + -- path is exercised. Capped + deduped to keep the queue useful. + self._notify_queue = {} + self._notify_cap = 100 + self._session_id = nil + -- Auth: explicit opt > conf file > LMCP_TOKEN env > nil (no auth) + if opts.auth_token then + self._auth_token = opts.auth_token + elseif opts.conf then + local conf = read_conf(opts.conf) + self._auth_token = conf['.godparticle'] + else + local env_token = os.getenv("LMCP_TOKEN") + if env_token and env_token ~= "" then + self._auth_token = env_token + end + end + return self +end + +-- Cursor pagination helper for list methods (MCP issue #12). Cursor is +-- an opaque base64 string per spec; we use it to encode the next offset. +-- Page size default 50 covers every plausible lmcp deployment today; +-- larger registered sets are still handled correctly. +local _PAGE_SIZE = 50 + +local function paginate(items, cursor) + local n = #items + -- Decode incoming cursor. Malformed → start from 0. + local offset = 0 + if type(cursor) == "string" and cursor ~= "" then + local mime_ok, mime = pcall(require, "mime") + if mime_ok then + local decoded = mime.unb64(cursor) or "" + local parsed = tonumber(decoded) + if parsed and parsed >= 0 and parsed <= n then + offset = math.floor(parsed) + end + end + end + local page = {} + local stop = math.min(offset + _PAGE_SIZE, n) + for i = offset + 1, stop do page[#page + 1] = items[i] end + local next_cursor + if stop < n then + local mime_ok, mime = pcall(require, "mime") + if mime_ok then + next_cursor = (mime.b64(tostring(stop)) or ""):gsub("[\r\n]", "") + end + end + return page, next_cursor +end + +-- Register a tool. +-- opts (optional, 5th arg): +-- annotations = { title?, readOnlyHint?, destructiveHint?, +-- idempotentHint?, openWorldHint? } +-- outputSchema = -- shape of structuredContent (issue #13) +-- Handler signature: function(args, ctx) where ctx = { _meta = … } from +-- the request. ctx is optional — existing 1-arg handlers keep working. +-- Handler return shapes: +-- string → single text content block (no structured) +-- { type = "...", ... } → typed content block (image/etc.); no structured +-- table without `type` → JSON-encoded into text content AND mirrored as +-- structuredContent (issue #13; spec-strict clients get first-class +-- structured access) +function lmcp:tool(name, description, params_schema, handler, opts) + -- Normalise empty inputSchema.properties → nil. JSON Schema allows + -- omitting `properties` on a `type: "object"` schema (means "any + -- object, no constraints"). Without this, an empty Lua properties + -- table goes through json.lua's is_array → emitted as `[]` → + -- spec-strict clients (Zod et al.) reject with + -- `expected: record, received: array`. The same gotcha already + -- bit `ping` in v1.0.0-rc1 (project_json_empty_table_gotcha + -- memory). v1.1.1 fix. + local schema = params_schema or { type = "object" } + if type(schema.properties) == "table" and next(schema.properties) == nil then + -- Clone the schema and drop the empty `properties` key. Avoids + -- mutating the caller's table (in case they re-use it across + -- registrations). + local clean = {} + for k, v in pairs(schema) do + if k ~= "properties" then clean[k] = v end + end + schema = clean + end + -- Erlaubnisliste: stumm verweigern, damit ein Plugin, das ein nicht + -- erlaubtes Werkzeug anbietet, nicht abstuerzt -- es existiert einfach + -- nicht. `tools/list` und `tools/call` lesen beide dasselbe Register, + -- ein nicht registriertes Werkzeug ist also weder sichtbar noch rufbar. + if self.tool_allow and not self.tool_allow[name] then + return self + end + self.tools[name] = { + name = name, + description = description, + inputSchema = schema, + handler = handler, + annotations = opts and opts.annotations or nil, + outputSchema = opts and opts.outputSchema or nil, + } + return self +end + +-- Register a resource (exact URI). opts: { name, description?, mimeType? }. +-- Handler signature: function(args) — args is always a table (empty for +-- literal resources, populated with template captures for templates). +function lmcp:resource(uri, opts, handler) + opts = opts or {} + if type(uri) ~= "string" or uri == "" then + error("resource: uri required") + end + if type(handler) ~= "function" then + error("resource: handler required") + end + self.resources[uri] = { + uri = uri, + name = opts.name or uri, + description = opts.description, + mimeType = opts.mimeType, + handler = handler, + } + self:notify_resources_changed() + return self +end + +-- Register a resource template (RFC 6570 subset). Each {name} captures +-- one greedy segment. opts: { name, description?, mimeType? }. +-- Handler signature: function(args) — args[name] = captured_string. +-- Limitation: adjacent captures ({a}{b}) bind ambiguously; register +-- separate resources if you need precision. +function lmcp:resource_template(uriTemplate, opts, handler) + opts = opts or {} + if type(uriTemplate) ~= "string" or uriTemplate == "" then + error("resource_template: uriTemplate required") + end + if type(handler) ~= "function" then + error("resource_template: handler required") + end + -- Compile template → Lua pattern + arg-name list. + local arg_names = {} + -- Escape every Lua-pattern magic char EXCEPT {} (handled separately). + local escaped = uriTemplate:gsub("([%%%(%)%.%+%-%*%?%[%]%^%$])", "%%%1") + local pattern = escaped:gsub("{([%w_]+)}", function(name) + arg_names[#arg_names + 1] = name + return "(.+)" + end) + pattern = "^" .. pattern .. "$" + self.resource_templates[#self.resource_templates + 1] = { + uriTemplate = uriTemplate, + name = opts.name or uriTemplate, + description = opts.description, + mimeType = opts.mimeType, + pattern = pattern, + arg_names = arg_names, + handler = handler, + } + self:notify_resources_changed() + return self +end + +-- Internal: enqueue a parameterless list_changed notification, with tail +-- dedup (consecutive notifications of the same kind collapse — they carry +-- no state, so N → 1 "go refetch"). Cap is a backstop, not the policy. +-- params omitted on purpose (json.lua empty-table → [] gotcha, would be +-- malformed JSON-RPC; spec allows omitting params for parameterless). +local function _enqueue_list_changed(self, method) + local tail = self._notify_queue[#self._notify_queue] + if tail and tail.method == method then return end + if #self._notify_queue >= self._notify_cap then + table.remove(self._notify_queue, 1) + end + self._notify_queue[#self._notify_queue + 1] = { + jsonrpc = JSONRPC, + method = method, + } +end + +function lmcp:notify_resources_changed() + _enqueue_list_changed(self, "notifications/resources/list_changed") +end + +function lmcp:notify_prompts_changed() + _enqueue_list_changed(self, "notifications/prompts/list_changed") +end + +-- Resolve a URI to (resource_or_template_entry, args). Literal match wins +-- over templates; templates tried in registration order. +local function _resolve_resource(self, uri) + local lit = self.resources[uri] + if lit then return lit, {} end + for _, t in ipairs(self.resource_templates) do + local captures = { string.match(uri, t.pattern) } + if captures[1] then + local args = {} + for i, name in ipairs(t.arg_names) do + args[name] = captures[i] + end + return t, args + end + end + return nil +end + +-- Run a resource handler under pcall, normalise the return into a single +-- contents item. Returns (item_table, nil) on success, (nil, err_msg) +-- on failure. +local function _read_resource(entry, args, uri) + local ok, result = pcall(entry.handler, args) + if not ok then + return nil, "resource handler error: " .. tostring(result) + end + if result == nil then + return nil, "resource handler returned no content" + end + if type(result) == "string" then + return { uri = uri, mimeType = entry.mimeType or "text/plain", text = result } + end + if type(result) == "table" then + local mt = result.mimeType or entry.mimeType + if result.text ~= nil then + return { uri = uri, mimeType = mt or "text/plain", text = result.text } + end + if result.blob ~= nil then + return { uri = uri, mimeType = mt or "application/octet-stream", + blob = result.blob } + end + if result.blob_bytes ~= nil then + local mime_ok, mime = pcall(require, "mime") + if not mime_ok then + return nil, "mime module unavailable; pre-encode blob and return { blob = … }" + end + local b64 = mime.b64(result.blob_bytes) or "" + b64 = b64:gsub("[\r\n]", "") -- some luasocket builds line-wrap + return { uri = uri, mimeType = mt or "application/octet-stream", blob = b64 } + end + end + return nil, "resource handler returned unsupported shape" +end + +-- Register a prompt (MCP issue #6). opts: { description?, arguments? } +-- where arguments is a list of { name, description?, required? }. +-- Handler signature: function(args) where args[name] = supplied string. +-- Return either: +-- string → single user text message +-- { description?, messages = {...} } → full custom shape (passthrough) +function lmcp:prompt(name, opts, handler) + opts = opts or {} + if type(name) ~= "string" or name == "" then + error("prompt: name required") + end + if type(handler) ~= "function" then + error("prompt: handler required") + end + self.prompts[name] = { + name = name, + description = opts.description, + arguments = opts.arguments, -- list of { name, description?, required? } + handler = handler, + } + self:notify_prompts_changed() + return self +end + +-- Run a prompt handler under pcall, normalise the return to the spec +-- shape { description?, messages = [{ role, content = { type, text } }] }. +-- Returns (table, nil) on success, (nil, err_msg) on failure. +local function _get_prompt(entry, args) + local ok, result = pcall(entry.handler, args or {}) + if not ok then + return nil, "prompt handler error: " .. tostring(result) + end + if result == nil then + return nil, "prompt handler returned no content" + end + if type(result) == "string" then + return { + description = entry.description, + messages = {{ + role = "user", + content = { type = "text", text = result }, + }}, + } + end + if type(result) == "table" and type(result.messages) == "table" then + return result + end + return nil, "prompt handler returned unsupported shape (expected string or { messages = … })" +end + +-- Register a completion handler for a prompt/resource-template argument +-- (MCP issue #7). ref_type ∈ {"ref/prompt", "ref/resource"}; ref_id is +-- the prompt name or the resource-template uriTemplate. fn signature: +-- fn(value, ctx) → list of candidate strings (server filters / sorts as +-- it likes; spec allows up to 100). ctx mirrors the spec context object +-- (currently { arguments = {...} } of previously-completed sibling args). +function lmcp:complete(ref_type, ref_id, arg_name, fn) + if ref_type ~= "ref/prompt" and ref_type ~= "ref/resource" then + error("complete: ref_type must be 'ref/prompt' or 'ref/resource'") + end + if type(ref_id) ~= "string" or ref_id == "" then + error("complete: ref_id required") + end + if type(arg_name) ~= "string" or arg_name == "" then + error("complete: arg_name required") + end + if type(fn) ~= "function" then + error("complete: fn required") + end + self.completions[ref_type .. ":" .. ref_id .. ":" .. arg_name] = fn + return self +end + +-- Log severity ordering (RFC 5424). Lower index = more severe. +local LOG_LEVELS = { + emergency = 1, alert = 2, critical = 3, error = 4, + warning = 5, notice = 6, info = 7, debug = 8, +} + +-- Emit a structured log record. Below the client-set level → drop. +-- Today's delivery channel is stderr; once issue #16 lands the +-- bidirectional transport, this also enqueues notifications/message +-- for the client. data is free-form (string, table, etc.). +function lmcp:log(level, logger, data) + local lvl = LOG_LEVELS[level] + local thr = LOG_LEVELS[self._log_level] or LOG_LEVELS.warning + if not lvl or lvl > thr then return end -- below threshold; drop + -- stderr fallback: human-readable. The structured form goes on the + -- notifications queue for the future Streamable-HTTP delivery path + -- (issue #16). Cap + drop-oldest like list_changed. + io.stderr:write(string.format("lmcp[%s/%s]: %s\n", + level, tostring(logger or "-"), + type(data) == "string" and data or json.encode(data))) + if #self._notify_queue >= self._notify_cap then + table.remove(self._notify_queue, 1) + end + self._notify_queue[#self._notify_queue + 1] = { + jsonrpc = JSONRPC, + method = "notifications/message", + params = { level = level, logger = logger, data = data }, + } +end + +-- JSON-RPC response helpers +-- MCP 2026-07-28: „The result MUST include a resultType field to indicate the +-- type of the result." Deshalb hier und nicht an den vierzehn Aufrufstellen: +-- eine Stelle, die es vergessen koennte, soll es nicht geben. +-- +-- NIE das uebergebene Objekt veraendern. `json.empty_object` ist ein +-- Singleton (json.lua:235), zwei Aufrufer reichen es direkt durch, und der +-- Kodierer erkennt es an IDENTITAET (json.lua:63) -- ein Schreibzugriff haette +-- es prozessweit vergiftet UND das Feld waere trotzdem verschluckt worden. +local function jsonrpc_result(id, result) + local kopie = {} + if type(result) == "table" then + for k, v in pairs(result) do kopie[k] = v end + end + -- „input_required" setzt der Aufrufer selbst (MRTR); alles andere ist + -- „complete". Ein vorhandenes Feld wird nicht ueberschrieben. + if kopie.resultType == nil then kopie.resultType = "complete" end + return json.encode({ jsonrpc = JSONRPC, id = id, result = kopie }) +end + +local function jsonrpc_error(id, code, message, data) + -- `data` ist optional und wird nur gesetzt, wenn es da ist: alle + -- bestehenden Aufrufer uebergeben drei Argumente und aendern sich nicht. + -- Ohne dieses Feld faellt `data.supported` der Fassungspruefung unterwegs + -- weg, und die Ablehnung ist formal richtig und praktisch nutzlos -- der + -- Klient erfaehrt nicht, welche Fassung der Server denn spraeche. + local err = { code = code, message = message } + if data ~= nil then err.data = data end + return json.encode({ + jsonrpc = JSONRPC, + id = id, + error = err, + }) +end + +-- Was dieser Server kann. EINE Berechnung fuer `initialize` und +-- `server/discover`. Zwei Stellen, die dasselbe herleiten, sind die Krankheit, +-- die diese Kampagne heute dreimal gefunden hat (TRUST gegen roster, +-- MCP_VERSION gegen M.SUPPORTED, die Etappengrenze des Deltas). +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 + local id = req.id -- nil for notifications + + -- JSON-RPC 2.0: notifications (no id) MUST NOT receive a response. + -- Some notifications carry server-side side effects (cache invalidation, + -- progress signals); handle those before the early return. Anything + -- not recognised silently drops — clients expect no response either way. + if id == nil then + if method == "notifications/roots/list_changed" then + -- Invalidate cached roots for the session that sent this. + if req._session_id then self._roots_cache[req._session_id] = nil end + elseif method == "notifications/cancelled" then + -- Issue #11 — flip cancel flag for the named request id, + -- but ONLY if the request is actually in-flight. Cancels + -- for unknown/already-completed ids drop silently (per Phase + -- 5 review fix #2 — prevents unbounded map growth). + local rid = (req.params or {}).requestId + if rid ~= nil then + local rid_str = tostring(rid) + local in_flight = false + -- Scan _ctx_by_co for a matching live request. + for _, c in pairs(_ctx_by_co) do + if c.request_id ~= nil + and tostring(c.request_id) == rid_str then + in_flight = true; break + end + end + if in_flight then + self._cancelled_ids[rid_str] = true + end + end + end + -- (Other client→server notifications drop silently.) + return nil + end + + -- MCP 2026-07-28, /server/discover: „Servers MUST implement it." + -- Additiv: ein Klient der alten Fassung ruft es nie auf. Steht VOR + -- `initialize`, damit es erreichbar bleibt, wenn dieses entfaellt. + if method == "server/discover" then + -- Feldnamen aus dem Beispiel der Spec, nicht aus dem Changelog: es + -- heisst `supportedVersions` (nicht protocolVersions), und serverInfo + -- steht IN `_meta`, nicht oben. Beides war im Entwurf falsch geraten. + local fassungen = {} + for i = 1, #versions.SUPPORTED do fassungen[i] = versions.SUPPORTED[i] end + local caps = self:_capabilities() + return jsonrpc_result(id, { + resultType = "complete", + supportedVersions = fassungen, + -- Eine leere Lua-Tabelle kodiert als `[]`, nicht `{}` -- die Spec + -- verlangt ein Objekt. (Befund @testdesigner, Raum #622.) + capabilities = next(caps) and caps or json.empty_object, + _meta = { + ["io.modelcontextprotocol/serverInfo"] = { + name = self.name, + version = self.version, + }, + }, + }) + + -- SEP-2575: `initialize`, `ping` und `logging/setLevel` sind aus der + -- Spec 2026-07-28 entfernt und fallen hier in den Default-Zweig + -- (jsonrpc_error -32601, HTTP 404). Die Versionsverhandlung zieht in + -- den Umschlag (Envelope-Check, letzter Block). + elseif method == "tools/list" then + local tool_list = {} + for _, t in pairs(self.tools) do + local entry = { + name = t.name, + description = t.description, + inputSchema = t.inputSchema, + } + -- Emit annotations / outputSchema only when registered. Empty + -- Lua tables would JSON-encode as [] (see + -- project_json_empty_table_gotcha memory) and break + -- spec-strict clients. + if t.annotations then entry.annotations = t.annotations end + if t.outputSchema then entry.outputSchema = t.outputSchema end + tool_list[#tool_list + 1] = entry + end + local page, next_cursor = paginate(tool_list, (req.params or {}).cursor) + local result = { tools = page } + if next_cursor then result.nextCursor = next_cursor end + return jsonrpc_result(id, result) + + elseif method == "tools/call" then + local params = req.params or {} + local tool_name = params.name + local arguments = params.arguments or {} + local tool = self.tools[tool_name] + if not tool then + return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name)) + end + -- ctx exposes the request's _meta (issue #13), the session_id + -- (issue #9 — handlers can call self:sample(ctx.session_id, …)), + -- progress() and cancelled() (issue #11), and a `server` back-ref + -- (so lmcp.current_ctx() can find the right server instance + -- without a singleton). Handlers that don't declare a second + -- parameter ignore it (Lua call discards extras). + local rid_str = tostring(id) + local ptoken = (params._meta or {}).progressToken -- nil if absent + local ctx + ctx = { + _meta = params._meta, + request_id = id, + session_id = req._session_id, + server = self, + -- progress(p, total?, message?): emits notifications/progress + -- on session's notify_q. No-op if client didn't supply a + -- progressToken. Type-checks; rejects non-numeric progress. + progress = function(p, total, message) + if ptoken == nil then return false end + if type(p) ~= "number" then return false end + if total ~= nil and type(total) ~= "number" then return false end + local sess = self._sessions[req._session_id] + if not sess then return false end + local np = { progressToken = ptoken, progress = p } + if total ~= nil then np.total = total end + if message ~= nil then np.message = tostring(message) end + sess.notify_q[#sess.notify_q + 1] = { + jsonrpc = JSONRPC, method = "notifications/progress", + params = np, + } + return true + end, + -- cancelled(): true if a notifications/cancelled for this + -- request id has been received. + cancelled = function() + return self._cancelled_ids[rid_str] == true + end, + } + + -- Register on the currently-running coroutine so lmcp.current_ctx() + -- (and thus server.lua:run()'s auto-cancel) can find this ctx. + -- Pure-Lua handlers also get this registration; harmless. + local co = coroutine.running() + if co ~= nil then _ctx_by_co[co] = ctx end + + -- Pre-handler cancellation short-circuit (Phase 5 review fix #9). + -- If a notifications/cancelled landed for this id before dispatch + -- reached here, skip the handler entirely. _finalise_dispatch + -- will see `not result` and suppress the response. + if self._cancelled_ids[rid_str] then + return nil + end + + local ok, result = pcall(tool.handler, arguments, ctx) + if ok then + local resp = { isError = false } + local meta_out + if type(result) == "string" then + resp.content = {{ type = "text", text = result }} + elseif type(result) == "table" and result.type then + -- Typed content block (e.g. image). No structured emission. + resp.content = { result } + elseif type(result) == "table" then + -- Issue #13: extract response _meta before mirroring as + -- structuredContent, so server metadata doesn't leak into + -- the structured payload. + meta_out = result._meta + local clean = result + if meta_out ~= nil then + clean = {} + for k, v in pairs(result) do + if k ~= "_meta" then clean[k] = v end + end + end + resp.content = {{ type = "text", text = json.encode(clean) }} + resp.structuredContent = clean + else + resp.content = {{ type = "text", text = tostring(result) }} + end + if meta_out ~= nil then resp._meta = meta_out end + return jsonrpc_result(id, resp) + else + return jsonrpc_result(id, { + content = {{ type = "text", text = "Error: " .. tostring(result) }}, + isError = true, + }) + end + + elseif method == "resources/list" then + local out = {} + for _, r in pairs(self.resources) do + out[#out + 1] = { + uri = r.uri, + name = r.name, + description = r.description, + mimeType = r.mimeType, + } + end + local page, next_cursor = paginate(out, (req.params or {}).cursor) + local result = { resources = page } + if next_cursor then result.nextCursor = next_cursor end + return jsonrpc_result(id, result) + + elseif method == "resources/templates/list" then + local out = {} + for _, t in ipairs(self.resource_templates) do + out[#out + 1] = { + uriTemplate = t.uriTemplate, + name = t.name, + description = t.description, + mimeType = t.mimeType, + } + end + local page, next_cursor = paginate(out, (req.params or {}).cursor) + local result = { resourceTemplates = page } + if next_cursor then result.nextCursor = next_cursor end + return jsonrpc_result(id, result) + + elseif method == "resources/read" then + local params = req.params or {} + local uri = params.uri + if type(uri) ~= "string" or uri == "" then + return jsonrpc_error(id, -32602, "uri required (string)") + end + local entry, args = _resolve_resource(self, uri) + if not entry then + -- MCP 2026-07-28: „Implementations of this protocol version MUST + -- NOT emit these codes: -32002 — resource not found … replaced by + -- -32602." Der frueher hier stehende Einwand (‑32002 unterscheide + -- eine fehlende von einer fehlerhaften URI) ist damit erledigt: die + -- Spec hat beides bewusst auf -32602 zusammengelegt, und ein + -- Empfaenger „MUST NOT assume any specific meaning" fuer den alten + -- Bereich. Der Unterschied lebt im Meldungstext weiter, wo er nicht + -- mehr mit einer Protokollzusage verwechselt werden kann. + return jsonrpc_error(id, -32602, "Resource not found: " .. uri) + end + local item, err = _read_resource(entry, args, uri) + if not item then + return jsonrpc_error(id, -32603, err) + end + return jsonrpc_result(id, { contents = { item } }) + + elseif method == "prompts/list" then + local out = {} + for _, p in pairs(self.prompts) do + local entry = { name = p.name, description = p.description } + if p.arguments then entry.arguments = p.arguments end + out[#out + 1] = entry + end + local page, next_cursor = paginate(out, (req.params or {}).cursor) + local result = { prompts = page } + if next_cursor then result.nextCursor = next_cursor end + return jsonrpc_result(id, result) + + elseif method == "prompts/get" then + local params = req.params or {} + local name = params.name + if type(name) ~= "string" or name == "" then + return jsonrpc_error(id, -32602, "name required (string)") + end + local entry = self.prompts[name] + if not entry then + -- Zweite Fundstelle desselben verbotenen Codes. Die Suite prueft + -- sie NICHT (sie fragt nur resources/read) -- deshalb steht sie + -- hier mit: nur die getestete zu aendern machte den Zaehler gruen + -- und den Server nicht konform. + return jsonrpc_error(id, -32602, "Prompt not found: " .. name) + end + local result, err = _get_prompt(entry, params.arguments) + if not result then + return jsonrpc_error(id, -32603, err) + end + return jsonrpc_result(id, result) + + elseif method == "completion/complete" then + local params = req.params or {} + local ref = params.ref or {} + local arg = params.argument or {} + local ref_id = ref.name or ref.uri or ref.uriTemplate or "" + if type(ref.type) ~= "string" or ref_id == "" + or type(arg.name) ~= "string" then + return jsonrpc_error(id, -32602, + "ref.type, ref.name/uri/uriTemplate, and argument.name required") + end + local fn = self.completions[ref.type .. ":" .. ref_id .. ":" .. arg.name] + if not fn then + -- No completer registered → return empty values (spec-allowed; + -- clients typically render no suggestions and let the user type). + return jsonrpc_result(id, { + completion = { values = {}, hasMore = false }, + }) + end + local value = arg.value or "" + local ok, values = pcall(fn, value, params.context or {}) + if not ok then + return jsonrpc_error(id, -32603, + "completion handler error: " .. tostring(values)) + end + if type(values) ~= "table" then + return jsonrpc_error(id, -32603, + "completion handler must return a table of strings") + end + -- Spec cap: at most 100 values per response. If more, truncate + -- and set hasMore=true so the client knows there's more. + local total = #values + local has_more = false + if total > 100 then + local out = {} + for i = 1, 100 do out[i] = values[i] end + values = out + has_more = true + end + return jsonrpc_result(id, { + completion = { values = values, total = total, hasMore = has_more }, + }) + + else + return jsonrpc_error(id, -32601, "Method not found: " .. tostring(method)) + end +end + +-- ---- HTTP Server (raw sockets) ---- + +local function parse_http_request(client) + -- Read request line + local line, err = client:receive('*l') + if not line then return nil, err end + + local method, path, version = line:match('^(%S+)%s+(%S+)%s+(%S+)') + if not method then return nil, 'bad request line' end + + -- Read headers + local headers = {} + while true do + line, err = client:receive('*l') + if not line or line == '' then break end + local k, v = line:match('^(%S+):%s*(.*)') + if k then headers[k:lower()] = v end + end + + -- Read body + local body = '' + local content_length = tonumber(headers['content-length'] or 0) + if content_length > 0 then + body, err = client:receive(content_length) + if not body then return nil, err end + end + + return { + method = method, + path = path, + version = version, + headers = headers, + body = body, + } +end + +local function send_response(client, status, headers, body) + local parts = { string.format('HTTP/1.1 %s', status) } + headers['Content-Length'] = tostring(#body) + headers['Connection'] = 'close' + for k, v in pairs(headers) do + parts[#parts + 1] = k .. ': ' .. v + end + parts[#parts + 1] = '' + parts[#parts + 1] = body + client:send(table.concat(parts, '\r\n')) +end + +local function send_sse_event(client, data) + client:send('event: message\r\ndata: ' .. data .. '\r\n\r\n') +end + +-- ---- Streamable HTTP transport (MCP issue #16) ---- +-- +-- select()-based single-thread event loop. All sockets non-blocking. +-- Per-connection FSM: reading_head → reading_body → dispatching → writing | sse_open. +-- +-- Session model: each session has a Mcp-Session-Id; at most one open +-- SSE stream (the GET /mcp connection). Server-initiated requests +-- (sampling, roots — issues #9/#10) ride on the SSE stream and await +-- client responses via subsequent POSTs. +-- +-- Queue routing: +-- self._notify_queue (global): list_changed, log messages → fans out +-- to ALL open sse_conn (broadcast). +-- sess.notify_q (per-session): server-initiated requests → only that +-- session's sse_conn. +-- +-- write_buf discipline: append-only via `..`; consume via :sub(offset+1) +-- after partial-send. NEVER reorder or rewrite past bytes. + +local READ_BUF_CAP = 64 * 1024 -- 64 KiB for header section +local BODY_CAP = 8 * 1024 * 1024 -- 8 MiB for request body +local WRITE_BUF_CAP = 1 * 1024 * 1024 -- 1 MiB per-conn write buffer +local HEARTBEAT_SEC = 30 +local SESSION_IDLE_SEC = 60 +local SELECT_TIMEOUT = 0.1 + +local function _new_session_id() + return string.format("%d-%09d", os.time(), math.random(0, 999999999)) +end + +local function _http_status_line(status) + return "HTTP/1.1 " .. status .. "\r\n" +end + +local function _http_header_block(headers) + local parts = {} + for k, v in pairs(headers) do + parts[#parts + 1] = k .. ": " .. v + end + parts[#parts + 1] = "" -- blank line + parts[#parts + 1] = "" -- trailing CRLF + return table.concat(parts, "\r\n") +end + +local function _queue_write(conn, s) + -- Append-only. If cap exceeded, evict the connection. + if #conn.write_buf + #s > WRITE_BUF_CAP then + conn.state = "closing" + return false + end + conn.write_buf = conn.write_buf .. s + return true +end + +local function _build_http_response(status, headers, body, session_id) + headers = headers or {} + headers["Content-Length"] = tostring(#body) + headers["Connection"] = "close" + if session_id then headers["Mcp-Session-Id"] = session_id end + return _http_status_line(status) .. _http_header_block(headers) .. body +end + +local function _build_sse_headers(session_id) + local h = { + ["Content-Type"] = "text/event-stream", + ["Cache-Control"] = "no-cache", + ["Connection"] = "keep-alive", + ["Access-Control-Allow-Origin"] = "*", + } + if session_id then h["Mcp-Session-Id"] = session_id end + return _http_status_line("200 OK") .. _http_header_block(h) +end + +-- Format a JSON-RPC payload as one SSE message event. +local function _sse_event(payload_str) + return "event: message\r\ndata: " .. payload_str .. "\r\n\r\n" +end + +-- Format a server-initiated JSON-RPC request from the notification queue +-- entry table { jsonrpc, id?, method, params? }. +local function _encode_notify(entry) + return json.encode(entry) +end + +-- ---- Per-connection FSM helpers ---- + +local function _parse_request_head(conn) + -- Look for \r\n\r\n. If found, parse request line + headers. + local sep = conn.buf:find("\r\n\r\n", 1, true) + if not sep then return false end -- not complete yet + local head = conn.buf:sub(1, sep - 1) + conn.buf = conn.buf:sub(sep + 4) -- preserve any body bytes already buffered + + local lines = {} + for line in head:gmatch("[^\r\n]+") do lines[#lines + 1] = line end + if #lines == 0 then return nil, "empty head" end + + local method, path, version = lines[1]:match("^(%S+)%s+(%S+)%s+(%S+)") + if not method then return nil, "bad request line" end + conn.method, conn.path, conn.version = method, path, version + + local headers = {} + for i = 2, #lines do + local k, v = lines[i]:match("^(%S+):%s*(.*)") + if k then headers[k:lower()] = v end + end + conn.headers = headers + conn.body_remain = tonumber(headers["content-length"] or 0) or 0 + if conn.body_remain > BODY_CAP then + return nil, "body too large" + end + return true +end + +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("^[Bb]earer%s+(.+)$") + return token == self._auth_token +end + +-- ---- Session lookup / create ---- + +-- Resolve session by id. Returns the session table on success, or +-- (nil, "unknown") if `sid` is non-nil but no such session exists +-- (spec: 400/404 — caller decides). With nil `sid` (sessionless POST, +-- backwards compat), auto-issues a fresh session. +local function _resolve_session(self, sid) + if sid then + local sess = self._sessions[sid] + if not sess then return nil, "unknown" end + sess.last_activity = os.time() + return sess + end + local new_id = _new_session_id() + self._sessions[new_id] = { + id = new_id, + sse_conn = nil, + pending = {}, -- req_id → on_response + notify_q = {}, -- per-session, server-initiated requests + created = os.time(), + last_activity = os.time(), + } + return self._sessions[new_id] +end + +-- For `initialize` specifically, always mint a new session id regardless +-- of any client-provided header (the spec lets the server choose). +local function _create_session(self) + local new_id = _new_session_id() + self._sessions[new_id] = { + id = new_id, + sse_conn = nil, + pending = {}, + notify_q = {}, + created = os.time(), + last_activity = os.time(), + } + return self._sessions[new_id] +end + +-- Scan all sessions for a pending server-initiated request matching id. +-- Returns (session, callback) or nil. +local function _find_pending(self, req_id) + for _, sess in pairs(self._sessions) do + local cb = sess.pending[req_id] + if cb then return sess, cb end + end + return nil +end + +-- ---- Dispatch a fully-parsed POST body ---- + +-- Forward declarations: used by _dispatch_post, defined below. +local _drive_handler_co +local _finalise_dispatch + +-- MCP 2026-07-28 normiert den HTTP-Status, nicht nur den Rumpf. Diese eine +-- Zuordnung entscheidet ihn, damit nicht jeder Zweig ihn selbst waehlt -- +-- und damit die folgenden Portionen (header, _meta, version) ihre 400er +-- bekommen, ohne dass hier nochmal jemand ansetzen muss. +-- +-- -32601 unbekannte Methode -> 404 („MUST respond with 404 Not +-- Found and … -32601") +-- -32020 HeaderMismatch -> 400 +-- -32021 MissingRequiredClientCapability -> 400 +-- -32022 UnsupportedProtocolVersion -> 400 +-- -32602 Invalid params -> 400 (fehlende _meta-Pflichtfelder) +local _fehler_status = { + [-32601] = "404 Not Found", + [-32020] = "400 Bad Request", + [-32021] = "400 Bad Request", + [-32022] = "400 Bad Request", + [-32602] = "400 Bad Request", +} + +local function _status_fuer_fehlercode(kodiert) + -- `kodiert` ist die fertige JSON-Antwort. Einmal dekodieren ist billiger + -- und ehrlicher als im String nach Zahlen zu suchen: eine Zeichenkette + -- kann `-32601` auch in einer Meldung enthalten. + local ok, obj = pcall(json.decode, kodiert) + if not ok or type(obj) ~= "table" or type(obj.error) ~= "table" then + return "200 OK" + end + return _fehler_status[obj.error.code] or "200 OK" +end + +local function _dispatch_post(self, conn) + local body = conn.body + if body == "" then + return _build_http_response("400 Bad Request", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + jsonrpc_error(nil, -32700, "Empty body"), nil) + end + local ok, rpc_req = pcall(json.decode, body) + if not ok then + return _build_http_response("400 Bad Request", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + jsonrpc_error(nil, -32700, "Parse error"), nil) + end + + -- Server-initiated response routing: if id matches a pending + -- server-initiated request in ANY session, this POST is a response. + if rpc_req.id and (rpc_req.result ~= nil or rpc_req.error ~= nil) + and not rpc_req.method then + local sess, cb = _find_pending(self, rpc_req.id) + if sess then + sess.pending[rpc_req.id] = nil + pcall(cb, rpc_req) + return _build_http_response("202 Accepted", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + "", sess.id) + end + end + + -- Envelope-Pruefung (2026-07-28): Pflichtkoepfe + _meta + Fassung VOR + -- der Methoden-Disposition. Fehler tragen ihren JSON-RPC-Code und ihren + -- HTTP-Status aus _fehler_status (-32020/-32602 -> 400). + local e_ok, e_code, e_meldung = umschlag.pruefe(conn.headers, rpc_req) + if not e_ok then + return _build_http_response( + _fehler_status[e_code] or "400 Bad Request", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + jsonrpc_error(rpc_req.id, e_code, e_meldung), nil) + end + + -- Fassungs-Pruefung (Schlussstein): `versions.check` lehnt unbekannte + -- Fassungen mit -32022 + 400 ab und nennt data.supported (Kopie der + -- Liste, Kopie-Schutz unveraendert) + data.requested (die angefragte + -- Fassung). Laesst nil/"" zu (Vertragsregel a) -- der Umschlag hat + -- fehlendes _meta aber schon abgewiesen, hier kommt nur an, was einen + -- protocolVersion-Wert traegt. + local gewuenscht = ((rpc_req.params or {})._meta or {}) + ["io.modelcontextprotocol/protocolVersion"] + local zulaessig, fehler = versions.check(gewuenscht) + if not zulaessig then + fehler.data.requested = gewuenscht + return _build_http_response( + _fehler_status[fehler.code] or "400 Bad Request", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + jsonrpc_error(rpc_req.id, fehler.code, + "Unsupported protocol version", fehler.data), nil) + end + + -- Session resolution (deferred from header-parse time so we can detect + -- `initialize`). Rules: + -- - `initialize`: always mint a fresh session, ignoring any client sid + -- - other methods, sid absent: auto-issue (backwards compat) + -- - other methods, sid known: use it + -- - other methods, sid unknown: 404 + local sess + if rpc_req.method == "initialize" then + sess = _create_session(self) + else + local s, serr = _resolve_session(self, conn.requested_sid) + if not s then + return _build_http_response("404 Not Found", + { ["Content-Type"] = "text/plain", + ["Access-Control-Allow-Origin"] = "*" }, + "Session not found: " .. tostring(conn.requested_sid), nil) + end + sess = s + end + conn.session_id = sess.id + -- Stash session id on the request so handle_request → tools/call can + -- expose it to handler ctx (issue #9 — sampling needs to know which + -- session to push the request onto). + rpc_req._session_id = sess.id + -- Stash the JSON-RPC id on the conn so _finalise_dispatch can clear + -- the cancellation flag for this request after building the response + -- (issue #11). Notifications have nil id; that's fine — the + -- nil-guard in _finalise_dispatch keeps tostring(nil) out of the + -- cancel map. + conn.dispatch_id = rpc_req.id + + -- Concurrent handler dispatch (issue #20). Wrap the dispatch call in + -- a coroutine so any tool handler that goes through server.lua:run() + -- (which yields when polling its sentinel file) can return control to + -- the event loop while it waits. Other connections continue making + -- progress. + -- + -- The coroutine resumes itself synchronously the first time. If it + -- completes without yielding (pure-Lua handlers, ping, etc.) the + -- response is built inline as before. If it yields, we park it in + -- self._pending_handlers and return nil — the conn enters + -- dispatching_async, the scheduler tick resumes when wake_at passes. + local co = coroutine.create(function() + return self:handle_request(rpc_req) + end) + return _drive_handler_co(self, conn, co) +end + +-- Resume a handler coroutine until it completes or yields. On completion, +-- build the deferred HTTP response (preserving the Accept-aware shape). +-- On yield, register in self._pending_handlers and return nil — the conn +-- is parked in dispatching_async until the scheduler resumes it. +_drive_handler_co = function(self, conn, co) + local rok, ryield = coroutine.resume(co) + if coroutine.status(co) == "dead" then + return _finalise_dispatch(self, conn, rok, ryield, co) + end + -- Suspended. Parse the yield payload. + local wake_at = (type(ryield) == "table" and ryield.wake_at) or 0 + self._pending_handlers[#self._pending_handlers + 1] = { + co = co, conn = conn, wake_at = wake_at, + } + conn.state = "dispatching_async" + return nil -- no write_buf change; conn parks +end + +-- Build the HTTP response for a completed dispatch. `rok` is the coroutine.resume +-- success flag; `result` is the handler/dispatch return (a JSON-RPC string when +-- rok=true; an error message when rok=false). Used by both the sync path +-- (_dispatch_post tail) and the async resume path (_scheduler_tick). +-- Also: clears cancellation flag and ctx-by-co registry entry for this +-- request (issue #11 — single cleanup site per Phase 5 review fix #7). +_finalise_dispatch = function(self, conn, rok, result, co) + local session_id = conn.session_id + + -- Cleanup (always): drop the coroutine's ctx entry and any + -- cancellation flag for this request id. + if co ~= nil then _ctx_by_co[co] = nil end + local rid = conn.dispatch_id + local was_cancelled = false + if rid ~= nil then + local rid_str = tostring(rid) + if self._cancelled_ids[rid_str] then + was_cancelled = true + self._cancelled_ids[rid_str] = nil + end + end + -- Issue #11: cancelled requests get a -32800 JSON-RPC error response. + -- The MCP spec wording is "SHOULD NOT respond" (not MUST NOT). A silent + -- TCP-close would be cleaner but the spawned shell subprocess in + -- server.lua:run() inherits the socket FD via fork(), so the kernel + -- keeps the connection alive until that shell exits (i.e. the + -- underlying long-running command completes anyway). The error + -- response gives the client a structured signal and exits curl + -- immediately, which is the practical UX they want. JSON-RPC 2.0 + -- code -32800 is the convention for "Request cancelled." + if was_cancelled then + return _build_http_response("200 OK", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + jsonrpc_error(rid, -32800, "Request cancelled"), + session_id) + end + + if not rok then + -- Internal dispatch error — surface as a JSON-RPC error response. + return _build_http_response("500 Internal Server Error", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + jsonrpc_error(nil, -32603, "Internal error: " .. tostring(result)), + session_id) + end + if not result then + -- Notification → 202 Accepted, no body. + return _build_http_response("202 Accepted", + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + "", session_id) + end + -- 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 "" + -- Fehler NIE als Strom. Die Spec verlangt vom Klienten ein Accept mit + -- text/event-stream (streamable-http, „MUST include an Accept header + -- listing both"), also traf der SSE-Zweig genau die vorgeschriebene + -- Anfrageform -- und _build_sse_headers verdrahtet 200. Die Statuszuordnung + -- war damit fuer den Normalfall unerreichbar. Ein Fehler hat nichts zu + -- streamen: keine Benachrichtigungen davor, nichts offen zu halten. + local status = _status_fuer_fehlercode(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(status, + { ["Content-Type"] = "application/json", + ["Access-Control-Allow-Origin"] = "*" }, + result, session_id) +end + +local function _dispatch_options(conn) + local acrh = conn.headers["access-control-request-headers"] + return _build_http_response("204 No Content", { + ["Access-Control-Allow-Origin"] = "*", + ["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS", + -- '*' does NOT cover Authorization per CORS spec; list explicitly. + ["Access-Control-Allow-Headers"] = acrh and (acrh .. ", Authorization") + or "Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version", + ["Access-Control-Max-Age"] = "86400", + }, "", conn.session_id) +end + +local function _dispatch_delete(self, conn) + if not conn.session_id or not self._sessions[conn.session_id] then + return _build_http_response("404 Not Found", + { ["Content-Type"] = "text/plain", + ["Access-Control-Allow-Origin"] = "*" }, + "Session not found", nil) + end + local sess = self._sessions[conn.session_id] + if sess.sse_conn then + sess.sse_conn.state = "closing" + sess.sse_conn = nil + end + self._sessions[conn.session_id] = nil + return _build_http_response("204 No Content", + { ["Access-Control-Allow-Origin"] = "*" }, "", nil) +end + +-- ---- Main loop helpers ---- + +local function _conn_read(self, conn) + local chunk, err, partial = conn.sock:receive(8192) + local data = chunk or partial or "" + if data ~= "" then + if #conn.buf + #data > READ_BUF_CAP and conn.state == "reading_head" then + -- Header section too large. + conn.write_buf = _build_http_response("431 Request Header Fields Too Large", + { ["Content-Type"] = "text/plain" }, "Headers too large", nil) + conn.state = "writing" + return + end + conn.buf = conn.buf .. data + end + if err == "closed" then + conn.state = "closing" + return + end + -- Advance FSM. + if conn.state == "reading_head" then + local ok, perr = _parse_request_head(conn) + if perr then + conn.write_buf = _build_http_response("400 Bad Request", + { ["Content-Type"] = "text/plain" }, tostring(perr), nil) + conn.state = "writing" + return + end + if not ok then return end -- still waiting for full head + + -- Headers parsed: auth check, session resolve. + if not _check_auth(self, conn) then + conn.write_buf = _build_http_response("401 Unauthorized", + { ["Content-Type"] = "application/json", + ["WWW-Authenticate"] = "Bearer" }, + '{"error":"unauthorized"}', nil) + conn.state = "writing" + return + end + -- Session resolution happens at dispatch time (after body is read), + -- because `initialize` always mints a fresh session and we need to + -- know the method to distinguish 404 (unknown id, non-initialize) + -- from "auto-issue on initialize". Just stash the requested id. + conn.requested_sid = conn.headers["mcp-session-id"] + + if conn.body_remain > 0 then + -- Any body bytes already in conn.buf land here. + conn.body = conn.buf:sub(1, conn.body_remain) + conn.buf = conn.buf:sub(#conn.body + 1) + conn.body_remain = conn.body_remain - #conn.body + conn.state = (conn.body_remain == 0) and "dispatching" or "reading_body" + else + conn.state = "dispatching" + end + end + if conn.state == "reading_body" then + if #conn.buf > 0 then + local take = math.min(conn.body_remain, #conn.buf) + conn.body = conn.body .. conn.buf:sub(1, take) + conn.buf = conn.buf:sub(take + 1) + conn.body_remain = conn.body_remain - take + end + if conn.body_remain == 0 then conn.state = "dispatching" end + end + if conn.state == "dispatching" then + local path = conn.path or "" + if not path:match("^/mcp") then + conn.write_buf = _build_http_response("404 Not Found", + { ["Content-Type"] = "text/plain", + ["Access-Control-Allow-Origin"] = "*" }, + "Not Found", nil) + conn.state = "writing" + return + end + if conn.method == "OPTIONS" then + conn.write_buf = _dispatch_options(conn) + conn.state = "writing" + 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." Beide trugen die Mechanik der + -- alten Fassung — GET oeffnete einen eigenstaendigen SSE-Strom, + -- DELETE beendete eine Sitzung. Zitat der Spec zu beidem: „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 == "POST" then + -- _dispatch_post may return nil (issue #20) if the handler + -- coroutine yielded. In that case it set conn.state = + -- "dispatching_async" itself and parked the coroutine. + local resp = _dispatch_post(self, conn) + if resp then + conn.write_buf = resp + -- _finalise_dispatch sets conn.state = "closing" for + -- cancelled requests (issue #11); only override if not. + if conn.state ~= "closing" then + conn.state = "writing" + end + end + -- else: conn already parked; scheduler tick will finalise. + else + conn.write_buf = _build_http_response("405 Method Not Allowed", + { ["Content-Type"] = "text/plain", + ["Allow"] = "GET, POST, DELETE, OPTIONS" }, + "Method Not Allowed", nil) + conn.state = "writing" + end + end +end + +local function _conn_write(conn) + if conn.write_buf == "" then + if conn.state == "writing" then conn.state = "closing" end + return + end + local sent, err, sent_partial = conn.sock:send(conn.write_buf) + if err == "closed" then + conn.state = "closing" + return + end + -- luasocket: on success returns last_byte_index_sent; on partial/timeout + -- returns (nil, "timeout"|"closed", last_byte_index_sent_so_far). The + -- second-return numeric is an absolute index into the original string. + local idx = sent or sent_partial or 0 + if idx > 0 then + conn.write_buf = conn.write_buf:sub(idx + 1) + end + if conn.write_buf == "" and conn.state == "writing" then + conn.state = "closing" + end +end + +local function _drain_notifications(self) + -- Global broadcast queue: fan out to every open sse_conn. + while #self._notify_queue > 0 do + local entry = table.remove(self._notify_queue, 1) + local payload = _sse_event(_encode_notify(entry)) + for _, sess in pairs(self._sessions) do + if sess.sse_conn and sess.sse_conn.state == "sse_open" then + _queue_write(sess.sse_conn, payload) + end + end + end + -- Per-session queues: route to that session only. + for _, sess in pairs(self._sessions) do + while #sess.notify_q > 0 do + if not (sess.sse_conn and sess.sse_conn.state == "sse_open") then + break -- no live SSE; leave queued (or expire policy could drop) + end + local entry = table.remove(sess.notify_q, 1) + _queue_write(sess.sse_conn, _sse_event(_encode_notify(entry))) + end + end +end + +local function _heartbeat_tick(self) + local now = os.time() + -- Heartbeats on open SSE conns. + for _, sess in pairs(self._sessions) do + local conn = sess.sse_conn + if conn and conn.state == "sse_open" + and now - conn.last_heart >= HEARTBEAT_SEC then + _queue_write(conn, ": heartbeat\r\n\r\n") + conn.last_heart = now + end + end + -- Idle session expiry: no SSE + no activity for SESSION_IDLE_SEC. + for sid, sess in pairs(self._sessions) do + if sess.sse_conn == nil and now - sess.last_activity > SESSION_IDLE_SEC then + self._sessions[sid] = nil + end + end +end + +-- Issue #20 — scheduler tick. Resume any parked dispatch coroutine whose +-- wake_at has passed. On completion, build the deferred response and +-- queue it for write. If the connection died while the handler was +-- parked, drop the coroutine. +-- +-- gettime() is wall-clock (luasocket uses gettimeofday) — NOT monotonic. +-- A large NTP step backwards could delay resumes; forwards could bunch +-- them. Acceptable for the deployment fleet (chrony slews); revisit if +-- a use case appears that needs CLOCK_MONOTONIC. +local function _scheduler_tick(self) + if not self._pending_handlers[1] then return end + local socket = require("socket") + local now = socket.gettime() + local i = 1 + while i <= #self._pending_handlers do + local p = self._pending_handlers[i] + if p.conn.state == "closing" then + -- Connection died mid-handler; drop the coroutine entirely + -- and free its ctx entry (issue #11 cleanup discipline). + _ctx_by_co[p.co] = nil + if p.conn.dispatch_id ~= nil then + self._cancelled_ids[tostring(p.conn.dispatch_id)] = nil + end + table.remove(self._pending_handlers, i) + elseif now >= p.wake_at then + -- Time to resume. Remove from pending BEFORE resume so a + -- re-yielding handler re-adds itself cleanly via _drive_handler_co. + table.remove(self._pending_handlers, i) + local rok, ryield = coroutine.resume(p.co) + if coroutine.status(p.co) == "dead" then + local resp = _finalise_dispatch(self, p.conn, rok, ryield, p.co) + p.conn.write_buf = (p.conn.write_buf or "") .. resp + -- _finalise_dispatch may set conn.state = "closing" for + -- cancelled requests; only transition to writing if it + -- didn't already pick the closing path. + if p.conn.state ~= "closing" then + p.conn.state = "writing" + end + else + -- Yielded again — re-park. + local wake_at = (type(ryield) == "table" and ryield.wake_at) or 0 + self._pending_handlers[#self._pending_handlers + 1] = { + co = p.co, conn = p.conn, wake_at = wake_at, + } + end + else + i = i + 1 + end + end +end + +-- Returns the earliest pending wake_at as an offset from now, or nil if +-- no handlers are parked. Used to tighten the select() timeout so the +-- scheduler wakes on the right beat. +local function _next_pending_delay(self) + if not self._pending_handlers[1] then return nil end + local socket = require("socket") + local now = socket.gettime() + local earliest = math.huge + for _, p in ipairs(self._pending_handlers) do + if p.wake_at < earliest then earliest = p.wake_at end + end + local d = earliest - now + if d < 0 then return 0 end + return d +end + +-- ---- Public: server-initiated request (for sampling/roots/etc.) ---- +-- Enqueues a JSON-RPC request on the session's SSE stream. The callback +-- fires when the client POSTs back the response (matched by id). +function lmcp:server_request(session_id, method, params, on_response) + local sess = self._sessions[session_id] + if not sess or not sess.sse_conn then + return false, "no live SSE stream for session " .. tostring(session_id) + end + self._server_req_id = (self._server_req_id or 0) + 1 + local id = "srv-" .. self._server_req_id + sess.pending[id] = on_response + local msg = { jsonrpc = JSONRPC, id = id, method = method } + -- Omit params if nil OR an empty Lua table (would JSON-encode as [] + -- per project_json_empty_table_gotcha memory). Real params with at + -- least one key encode correctly as an object. + if params ~= nil and (type(params) ~= "table" or next(params)) then + msg.params = params + end + sess.notify_q[#sess.notify_q + 1] = msg + return true, id +end + +-- Sampling (MCP issue #9): ask the client's LLM to generate text. Returns +-- (true, request_id) if dispatched, (false, err) otherwise. `on_response` +-- is called with the client's JSON-RPC response shape: +-- { result = { role, content = { type = "text", text = "..." }, model, stopReason? } } +-- or { error = { code, message } }. +-- +-- Today this is fire-and-forget — tool handlers cannot block waiting for +-- the response in the single-threaded event loop (see follow-up #20). A +-- tool may kick off sampling and return immediately; the callback fires +-- when the client posts the response back. +-- +-- opts shape (matches MCP spec): +-- messages = { { role = "user"|"assistant", content = {type, text} }, ... } +-- modelPreferences? = { hints?, intelligencePriority?, ... } +-- systemPrompt? = string +-- includeContext? = "none"|"thisServer"|"allServers" +-- temperature? = number +-- maxTokens = integer (required) +-- stopSequences? = list of strings +function lmcp:sample(session_id, opts, on_response) + if not (self._client_caps.sampling) then + return false, "client did not advertise sampling capability" + end + if type(opts) ~= "table" or type(opts.messages) ~= "table" + or type(opts.maxTokens) ~= "number" then + return false, "sample: opts.messages (table) and opts.maxTokens (number) required" + end + return self:server_request(session_id, "sampling/createMessage", opts, on_response) +end + +-- Roots (MCP issue #10): ask the client which filesystem/URL roots are +-- in scope for this session. Async like sample(); on_fetched(roots_list, +-- err) fires when the client responds. Result is also cached on +-- self._roots_cache[session_id] for later sync lookups. +-- +-- Client→server `notifications/roots/list_changed` invalidates the cache; +-- next call to :roots() re-fetches. +function lmcp:roots(session_id, on_fetched) + if not (self._client_caps.roots) then + return false, "client did not advertise roots capability" + end + return self:server_request(session_id, "roots/list", {}, function(resp) + if resp.error then + if on_fetched then on_fetched(nil, resp.error.message or "rpc error") end + return + end + local list = resp.result and resp.result.roots or {} + self._roots_cache[session_id] = { + roots = list, fetched = os.time(), + } + if on_fetched then on_fetched(list, nil) end + end) +end + +-- Synchronous lookup of the cached roots. Returns the list (possibly +-- empty) if previously fetched, or nil if no :roots() call has completed +-- for this session yet. +function lmcp:roots_cached(session_id) + local entry = self._roots_cache[session_id] + return entry and entry.roots or nil +end + +-- Synchronous helper: is `path` (a file:// URI or absolute filesystem +-- path) within any cached root for this session? Returns: +-- true → matches at least one root +-- false → cache is populated but path is outside all roots +-- nil → no roots cached yet; caller should :roots() first +function lmcp:path_in_roots(session_id, path) + local roots = self:roots_cached(session_id) + if not roots then return nil end + -- Normalise: treat file:// URIs and bare paths uniformly. + local norm = path:gsub("^file://", "") + for _, r in ipairs(roots) do + local root_path = (r.uri or ""):gsub("^file://", "") + if root_path ~= "" and norm:sub(1, #root_path) == root_path then + return true + end + end + return false +end + +function lmcp:run() + local socket = require("socket") + local server_sock = assert(socket.bind(self.host, self.port)) + server_sock:settimeout(0) + self._conns = {} + self._sessions = self._sessions or {} + + local addr, port = server_sock:getsockname() + io.stderr:write(string.format("lmcp: %s v%s listening on %s:%d/mcp\n", + self.name, self.version, addr, port)) + + while true do + -- Build select watch lists. + local reads, writes = { server_sock }, {} + for sock, conn in pairs(self._conns) do + if conn.state == "reading_head" or conn.state == "reading_body" + or conn.state == "sse_open" then + reads[#reads + 1] = sock + end + if conn.write_buf ~= "" then + writes[#writes + 1] = sock + end + end + + -- Tighten select timeout if a parked handler is due sooner. + -- Otherwise a 100ms tick adds 100ms latency to short shell-tool runs. + local select_timeout = SELECT_TIMEOUT + local next_pend = _next_pending_delay(self) + if next_pend and next_pend < select_timeout then + select_timeout = next_pend + end + local ready_r, ready_w = socket.select(reads, writes, select_timeout) + + for _, sock in ipairs(ready_r or {}) do + if sock == server_sock then + local new_sock, aerr = server_sock:accept() + if new_sock then + new_sock:settimeout(0) + self._conns[new_sock] = { + sock = new_sock, state = "reading_head", + buf = "", body = "", headers = {}, + method = nil, path = nil, + body_remain = 0, write_buf = "", + session_id = nil, last_heart = os.time(), + } + elseif aerr and aerr ~= "timeout" then + io.stderr:write("lmcp: accept error: " .. tostring(aerr) .. "\n") + end + else + local conn = self._conns[sock] + if conn then + local ok, rerr = pcall(_conn_read, self, conn) + if not ok then + io.stderr:write("lmcp: read error: " .. tostring(rerr) .. "\n") + conn.state = "closing" + end + end + end + end + + for _, sock in ipairs(ready_w or {}) do + local conn = self._conns[sock] + if conn then + local ok, werr = pcall(_conn_write, conn) + if not ok then + io.stderr:write("lmcp: write error: " .. tostring(werr) .. "\n") + conn.state = "closing" + end + end + end + + -- Per-tick maintenance. + _drain_notifications(self) + _heartbeat_tick(self) + _scheduler_tick(self) -- issue #20: resume due dispatch coroutines + + -- After draining, attempt immediate writes on conns whose write_buf + -- just got bytes (so list_changed / heartbeat / async-completed + -- responses appear within one tick). + for sock, conn in pairs(self._conns) do + if conn.write_buf ~= "" and conn.state ~= "closing" then + pcall(_conn_write, conn) + end + end + + -- Sweep closing conns. + for sock, conn in pairs(self._conns) do + if conn.state == "closing" then + -- Detach from session if it was the sse_conn. + if conn.session_id then + local sess = self._sessions[conn.session_id] + if sess and sess.sse_conn == conn then + sess.sse_conn = nil + sess.last_activity = os.time() + end + end + pcall(sock.close, sock) + self._conns[sock] = nil + end + end + end +end + +-- ---- stdio transport (MCP issue #15) ---- +-- Line-delimited JSON-RPC: one message per line on stdin, one response +-- line per request on stdout, diagnostics on stderr. EOF closes cleanly. +-- Does NOT require luasocket — handle_request is transport-agnostic. +-- Bearer auth bypassed: stdio means the parent process is the trust +-- boundary. +function lmcp:run_stdio() + -- Default stdout buffering on a pipe is "full" — a response would + -- sit in the buffer until it fills, deadlocking the MCP client. + -- Set "no" once + per-write flush belt-and-braces. + io.stdout:setvbuf("no") + io.stderr:write(string.format( + "lmcp: %s v%s serving stdio\n", self.name, self.version)) + + for line in io.stdin:lines() do + if line ~= "" then + -- pcall the whole body so a transient error (malformed JSON, + -- handler bug, exotic pipe state) doesn't crash the loop. + local body_ok, body_err = pcall(function() + local parse_ok, req = pcall(json.decode, line) + local response + if not parse_ok then + response = jsonrpc_error(nil, -32700, "Parse error") + else + response = self:handle_request(req) + end + if type(response) == "string" then + io.stdout:write(response, "\n") + io.stdout:flush() + elseif response ~= nil then + io.stderr:write( + "lmcp: handler returned non-string (" + .. type(response) .. "); dropped\n") + end + end) + if not body_ok then + io.stderr:write("lmcp: stdio loop error: " + .. tostring(body_err) .. "\n") + end + end + end +end + +return lmcp diff --git a/artefakte_konformitaet/umschlag.lua b/artefakte_konformitaet/umschlag.lua new file mode 100644 index 0000000..b04531c --- /dev/null +++ b/artefakte_konformitaet/umschlag.lua @@ -0,0 +1,65 @@ +-- umschlag.lua: Kopf-/Envelope-Pruefung nach Spec 2026-07-28 +-- Basis: job667.lua (coder Lauf 5, #669) — parst, reine Struktur, zwei +-- Defekte per Foreman-Review #671/#674 hier korrigiert: +-- Regel 3: mcp-name-KOPF wird geprueft (war nur params.name/uri) +-- Regel 4: _meta liegt unter rumpf.params._meta (war rumpf.meta) +local M = {} + +-- kopf: Tabelle der HTTP-Kopfzeilen, Namen KLEINGESCHRIEBEN, z.B. +-- {['mcp-method']='tools/list',['mcp-protocol-version']='2026-07-28'} +-- rumpf: dekodierte JSON-RPC-Anfrage als Lua-Tabelle +-- {jsonrpc='2.0',id=1,method='tools/list',params={_meta={...}}} +-- Rueckgabe: true,nil,nil | false,, +function M.pruefe(kopf, rumpf) + if not kopf or not rumpf then + return false, -32600, "Invalid Request" + end + + -- Regel 1: k['mcp-protocol-version'] fehlt + if not kopf['mcp-protocol-version'] then + return false, -32020, 'Missing required header: MCP-Protocol-Version' + end + + -- Regel 2: k['mcp-method'] fehlt + if not kopf['mcp-method'] then + return false, -32020, 'Missing required header: Mcp-Method' + end + + -- Regel 2: k['mcp-method'] ~= rumpf.method + if kopf['mcp-method'] ~= rumpf.method then + return false, -32020, 'Mcp-Method header does not match body' + end + + -- Regel 3: Mcp-Name-Kopf Pflicht bei tools/call, resources/read, prompts/get + local method = rumpf.method + local params = rumpf.params or {} + + if method == 'tools/call' or method == 'prompts/get' then + if not kopf['mcp-name'] or kopf['mcp-name'] ~= params.name then + return false, -32020, 'Mcp-Name header does not match body' + end + elseif method == 'resources/read' then + if not kopf['mcp-name'] or kopf['mcp-name'] ~= params.uri then + return false, -32020, 'Mcp-Name header does not match body' + end + end + + -- Regel 4: _meta-Pflichtfelder (liegen unter params._meta) + local meta = (rumpf.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 + + -- Regel 5: k['mcp-protocol-version'] ~= meta['io.modelcontextprotocol/protocolVersion'] + if kopf['mcp-protocol-version'] ~= meta['io.modelcontextprotocol/protocolVersion'] then + return false, -32020, 'Header MCP-Protocol-Version does not match _meta protocolVersion' + end + + -- Erfolg + return true, nil, nil +end + +return M diff --git a/artefakte_konformitaet/versions.lua b/artefakte_konformitaet/versions.lua new file mode 100644 index 0000000..ad32b0c --- /dev/null +++ b/artefakte_konformitaet/versions.lua @@ -0,0 +1,55 @@ +-- versions.lua +-- Modul zur Versionspruefung gemaess LMCP-Vertrag + +local M = {} + +-- Nur die neue Fassung. Die alte (2025-06-18) existiert nicht mehr: mit +-- SEP-2575 ist `initialize` entfernt, die Verhandlung der alten Fassung +-- damit tot. 2025-06-18 in der Liste zu fuehren waere eine Luege im +-- discover-Response. Dual-Protokoll (alt + neu) ist eine spaetere Phase. +M.SUPPORTED = {"2026-07-28"} + +--- Prueft, ob eine Version unterstuetzt wird. +-- @param version Die zu pruefende Version (String oder nil) +-- @return boolean true wenn unterstuetzt, sonst false +-- @return table|nil Fehlerdetails bei Nichtunterstuetzung +function M.check(version) + -- (a) version == nil oder "" -> true, nil + if version == nil or version == "" then + return true, nil + end + + -- (b) exakter Treffer in M.SUPPORTED -> true, nil + for _, supported_version in ipairs(M.SUPPORTED) do + if version == supported_version then + return true, nil + end + end + + -- (c) alles andere -> false, { code = -32022, data = { supported = } } + -- + -- KOPIE, nicht M.SUPPORTED selbst. Vorher ging die Modultabelle nach aussen: + -- ein Aufrufer, der das Fehlerobjekt behaelt und `err.data.supported[1]` + -- ueberschreibt, veraendert die Liste des Moduls fuer JEDEN folgenden + -- Aufruf -- vorgefuehrt am 2026-08-09: nach + -- `e.data.supported[1] = "GEKAPERT"` ist `check("2025-06-18")` falsch und + -- `check("GEKAPERT")` wahr. Vertragsregel 5 ("Ein Aufruf darf die Liste des + -- naechsten nicht verschieben") war damit gebrochen, und Pruefung 5 sah es + -- nicht, weil sie nie ueber die zurueckgegebene Referenz schreibt. + -- Solange das Modul niemand aufrief, war das theoretisch. Seit es an + -- `initialize` haengt, ist es erreichbar. + local kopie = {} + for i = 1, #M.SUPPORTED do + kopie[i] = M.SUPPORTED[i] + end + return false, { + code = -32022, + data = { + supported = kopie + } + } +end + +-- Keine neuen Globalen, keine Seiteneffekte, M.SUPPORTED wird nicht geaendert + +return M diff --git a/entscheid_umschlag.md b/entscheid_umschlag.md new file mode 100644 index 0000000..8300289 --- /dev/null +++ b/entscheid_umschlag.md @@ -0,0 +1,32 @@ +ENTSCHEIDUNG (deus, Operator) — Eskalation #656: Option C. + +C gewaehlt: Selbsttest-Anforderung streichen. Die Suite ist der Test. + +Begruendung: +- 3 von 4 Coder-Fehlschlägen sind Selbsttest-MECHANIK (Fence, return-Position, + String-Konstruktion) — Nicht-Konvergenz genau an dem Teil ohne externen Wert. + Die Suite ist DER VERTRAG (Uebergabe). Weiter am Selbsttest zu ziehen ist + der bekannte Runaway. +- Lauf 3 (job649) hatte einen gruenen Selbsttest auf den falschen Funktionen — + Selbsttests dieses Modells haben nachweislich null Verifikationswert. + Einzig vertrauenswuerdiger Check ist die Suite. +- Nach Integration laeuft die Suite sofort; konkrete rote Punkte nennen + erwartet/ist und treiben gezielte Fixes. + +Neuer Dispatch an @coder (Lauf 5): +- Datei umschlag.lua, ROH-Lua, keine Fences/Backticks, Zeile 1 echte Lua. +- NUR das Modul: local M = {}; function M.pruefe(kopf, rumpf) -> true,nil,nil | + false,, end; return M als LETZTE Zeile. +- KEIN Selbsttest in der Datei (entfaellt, die Suite prueft nach meiner + Integration). +- Parse-Check durch die Sandkiste (lua5.4); Logik-Check durch die Suite. +- Regeln unveraendert (#628): Kopfnamen klein (case-insensitiv), Kopfwerte + case-sensitiv; fehlender/abweichender Pflichtkopf (Mcp-Method, + Mcp-Protocol-Version, Mcp-Name bei tools/call|resources/read|prompts/get) + -> -32020 HeaderMismatch; MCP-Protocol-Version muss == params._meta + ["io.modelcontextprotocol/protocolVersion"]; _meta-Pflichtfelder fehlen + -> -32602; Reihenfolge: Koepfe zuerst, bei beidem verletzt gewinnt -32020. + +Nach Lieferung: ich integriere in lmcp.lua (Operator), lasse die Suite laufen, +sende die konkreten roten Punkte als Fix-Auftraege mit Suite-Text als +Abnahmekriterium. Kein Dienst wird angefasst. diff --git a/integration_done.md b/integration_done.md new file mode 100644 index 0000000..d0fa6c9 --- /dev/null +++ b/integration_done.md @@ -0,0 +1,25 @@ +INTEGRATION DONE (deus, Operator) — Suite gemessen: 19/27 zu, 8 OFFEN. + +Was ich tat (Basis job667.lua, coder Lauf 5): +- umschlag.lua = job667 + die zwei von dir benannten Fixes (#671/#674), + angewandt beim Integrieren: Regel 3 mcp-name-KOPF, Regel 4 _meta unter + rumpf.params._meta. Lokal gegen die Suite-Szenarien verifiziert (11/11). +- In lmcp.lua verdrahtet: require + Envelope-Check in _dispatch_post nach dem + Response-Routing, Fehler via _fehler_status -> 400. Kein anderer Zweig + angefasst; laufender Raum-Server unberuehrt (nur Arbeitskopie). +- Suite gelaufen, Vertrag unveraendert (sha256 c90450a6... identisch). + +Zugewachsen: header 5/5, _meta 3/3 (waren 0). Keine Regression (discover 4/5, +result, method, codes, transport weiter gruen). + +Offen (8), alle Operator-Arbeit (Lua-Implementierungsdateien, keine +Roster-Rolle darf die aendern): +- version 4: unbekannte Fassung -> -32022 + 400 + data.supported/requested. + versions.check haengt nur am initialize-Zweig (Z.599), nicht am + Request-Pfad. Anbinden im Umschlag erfordert "2026-07-28" in + versions.SUPPORTED (discover kopiert die Liste, Z.576) -> dreht den + Schlussstein mit. => letzter Block. +- entfernt 3: initialize/ping/logging/setLevel -> -32601. +- Schlussstein 1: supportedVersions enthaelt 2026-07-28 (bleibt rot bis 26 zu). + +@coder-Umschlag-Teil abgeschlossen. Naechste Portionen fahre ich als Operator. diff --git a/resume_umschlag.md b/resume_umschlag.md new file mode 100644 index 0000000..6fefd68 --- /dev/null +++ b/resume_umschlag.md @@ -0,0 +1,30 @@ +RESUME — umschlag.lua re-dispatch an @coder (Repair-first abgeschlossen, deus). + +Stand: @foreman-Sitzung funktional (bestätigt #642). @coder-Sandbox ist +funktionsfähig — der Fehler (#630, "job628.lua:1: unexpected symbol near '`'") +war @coders eigene Ausgabe: es hat seinen Lua-Code in ```lua ... ```-Fences +gepackt, und die Sandkiste schrieb das Backtick als ungültige Zeile 1. + +Bitte @coder erneut dispatchen für die Datei umschlag.lua, mit dem expliziten +Zusatz: + + "Schreibe die Datei als ROHEN Lua-Quelltext — KEINE Markdown-Fences, kein + ```lua ... ```, keine Backticks. Zeile 1 muss echte Lua sein." + +Vertrag bleibt wie in #628 (nicht neu verhandeln): + local M = {} + -- kopf: Tabelle der HTTP-Kopfzeilen, Namen KLEINGESCHRIEBEN + -- rumpf: dekodierte JSON-RPC-Anfrage als Lua-Tabelle + function M.pruefe(kopf, rumpf) -> true,nil,nil | false,, + return M +Regeln (wörtlich aus der Spec, siehe SPEC-AUSZUG.md): + - fehlender/abweichender Pflichtkopf (Mcp-Method, Mcp-Protocol-Version, + Mcp-Name bei tools/call|resources/read|prompts/get) -> -32020 HeaderMismatch + - MCP-Protocol-Version muss == params._meta["io.modelcontextprotocol/protocolVersion"] + - Kopfnamen case-insensitiv (bekommt sie klein), Kopfwerte case-sensitiv + - _meta-Pflichtfelder fehlen -> -32602 (protocolVersion, clientCapabilities) + - Reihenfolge: Köpfe zuerst, dann _meta; bei beidem verletzt gewinnt -32020. + - Selbsttest in der Datei; er darf nicht werfen. + +Wenn @coder liefert, integriere ich (deus, Operator) in lmcp.lua, lasse die +Suite laufen und reiche an @reviewer. Kein Dienst wird angefasst.