Files
lmcp/lmcp.lua
T
test0r deb73d129e v1.0.0-rc1: full MCP 2025-06-18 surface
Closes 14 issues; lmcp now implements the complete client-facing
surface of MCP spec 2025-06-18.

New primitives:
  - fetch (#3)              HTTP GET/HEAD with bounded body + render chain
  - web_search (#4)         pluggable backend (SearXNG/DDG/Tavily/Brave)
  - Resources (#5)          resources/list, /read, /templates/list + list_changed
  - Prompts (#6)            prompts/list, /get + list_changed
  - Completion (#7)         completion/complete for prompt/template args
  - Logging (#8)            logging/setLevel + notifications/message
  - Sampling (#9)           server-initiated sampling/createMessage
  - Roots (#10)             roots/list + cache + path_in_roots helper

Protocol / wire:
  - Pagination (#12)        cursor on tools|resources|prompts/list
  - Structured tool output (#13)  structuredContent + _meta + protoV bump to 2025-06-18
  - Tool annotations (#14)  readOnlyHint/destructive/idempotent/openWorld on all tools
  - stdio transport (#15)   LMCP_TRANSPORT=stdio for Claude Desktop / IDE clients
  - Streamable HTTP (#16)   select()-based event loop, sessions, persistent SSE,
                            DELETE, heartbeat, server-initiated request helper
  - ping (#19)              now emits result:{} not result:[] via json.empty_object

Cross-cutting fixes:
  - json.lua: UTF-16 surrogate pair combination (emoji/non-BMP CJK round-trip)
  - json.lua: json.empty_object sentinel for spec-correct {} emission
  - handle_request: generic notification suppression (id==nil → return nil)
    eliminates malformed -32601 with id:null on stdio and HTTP transports

Tool annotations backfilled across all registrations:
  - server.lua: 10 tools (shell, shell_bg, read_file, write_file, edit_file,
    list_dir, search_files, fetch, web_search, systeminfo)
  - hub.lua:   8 remote_* tools
  - example_server.lua: 4 demo tools + 3 sample resources + 1 sample prompt
                        + 1 sample completer

Honest limits, filed as follow-up issues:
  - #11 progress + cancellation — gated on #20 (handler concurrency)
  - #18 windows/pkg sync         — stale April-2026 snapshot, packaging decision
  - #20 concurrent handler dispatch — select() loop concurrencies I/O, not
                                      handler execution; synchronous tool
                                      handlers still serialise (shell sleep 3
                                      blocks a parallel ping)

Backwards compatible: every previously-deployed lmcp client (sessionless
POST, HTTP-only, no Mcp-Session-Id awareness) keeps working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:54 +00:00

1439 lines
56 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
-- 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 = {}
-- 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 = {}
-- 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)
self.tools[name] = {
name = name,
description = description,
inputSchema = params_schema or { type = "object", properties = {} },
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
end
-- (Other client→server notifications: cancelled, message — no
-- action today; add side-effects here as needed.)
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) and the session_id
-- (issue #9 — so handlers can call self:sample(ctx.session_id, …)).
-- Handlers that don't declare a second parameter ignore it (Lua
-- call discards extras).
local ctx = {
_meta = params._meta,
request_id = id,
session_id = req._session_id,
}
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("^Bearer%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 ----
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
-- Normal client request / notification. Dispatch via handle_request.
local response = self:handle_request(rpc_req)
if not response then
-- Notification → 202 Accepted, no body.
return _build_http_response("202 Accepted",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
"", conn.session_id)
end
-- If client accepts SSE, respond as a single-event SSE stream.
-- Otherwise plain JSON body.
local accept = conn.headers["accept"] or ""
if accept:find("text/event%-stream") then
local hdrs = _build_sse_headers(conn.session_id)
return hdrs .. _sse_event(response)
end
return _build_http_response("200 OK",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
response, conn.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
conn.write_buf = _dispatch_post(self, conn)
conn.state = "writing"
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
-- ---- 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
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)
-- After draining, attempt immediate writes on conns whose write_buf
-- just got bytes (so list_changed / heartbeat appears 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