611a047bef
Eine lmcp-Instanz konnte ihren Werkzeugsatz bisher nur ERWEITERN.
tools.d-Dateien fuegen hinzu; der Grundstock aus server.lua bringt
shell, shell_bg, write_file, edit_file, read_file, fetch und web_search
mit, und eine Plugin-Datei kann nichts wegnehmen.
Gemessen am 2026-08-08 im bullpen: jeder Agent-Container haelt den
Raum-Token, also hatte JEDER von ihnen eine Wurzelschale im
Raum-Container. Nachgewiesen aus dem foreman-Container -> uid=0(root),
hostname room, Schreibzugriff auf room.jsonl. Das ist keine
Einbruchsluecke (alle Container sind eine Sicherheitsdomaene), aber es
macht jede Aussage ueber Rollentrennung unbelegbar: der Orchestrator,
der laut Entwurf KEINE Schale haben darf ("Raumtext ist nicht
vertrauenswuerdig"), hat eine — und hat sie im ersten Durchlauf
unaufgefordert benutzt, um die Abnahme seiner eigenen Koordination
auszufuehren.
GEPRUEFT WIRD BEI DER REGISTRIERUNG, nicht nachtraeglich loeschend.
Die Alternative waere eine Plugin-Datei, die nach dem Laden aus
server.tools entfernt — die muss jeden kuenftigen Grundstock-Eintrag
kennen und ist damit wieder eine Liste, die jemand pflegen muss. Genau
solche Listen laufen auseinander (siehe PRIVILEGED, zwei Kopien in zwei
Dateien). An der Registrierung ist die Regel EINE Aussage: was nicht auf
der Liste steht, entsteht nicht — Built-ins wie Plugins, heute wie fuer
alles, was morgen dazukommt.
Rueckwaertsvertraeglich: ohne LMCP_TOOL_ALLOW aendert sich nichts.
Verweigern ist stumm und wirft nicht, damit ein Plugin, das ein
gesperrtes Werkzeug anbietet, weiterlaeuft statt abzustuerzen; tool()
bleibt verkettbar.
tests/test_tool_allow.lua weist es in beide Richtungen nach: Rueckgabe 0
gegen diese Fassung, Rueckgabe 1 mit sieben Fehlschlaegen gegen die
vorige. Der Test prueft ausdruecklich, dass ein verweigertes Werkzeug
auch nicht AUFRUFBAR ist — tools/list und tools/call lesen dasselbe
Register, ein reiner Anzeigefilter waere wertlos gewesen.
Zwei Fallen im Test selbst, als Kommentar festgehalten, weil sie mich
zwei Runden gekostet haben: package.path muss VORNE ergaenzt werden,
sonst gewinnt die installierte /usr/share/lua/5.4/lmcp.lua und der Test
misst den falschen Baum; und die Rueckwaerts-Pruefung darf nur im
Elternlauf laufen, im Kind ist die Liste gesetzt.
1742 lines
70 KiB
Lua
1742 lines
70 KiB
Lua
-- 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')
|
|
|
|
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
|
|
local MCP_VERSION = "2025-06-18"
|
|
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 = <JSON Schema> -- 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
|
|
local function jsonrpc_result(id, result)
|
|
return json.encode({ jsonrpc = JSONRPC, id = id, result = result })
|
|
end
|
|
|
|
local function jsonrpc_error(id, code, message)
|
|
return json.encode({
|
|
jsonrpc = JSONRPC,
|
|
id = id,
|
|
error = { code = code, message = message },
|
|
})
|
|
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
|
|
|
|
if method == "initialize" then
|
|
self._session_id = self._session_id or tostring(os.time())
|
|
-- 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, {
|
|
protocolVersion = MCP_VERSION,
|
|
capabilities = caps,
|
|
serverInfo = {
|
|
name = self.name,
|
|
version = self.version,
|
|
},
|
|
})
|
|
|
|
elseif method == "ping" then
|
|
return jsonrpc_result(id, json.empty_object)
|
|
|
|
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
|
|
-- -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)
|
|
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
|
|
return jsonrpc_error(id, -32002, "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 == "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 {}
|
|
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
|
|
|
|
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
|
|
|
|
-- 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 ""
|
|
if 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",
|
|
{ ["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 == "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)
|
|
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 =
|
|
-- "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
|