forked from marfrit/lmcp
175244ab89
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.
1915 lines
79 KiB
Lua
1915 lines
79 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')
|
||
-- 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
|
||
|
||
-- 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
|
||
-- 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)
|
||
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 = {}
|
||
-- Per-instance allowlist (LMCP_TOOL_ALLOW, comma-separated). When set,
|
||
-- `tool()` registers ONLY those names -- built-ins like plugins.
|
||
-- When unset: everything as before. This is the only place where a
|
||
-- tool is created, hence the only place where it can be prevented;
|
||
-- removing one later means every future entry must know and stale out.
|
||
self.tool_allow = nil
|
||
do
|
||
local roh = os.getenv("LMCP_TOOL_ALLOW")
|
||
if roh and roh:match("%S") then
|
||
self.tool_allow = {}
|
||
for n in roh:gmatch("[^,%s]+") do self.tool_allow[n] = true end
|
||
end
|
||
end
|
||
-- Resources primitive (MCP 2025-06-18 §Server/Resources). Storage is
|
||
-- always present; capability is advertised iff `opts.resources` is
|
||
-- truthy OR at least one resource/template has been registered by
|
||
-- 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
|
||
-- Allow-list: silently refuse, so a plugin offering a tool that is not
|
||
-- allowed does not crash -- it simply does not exist. `tools/list` and
|
||
-- `tools/call` both read the same registry, so an unregistered tool is
|
||
-- neither visible nor callable.
|
||
if self.tool_allow and not self.tool_allow[name] then
|
||
return self
|
||
end
|
||
self.tools[name] = {
|
||
name = name,
|
||
description = description,
|
||
inputSchema = schema,
|
||
handler = handler,
|
||
annotations = opts and opts.annotations or nil,
|
||
outputSchema = opts and opts.outputSchema or nil,
|
||
}
|
||
return self
|
||
end
|
||
|
||
-- Register a resource (exact URI). opts: { name, description?, mimeType? }.
|
||
-- Handler signature: function(args) — args is always a table (empty for
|
||
-- literal resources, populated with template captures for templates).
|
||
function lmcp:resource(uri, opts, handler)
|
||
opts = opts or {}
|
||
if type(uri) ~= "string" or uri == "" then
|
||
error("resource: uri required")
|
||
end
|
||
if type(handler) ~= "function" then
|
||
error("resource: handler required")
|
||
end
|
||
self.resources[uri] = {
|
||
uri = uri,
|
||
name = opts.name or uri,
|
||
description = opts.description,
|
||
mimeType = opts.mimeType,
|
||
handler = handler,
|
||
}
|
||
self:notify_resources_changed()
|
||
return self
|
||
end
|
||
|
||
-- Register a resource template (RFC 6570 subset). Each {name} captures
|
||
-- one greedy segment. opts: { name, description?, mimeType? }.
|
||
-- Handler signature: function(args) — args[name] = captured_string.
|
||
-- Limitation: adjacent captures ({a}{b}) bind ambiguously; register
|
||
-- separate resources if you need precision.
|
||
function lmcp:resource_template(uriTemplate, opts, handler)
|
||
opts = opts or {}
|
||
if type(uriTemplate) ~= "string" or uriTemplate == "" then
|
||
error("resource_template: uriTemplate required")
|
||
end
|
||
if type(handler) ~= "function" then
|
||
error("resource_template: handler required")
|
||
end
|
||
-- Compile template → Lua pattern + arg-name list.
|
||
local arg_names = {}
|
||
-- Escape every Lua-pattern magic char EXCEPT {} (handled separately).
|
||
local escaped = uriTemplate:gsub("([%%%(%)%.%+%-%*%?%[%]%^%$])", "%%%1")
|
||
local pattern = escaped:gsub("{([%w_]+)}", function(name)
|
||
arg_names[#arg_names + 1] = name
|
||
return "(.+)"
|
||
end)
|
||
pattern = "^" .. pattern .. "$"
|
||
self.resource_templates[#self.resource_templates + 1] = {
|
||
uriTemplate = uriTemplate,
|
||
name = opts.name or uriTemplate,
|
||
description = opts.description,
|
||
mimeType = opts.mimeType,
|
||
pattern = pattern,
|
||
arg_names = arg_names,
|
||
handler = handler,
|
||
}
|
||
self:notify_resources_changed()
|
||
return self
|
||
end
|
||
|
||
-- Internal: enqueue a parameterless list_changed notification, with tail
|
||
-- dedup (consecutive notifications of the same kind collapse — they carry
|
||
-- no state, so N → 1 "go refetch"). Cap is a backstop, not the policy.
|
||
-- params omitted on purpose (json.lua empty-table → [] gotcha, would be
|
||
-- malformed JSON-RPC; spec allows omitting params for parameterless).
|
||
local function _enqueue_list_changed(self, method)
|
||
local tail = self._notify_queue[#self._notify_queue]
|
||
if tail and tail.method == method then return end
|
||
if #self._notify_queue >= self._notify_cap then
|
||
table.remove(self._notify_queue, 1)
|
||
end
|
||
self._notify_queue[#self._notify_queue + 1] = {
|
||
jsonrpc = JSONRPC,
|
||
method = method,
|
||
}
|
||
end
|
||
|
||
function lmcp:notify_resources_changed()
|
||
_enqueue_list_changed(self, "notifications/resources/list_changed")
|
||
end
|
||
|
||
function lmcp:notify_prompts_changed()
|
||
_enqueue_list_changed(self, "notifications/prompts/list_changed")
|
||
end
|
||
|
||
-- Resolve a URI to (resource_or_template_entry, args). Literal match wins
|
||
-- over templates; templates tried in registration order.
|
||
local function _resolve_resource(self, uri)
|
||
local lit = self.resources[uri]
|
||
if lit then return lit, {} end
|
||
for _, t in ipairs(self.resource_templates) do
|
||
local captures = { string.match(uri, t.pattern) }
|
||
if captures[1] then
|
||
local args = {}
|
||
for i, name in ipairs(t.arg_names) do
|
||
args[name] = captures[i]
|
||
end
|
||
return t, args
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- Run a resource handler under pcall, normalise the return into a single
|
||
-- contents item. Returns (item_table, nil) on success, (nil, err_msg)
|
||
-- on failure.
|
||
local function _read_resource(entry, args, uri)
|
||
local ok, result = pcall(entry.handler, args)
|
||
if not ok then
|
||
return nil, "resource handler error: " .. tostring(result)
|
||
end
|
||
if result == nil then
|
||
return nil, "resource handler returned no content"
|
||
end
|
||
if type(result) == "string" then
|
||
return { uri = uri, mimeType = entry.mimeType or "text/plain", text = result }
|
||
end
|
||
if type(result) == "table" then
|
||
local mt = result.mimeType or entry.mimeType
|
||
if result.text ~= nil then
|
||
return { uri = uri, mimeType = mt or "text/plain", text = result.text }
|
||
end
|
||
if result.blob ~= nil then
|
||
return { uri = uri, mimeType = mt or "application/octet-stream",
|
||
blob = result.blob }
|
||
end
|
||
if result.blob_bytes ~= nil then
|
||
local mime_ok, mime = pcall(require, "mime")
|
||
if not mime_ok then
|
||
return nil, "mime module unavailable; pre-encode blob and return { blob = … }"
|
||
end
|
||
local b64 = mime.b64(result.blob_bytes) or ""
|
||
b64 = b64:gsub("[\r\n]", "") -- some luasocket builds line-wrap
|
||
return { uri = uri, mimeType = mt or "application/octet-stream", blob = b64 }
|
||
end
|
||
end
|
||
return nil, "resource handler returned unsupported shape"
|
||
end
|
||
|
||
-- Register a prompt (MCP issue #6). opts: { description?, arguments? }
|
||
-- where arguments is a list of { name, description?, required? }.
|
||
-- Handler signature: function(args) where args[name] = supplied string.
|
||
-- Return either:
|
||
-- string → single user text message
|
||
-- { description?, messages = {...} } → full custom shape (passthrough)
|
||
function lmcp:prompt(name, opts, handler)
|
||
opts = opts or {}
|
||
if type(name) ~= "string" or name == "" then
|
||
error("prompt: name required")
|
||
end
|
||
if type(handler) ~= "function" then
|
||
error("prompt: handler required")
|
||
end
|
||
self.prompts[name] = {
|
||
name = name,
|
||
description = opts.description,
|
||
arguments = opts.arguments, -- list of { name, description?, required? }
|
||
handler = handler,
|
||
}
|
||
self:notify_prompts_changed()
|
||
return self
|
||
end
|
||
|
||
-- Run a prompt handler under pcall, normalise the return to the spec
|
||
-- shape { description?, messages = [{ role, content = { type, text } }] }.
|
||
-- Returns (table, nil) on success, (nil, err_msg) on failure.
|
||
local function _get_prompt(entry, args)
|
||
local ok, result = pcall(entry.handler, args or {})
|
||
if not ok then
|
||
return nil, "prompt handler error: " .. tostring(result)
|
||
end
|
||
if result == nil then
|
||
return nil, "prompt handler returned no content"
|
||
end
|
||
if type(result) == "string" then
|
||
return {
|
||
description = entry.description,
|
||
messages = {{
|
||
role = "user",
|
||
content = { type = "text", text = result },
|
||
}},
|
||
}
|
||
end
|
||
if type(result) == "table" and type(result.messages) == "table" then
|
||
return result
|
||
end
|
||
return nil, "prompt handler returned unsupported shape (expected string or { messages = … })"
|
||
end
|
||
|
||
-- Register a completion handler for a prompt/resource-template argument
|
||
-- (MCP issue #7). ref_type ∈ {"ref/prompt", "ref/resource"}; ref_id is
|
||
-- the prompt name or the resource-template uriTemplate. fn signature:
|
||
-- fn(value, ctx) → list of candidate strings (server filters / sorts as
|
||
-- it likes; spec allows up to 100). ctx mirrors the spec context object
|
||
-- (currently { arguments = {...} } of previously-completed sibling args).
|
||
function lmcp:complete(ref_type, ref_id, arg_name, fn)
|
||
if ref_type ~= "ref/prompt" and ref_type ~= "ref/resource" then
|
||
error("complete: ref_type must be 'ref/prompt' or 'ref/resource'")
|
||
end
|
||
if type(ref_id) ~= "string" or ref_id == "" then
|
||
error("complete: ref_id required")
|
||
end
|
||
if type(arg_name) ~= "string" or arg_name == "" then
|
||
error("complete: arg_name required")
|
||
end
|
||
if type(fn) ~= "function" then
|
||
error("complete: fn required")
|
||
end
|
||
self.completions[ref_type .. ":" .. ref_id .. ":" .. arg_name] = fn
|
||
return self
|
||
end
|
||
|
||
-- Log severity ordering (RFC 5424). Lower index = more severe.
|
||
local LOG_LEVELS = {
|
||
emergency = 1, alert = 2, critical = 3, error = 4,
|
||
warning = 5, notice = 6, info = 7, debug = 8,
|
||
}
|
||
|
||
-- Emit a structured log record. Below the client-set level → drop.
|
||
-- Today's delivery channel is stderr; once issue #16 lands the
|
||
-- bidirectional transport, this also enqueues notifications/message
|
||
-- for the client. data is free-form (string, table, etc.).
|
||
function lmcp:log(level, logger, data)
|
||
local lvl = LOG_LEVELS[level]
|
||
local thr = LOG_LEVELS[self._log_level] or LOG_LEVELS.warning
|
||
if not lvl or lvl > thr then return end -- below threshold; drop
|
||
-- stderr fallback: human-readable. The structured form goes on the
|
||
-- notifications queue for the future Streamable-HTTP delivery path
|
||
-- (issue #16). Cap + drop-oldest like list_changed.
|
||
io.stderr:write(string.format("lmcp[%s/%s]: %s\n",
|
||
level, tostring(logger or "-"),
|
||
type(data) == "string" and data or json.encode(data)))
|
||
if #self._notify_queue >= self._notify_cap then
|
||
table.remove(self._notify_queue, 1)
|
||
end
|
||
self._notify_queue[#self._notify_queue + 1] = {
|
||
jsonrpc = JSONRPC,
|
||
method = "notifications/message",
|
||
params = { level = level, logger = logger, data = data },
|
||
}
|
||
end
|
||
|
||
-- JSON-RPC response helpers
|
||
-- MCP 2026-07-28: "The result MUST include a resultType field to indicate the
|
||
-- type of the result." 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, 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 = 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
|
||
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
|
||
|
||
-- 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 {}
|
||
return jsonrpc_result_legacy(id, {
|
||
protocolVersion = MCP_VERSION,
|
||
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
|
||
|
||
-- 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
|
||
local entry = {
|
||
name = t.name,
|
||
description = t.description,
|
||
inputSchema = t.inputSchema,
|
||
}
|
||
-- Emit annotations / outputSchema only when registered. Empty
|
||
-- Lua tables would JSON-encode as [] (see
|
||
-- project_json_empty_table_gotcha memory) and break
|
||
-- spec-strict clients.
|
||
if t.annotations then entry.annotations = t.annotations end
|
||
if t.outputSchema then entry.outputSchema = t.outputSchema end
|
||
tool_list[#tool_list + 1] = entry
|
||
end
|
||
local page, next_cursor = paginate(tool_list, (req.params or {}).cursor)
|
||
local result = { tools = page }
|
||
if next_cursor then result.nextCursor = next_cursor end
|
||
return jsonrpc_result(id, result)
|
||
|
||
elseif method == "tools/call" then
|
||
local params = req.params or {}
|
||
local tool_name = params.name
|
||
local arguments = params.arguments or {}
|
||
local tool = self.tools[tool_name]
|
||
if not tool then
|
||
return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name))
|
||
end
|
||
-- ctx exposes the request's _meta (issue #13), the session_id
|
||
-- (issue #9 — handlers can call self:sample(ctx.session_id, …)),
|
||
-- progress() and cancelled() (issue #11), and a `server` back-ref
|
||
-- (so lmcp.current_ctx() can find the right server instance
|
||
-- without a singleton). Handlers that don't declare a second
|
||
-- parameter ignore it (Lua call discards extras).
|
||
local rid_str = tostring(id)
|
||
local ptoken = (params._meta or {}).progressToken -- nil if absent
|
||
local ctx
|
||
ctx = {
|
||
_meta = params._meta,
|
||
request_id = id,
|
||
session_id = req._session_id,
|
||
server = self,
|
||
-- progress(p, total?, message?): emits notifications/progress
|
||
-- on session's notify_q. No-op if client didn't supply a
|
||
-- progressToken. Type-checks; rejects non-numeric progress.
|
||
progress = function(p, total, message)
|
||
if ptoken == nil then return false end
|
||
if type(p) ~= "number" then return false end
|
||
if total ~= nil and type(total) ~= "number" then return false end
|
||
local sess = self._sessions[req._session_id]
|
||
if not sess then return false end
|
||
local np = { progressToken = ptoken, progress = p }
|
||
if total ~= nil then np.total = total end
|
||
if message ~= nil then np.message = tostring(message) end
|
||
sess.notify_q[#sess.notify_q + 1] = {
|
||
jsonrpc = JSONRPC, method = "notifications/progress",
|
||
params = np,
|
||
}
|
||
return true
|
||
end,
|
||
-- cancelled(): true if a notifications/cancelled for this
|
||
-- request id has been received.
|
||
cancelled = function()
|
||
return self._cancelled_ids[rid_str] == true
|
||
end,
|
||
}
|
||
|
||
-- Register on the currently-running coroutine so lmcp.current_ctx()
|
||
-- (and thus server.lua:run()'s auto-cancel) can find this ctx.
|
||
-- Pure-Lua handlers also get this registration; harmless.
|
||
local co = coroutine.running()
|
||
if co ~= nil then _ctx_by_co[co] = ctx end
|
||
|
||
-- Pre-handler cancellation short-circuit (Phase 5 review fix #9).
|
||
-- If a notifications/cancelled landed for this id before dispatch
|
||
-- reached here, skip the handler entirely. _finalise_dispatch
|
||
-- will see `not result` and suppress the response.
|
||
if self._cancelled_ids[rid_str] then
|
||
return nil
|
||
end
|
||
|
||
local ok, result = pcall(tool.handler, arguments, ctx)
|
||
if ok then
|
||
local resp = { isError = false }
|
||
local meta_out
|
||
if type(result) == "string" then
|
||
resp.content = {{ type = "text", text = result }}
|
||
elseif type(result) == "table" and result.type then
|
||
-- Typed content block (e.g. image). No structured emission.
|
||
resp.content = { result }
|
||
elseif type(result) == "table" then
|
||
-- Issue #13: extract response _meta before mirroring as
|
||
-- structuredContent, so server metadata doesn't leak into
|
||
-- the structured payload.
|
||
meta_out = result._meta
|
||
local clean = result
|
||
if meta_out ~= nil then
|
||
clean = {}
|
||
for k, v in pairs(result) do
|
||
if k ~= "_meta" then clean[k] = v end
|
||
end
|
||
end
|
||
resp.content = {{ type = "text", text = json.encode(clean) }}
|
||
resp.structuredContent = clean
|
||
else
|
||
resp.content = {{ type = "text", text = tostring(result) }}
|
||
end
|
||
if meta_out ~= nil then resp._meta = meta_out end
|
||
return jsonrpc_result(id, resp)
|
||
else
|
||
return jsonrpc_result(id, {
|
||
content = {{ type = "text", text = "Error: " .. tostring(result) }},
|
||
isError = true,
|
||
})
|
||
end
|
||
|
||
elseif method == "resources/list" then
|
||
local out = {}
|
||
for _, r in pairs(self.resources) do
|
||
out[#out + 1] = {
|
||
uri = r.uri,
|
||
name = r.name,
|
||
description = r.description,
|
||
mimeType = r.mimeType,
|
||
}
|
||
end
|
||
local page, next_cursor = paginate(out, (req.params or {}).cursor)
|
||
local result = { resources = page }
|
||
if next_cursor then result.nextCursor = next_cursor end
|
||
return jsonrpc_result(id, result)
|
||
|
||
elseif method == "resources/templates/list" then
|
||
local out = {}
|
||
for _, t in ipairs(self.resource_templates) do
|
||
out[#out + 1] = {
|
||
uriTemplate = t.uriTemplate,
|
||
name = t.name,
|
||
description = t.description,
|
||
mimeType = t.mimeType,
|
||
}
|
||
end
|
||
local page, next_cursor = paginate(out, (req.params or {}).cursor)
|
||
local result = { resourceTemplates = page }
|
||
if next_cursor then result.nextCursor = next_cursor end
|
||
return jsonrpc_result(id, result)
|
||
|
||
elseif method == "resources/read" then
|
||
local params = req.params or {}
|
||
local uri = params.uri
|
||
if type(uri) ~= "string" or uri == "" then
|
||
return jsonrpc_error(id, -32602, "uri required (string)")
|
||
end
|
||
local entry, args = _resolve_resource(self, uri)
|
||
if not entry then
|
||
-- MCP 2026-07-28: "Implementations of this protocol version MUST
|
||
-- NOT emit these codes: -32002 — resource not found … replaced by
|
||
-- -32602." 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
|
||
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
|
||
-- 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
|
||
return jsonrpc_error(id, -32603, err)
|
||
end
|
||
return jsonrpc_result(id, result)
|
||
|
||
elseif method == "completion/complete" then
|
||
local params = req.params or {}
|
||
local ref = params.ref or {}
|
||
local arg = params.argument or {}
|
||
local ref_id = ref.name or ref.uri or ref.uriTemplate or ""
|
||
if type(ref.type) ~= "string" or ref_id == ""
|
||
or type(arg.name) ~= "string" then
|
||
return jsonrpc_error(id, -32602,
|
||
"ref.type, ref.name/uri/uriTemplate, and argument.name required")
|
||
end
|
||
local fn = self.completions[ref.type .. ":" .. ref_id .. ":" .. arg.name]
|
||
if not fn then
|
||
-- No completer registered → return empty values (spec-allowed;
|
||
-- clients typically render no suggestions and let the user type).
|
||
return jsonrpc_result(id, {
|
||
completion = { values = {}, hasMore = false },
|
||
})
|
||
end
|
||
local value = arg.value or ""
|
||
local ok, values = pcall(fn, value, params.context or {})
|
||
if not ok then
|
||
return jsonrpc_error(id, -32603,
|
||
"completion handler error: " .. tostring(values))
|
||
end
|
||
if type(values) ~= "table" then
|
||
return jsonrpc_error(id, -32603,
|
||
"completion handler must return a table of strings")
|
||
end
|
||
-- Spec cap: at most 100 values per response. If more, truncate
|
||
-- and set hasMore=true so the client knows there's more.
|
||
local total = #values
|
||
local has_more = false
|
||
if total > 100 then
|
||
local out = {}
|
||
for i = 1, 100 do out[i] = values[i] end
|
||
values = out
|
||
has_more = true
|
||
end
|
||
return jsonrpc_result(id, {
|
||
completion = { values = values, total = total, hasMore = has_more },
|
||
})
|
||
|
||
else
|
||
return jsonrpc_error(id, -32601, "Method not found: " .. tostring(method))
|
||
end
|
||
end
|
||
|
||
-- ---- HTTP Server (raw sockets) ----
|
||
|
||
local function parse_http_request(client)
|
||
-- Read request line
|
||
local line, err = client:receive('*l')
|
||
if not line then return nil, err end
|
||
|
||
local method, path, version = line:match('^(%S+)%s+(%S+)%s+(%S+)')
|
||
if not method then return nil, 'bad request line' end
|
||
|
||
-- Read headers
|
||
local headers = {}
|
||
while true do
|
||
line, err = client:receive('*l')
|
||
if not line or line == '' then break end
|
||
local k, v = line:match('^(%S+):%s*(.*)')
|
||
if k then headers[k:lower()] = v end
|
||
end
|
||
|
||
-- Read body
|
||
local body = ''
|
||
local content_length = tonumber(headers['content-length'] or 0)
|
||
if content_length > 0 then
|
||
body, err = client:receive(content_length)
|
||
if not body then return nil, err end
|
||
end
|
||
|
||
return {
|
||
method = method,
|
||
path = path,
|
||
version = version,
|
||
headers = headers,
|
||
body = body,
|
||
}
|
||
end
|
||
|
||
local function send_response(client, status, headers, body)
|
||
local parts = { string.format('HTTP/1.1 %s', status) }
|
||
headers['Content-Length'] = tostring(#body)
|
||
headers['Connection'] = 'close'
|
||
for k, v in pairs(headers) do
|
||
parts[#parts + 1] = k .. ': ' .. v
|
||
end
|
||
parts[#parts + 1] = ''
|
||
parts[#parts + 1] = body
|
||
client:send(table.concat(parts, '\r\n'))
|
||
end
|
||
|
||
local function send_sse_event(client, data)
|
||
client:send('event: message\r\ndata: ' .. data .. '\r\n\r\n')
|
||
end
|
||
|
||
-- ---- Streamable HTTP transport (MCP issue #16) ----
|
||
--
|
||
-- select()-based single-thread event loop. All sockets non-blocking.
|
||
-- Per-connection FSM: reading_head → reading_body → dispatching → writing | sse_open.
|
||
--
|
||
-- Session model: each session has a Mcp-Session-Id; at most one open
|
||
-- SSE stream (the GET /mcp connection). Server-initiated requests
|
||
-- (sampling, roots — issues #9/#10) ride on the SSE stream and await
|
||
-- client responses via subsequent POSTs.
|
||
--
|
||
-- Queue routing:
|
||
-- self._notify_queue (global): list_changed, log messages → fans out
|
||
-- to ALL open sse_conn (broadcast).
|
||
-- sess.notify_q (per-session): server-initiated requests → only that
|
||
-- session's sse_conn.
|
||
--
|
||
-- write_buf discipline: append-only via `..`; consume via :sub(offset+1)
|
||
-- after partial-send. NEVER reorder or rewrite past bytes.
|
||
|
||
local READ_BUF_CAP = 64 * 1024 -- 64 KiB for header section
|
||
local BODY_CAP = 8 * 1024 * 1024 -- 8 MiB for request body
|
||
local WRITE_BUF_CAP = 1 * 1024 * 1024 -- 1 MiB per-conn write buffer
|
||
local HEARTBEAT_SEC = 30
|
||
local SESSION_IDLE_SEC = 60
|
||
local SELECT_TIMEOUT = 0.1
|
||
|
||
local function _new_session_id()
|
||
return string.format("%d-%09d", os.time(), math.random(0, 999999999))
|
||
end
|
||
|
||
local function _http_status_line(status)
|
||
return "HTTP/1.1 " .. status .. "\r\n"
|
||
end
|
||
|
||
local function _http_header_block(headers)
|
||
local parts = {}
|
||
for k, v in pairs(headers) do
|
||
parts[#parts + 1] = k .. ": " .. v
|
||
end
|
||
parts[#parts + 1] = "" -- blank line
|
||
parts[#parts + 1] = "" -- trailing CRLF
|
||
return table.concat(parts, "\r\n")
|
||
end
|
||
|
||
local function _queue_write(conn, s)
|
||
-- Append-only. If cap exceeded, evict the connection.
|
||
if #conn.write_buf + #s > WRITE_BUF_CAP then
|
||
conn.state = "closing"
|
||
return false
|
||
end
|
||
conn.write_buf = conn.write_buf .. s
|
||
return true
|
||
end
|
||
|
||
local function _build_http_response(status, headers, body, session_id)
|
||
headers = headers or {}
|
||
headers["Content-Length"] = tostring(#body)
|
||
headers["Connection"] = "close"
|
||
if session_id then headers["Mcp-Session-Id"] = session_id end
|
||
return _http_status_line(status) .. _http_header_block(headers) .. body
|
||
end
|
||
|
||
local function _build_sse_headers(session_id)
|
||
local h = {
|
||
["Content-Type"] = "text/event-stream",
|
||
["Cache-Control"] = "no-cache",
|
||
["Connection"] = "keep-alive",
|
||
["Access-Control-Allow-Origin"] = "*",
|
||
}
|
||
if session_id then h["Mcp-Session-Id"] = session_id end
|
||
return _http_status_line("200 OK") .. _http_header_block(h)
|
||
end
|
||
|
||
-- Format a JSON-RPC payload as one SSE message event.
|
||
local function _sse_event(payload_str)
|
||
return "event: message\r\ndata: " .. payload_str .. "\r\n\r\n"
|
||
end
|
||
|
||
-- Format a server-initiated JSON-RPC request from the notification queue
|
||
-- entry table { jsonrpc, id?, method, params? }.
|
||
local function _encode_notify(entry)
|
||
return json.encode(entry)
|
||
end
|
||
|
||
-- ---- Per-connection FSM helpers ----
|
||
|
||
local function _parse_request_head(conn)
|
||
-- Look for \r\n\r\n. If found, parse request line + headers.
|
||
local sep = conn.buf:find("\r\n\r\n", 1, true)
|
||
if not sep then return false end -- not complete yet
|
||
local head = conn.buf:sub(1, sep - 1)
|
||
conn.buf = conn.buf:sub(sep + 4) -- preserve any body bytes already buffered
|
||
|
||
local lines = {}
|
||
for line in head:gmatch("[^\r\n]+") do lines[#lines + 1] = line end
|
||
if #lines == 0 then return nil, "empty head" end
|
||
|
||
local method, path, version = lines[1]:match("^(%S+)%s+(%S+)%s+(%S+)")
|
||
if not method then return nil, "bad request line" end
|
||
conn.method, conn.path, conn.version = method, path, version
|
||
|
||
local headers = {}
|
||
for i = 2, #lines do
|
||
local k, v = lines[i]:match("^(%S+):%s*(.*)")
|
||
if k then headers[k:lower()] = v end
|
||
end
|
||
conn.headers = headers
|
||
conn.body_remain = tonumber(headers["content-length"] or 0) or 0
|
||
if conn.body_remain > BODY_CAP then
|
||
return nil, "body too large"
|
||
end
|
||
return true
|
||
end
|
||
|
||
local function _check_auth(self, conn)
|
||
if not self._auth_token then return true end
|
||
if conn.method == "OPTIONS" then return true end
|
||
local auth = conn.headers["authorization"] or ""
|
||
local token = auth:match("^[Bb]earer%s+(.+)$")
|
||
return token == self._auth_token
|
||
end
|
||
|
||
-- ---- Session lookup / create ----
|
||
|
||
-- Resolve session by id. Returns the session table on success, or
|
||
-- (nil, "unknown") if `sid` is non-nil but no such session exists
|
||
-- (spec: 400/404 — caller decides). With nil `sid` (sessionless POST,
|
||
-- backwards compat), auto-issues a fresh session.
|
||
local function _resolve_session(self, sid)
|
||
if sid then
|
||
local sess = self._sessions[sid]
|
||
if not sess then return nil, "unknown" end
|
||
sess.last_activity = os.time()
|
||
return sess
|
||
end
|
||
local new_id = _new_session_id()
|
||
self._sessions[new_id] = {
|
||
id = new_id,
|
||
sse_conn = nil,
|
||
pending = {}, -- req_id → on_response
|
||
notify_q = {}, -- per-session, server-initiated requests
|
||
created = os.time(),
|
||
last_activity = os.time(),
|
||
}
|
||
return self._sessions[new_id]
|
||
end
|
||
|
||
-- For `initialize` specifically, always mint a new session id regardless
|
||
-- of any client-provided header (the spec lets the server choose).
|
||
local function _create_session(self)
|
||
local new_id = _new_session_id()
|
||
self._sessions[new_id] = {
|
||
id = new_id,
|
||
sse_conn = nil,
|
||
pending = {},
|
||
notify_q = {},
|
||
created = os.time(),
|
||
last_activity = os.time(),
|
||
}
|
||
return self._sessions[new_id]
|
||
end
|
||
|
||
-- Scan all sessions for a pending server-initiated request matching id.
|
||
-- Returns (session, callback) or nil.
|
||
local function _find_pending(self, req_id)
|
||
for _, sess in pairs(self._sessions) do
|
||
local cb = sess.pending[req_id]
|
||
if cb then return sess, cb end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
-- ---- Dispatch a fully-parsed POST body ----
|
||
|
||
-- Forward declarations: used by _dispatch_post, defined below.
|
||
local _drive_handler_co
|
||
local _finalise_dispatch
|
||
|
||
-- MCP 2026-07-28 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
|
||
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
|
||
|
||
-- 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
|
||
-- - 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 ""
|
||
-- 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(status,
|
||
{ ["Content-Type"] = "application/json",
|
||
["Access-Control-Allow-Origin"] = "*" },
|
||
result, session_id)
|
||
end
|
||
|
||
local function _dispatch_options(conn)
|
||
local acrh = conn.headers["access-control-request-headers"]
|
||
return _build_http_response("204 No Content", {
|
||
["Access-Control-Allow-Origin"] = "*",
|
||
["Access-Control-Allow-Methods"] = "GET, POST, DELETE, OPTIONS",
|
||
-- '*' does NOT cover Authorization per CORS spec; list explicitly.
|
||
["Access-Control-Allow-Headers"] = acrh and (acrh .. ", Authorization")
|
||
or "Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version",
|
||
["Access-Control-Max-Age"] = "86400",
|
||
}, "", conn.session_id)
|
||
end
|
||
|
||
local function _dispatch_delete(self, conn)
|
||
if not conn.session_id or not self._sessions[conn.session_id] then
|
||
return _build_http_response("404 Not Found",
|
||
{ ["Content-Type"] = "text/plain",
|
||
["Access-Control-Allow-Origin"] = "*" },
|
||
"Session not found", nil)
|
||
end
|
||
local sess = self._sessions[conn.session_id]
|
||
if sess.sse_conn then
|
||
sess.sse_conn.state = "closing"
|
||
sess.sse_conn = nil
|
||
end
|
||
self._sessions[conn.session_id] = nil
|
||
return _build_http_response("204 No Content",
|
||
{ ["Access-Control-Allow-Origin"] = "*" }, "", nil)
|
||
end
|
||
|
||
-- ---- Main loop helpers ----
|
||
|
||
local function _conn_read(self, conn)
|
||
local chunk, err, partial = conn.sock:receive(8192)
|
||
local data = chunk or partial or ""
|
||
if data ~= "" then
|
||
if #conn.buf + #data > READ_BUF_CAP and conn.state == "reading_head" then
|
||
-- Header section too large.
|
||
conn.write_buf = _build_http_response("431 Request Header Fields Too Large",
|
||
{ ["Content-Type"] = "text/plain" }, "Headers too large", nil)
|
||
conn.state = "writing"
|
||
return
|
||
end
|
||
conn.buf = conn.buf .. data
|
||
end
|
||
if err == "closed" then
|
||
conn.state = "closing"
|
||
return
|
||
end
|
||
-- Advance FSM.
|
||
if conn.state == "reading_head" then
|
||
local ok, perr = _parse_request_head(conn)
|
||
if perr then
|
||
conn.write_buf = _build_http_response("400 Bad Request",
|
||
{ ["Content-Type"] = "text/plain" }, tostring(perr), nil)
|
||
conn.state = "writing"
|
||
return
|
||
end
|
||
if not ok then return end -- still waiting for full head
|
||
|
||
-- Headers parsed: auth check, session resolve.
|
||
if not _check_auth(self, conn) then
|
||
conn.write_buf = _build_http_response("401 Unauthorized",
|
||
{ ["Content-Type"] = "application/json",
|
||
["WWW-Authenticate"] = "Bearer" },
|
||
'{"error":"unauthorized"}', nil)
|
||
conn.state = "writing"
|
||
return
|
||
end
|
||
-- Session resolution happens at dispatch time (after body is read),
|
||
-- because `initialize` always mints a fresh session and we need to
|
||
-- know the method to distinguish 404 (unknown id, non-initialize)
|
||
-- from "auto-issue on initialize". Just stash the requested id.
|
||
conn.requested_sid = conn.headers["mcp-session-id"]
|
||
|
||
if conn.body_remain > 0 then
|
||
-- Any body bytes already in conn.buf land here.
|
||
conn.body = conn.buf:sub(1, conn.body_remain)
|
||
conn.buf = conn.buf:sub(#conn.body + 1)
|
||
conn.body_remain = conn.body_remain - #conn.body
|
||
conn.state = (conn.body_remain == 0) and "dispatching" or "reading_body"
|
||
else
|
||
conn.state = "dispatching"
|
||
end
|
||
end
|
||
if conn.state == "reading_body" then
|
||
if #conn.buf > 0 then
|
||
local take = math.min(conn.body_remain, #conn.buf)
|
||
conn.body = conn.body .. conn.buf:sub(1, take)
|
||
conn.buf = conn.buf:sub(take + 1)
|
||
conn.body_remain = conn.body_remain - take
|
||
end
|
||
if conn.body_remain == 0 then conn.state = "dispatching" end
|
||
end
|
||
if conn.state == "dispatching" then
|
||
local path = conn.path or ""
|
||
if not path:match("^/mcp") then
|
||
conn.write_buf = _build_http_response("404 Not Found",
|
||
{ ["Content-Type"] = "text/plain",
|
||
["Access-Control-Allow-Origin"] = "*" },
|
||
"Not Found", nil)
|
||
conn.state = "writing"
|
||
return
|
||
end
|
||
if conn.method == "OPTIONS" then
|
||
conn.write_buf = _dispatch_options(conn)
|
||
conn.state = "writing"
|
||
elseif conn.method == "GET" or conn.method == "DELETE" then
|
||
-- MCP 2026-07-28: „HTTP GET or DELETE to the MCP endpoint: respond
|
||
-- with 405 Method Not Allowed." 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 == "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
|