4 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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