From deb73d129e3d010b3df2dd71b981bf0484b12f0a Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sun, 17 May 2026 17:15:54 +0000 Subject: [PATCH] v1.0.0-rc1: full MCP 2025-06-18 surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- example_server.lua | 116 +++- hub.lua | 72 ++- json.lua | 28 +- lmcp.lua | 1387 +++++++++++++++++++++++++++++++++++++++----- server.lua | 710 ++++++++++++++++++++++- 5 files changed, 2135 insertions(+), 178 deletions(-) diff --git a/example_server.lua b/example_server.lua index b644a52..caa5efc 100644 --- a/example_server.lua +++ b/example_server.lua @@ -11,6 +11,10 @@ local server = lmcp.new("example-tools", { port = tonumber(arg[1]) or 8080, }) +-- The optional 5th `opts` arg to server:tool carries MCP annotations. +-- Omit it and clients assume the worst (destructive, openWorld) — fine +-- for prototypes; declare annotations once you know each tool's stance. + server:tool("shell", "Execute a shell command", { type = "object", properties = { @@ -24,7 +28,15 @@ server:tool("shell", "Execute a shell command", { local result = handle:read('*a') handle:close() return result ~= '' and result or '(no output)' -end) +end, { + annotations = { + title = "Run shell", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = true, + }, +}) server:tool("read_file", "Read a file", { type = "object", @@ -38,7 +50,15 @@ server:tool("read_file", "Read a file", { local content = f:read('*a') f:close() return content -end) +end, { + annotations = { + title = "Read file", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = false, + }, +}) server:tool("write_file", "Write content to a file", { type = "object", @@ -53,7 +73,15 @@ server:tool("write_file", "Write content to a file", { f:write(args.content) f:close() return string.format("Written %d bytes to %s", #args.content, args.path) -end) +end, { + annotations = { + title = "Write file", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = true, + openWorldHint = false, + }, +}) server:tool("list_dir", "List directory contents", { type = "object", @@ -67,7 +95,85 @@ server:tool("list_dir", "List directory contents", { local result = handle:read('*a') handle:close() return result +end, { + annotations = { + title = "List directory", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = false, + }, +}) + +-- ---- Resources (MCP primitive — see issue #5) ---- +-- Tools-only servers force the client to spend a tools/call round-trip +-- for every read. Resources let the client list and read by URI, with a +-- stable identity it can cache and reference in prompts. + +server:resource("text://greeting", { + name = "Greeting", + mimeType = "text/plain", +}, function() return "Hello from lmcp!" end) + +-- Tiny binary resource: 8-byte PNG signature, demonstrates blob handling. +server:resource("data://lmcp.png", { + name = "PNG signature", + mimeType = "image/png", +}, function() + return { blob_bytes = "\x89PNG\r\n\x1a\n", mimeType = "image/png" } end) -io.stderr:write("Starting lmcp example server...\n") -server:run() +-- Template: any local file. `args.path` is captured greedily (no leading +-- slash because the template literal already includes ///). +server:resource_template("file:///{path}", { + name = "Local file", + mimeType = "text/plain", +}, function(args) + local f = io.open("/" .. args.path, "r") + if not f then error("file not found: /" .. args.path) end + local content = f:read("*a"); f:close() + return content +end) + +-- ---- Prompts (MCP primitive — see issue #6) ---- +-- Parameterised prompt templates the client surfaces as a menu +-- (slash-commands, snippets). Handler returns either a plain string (one +-- user-role text message) or a full { description?, messages = {...} } +-- shape for finer control. + +server:prompt("release_note", { + description = "Draft a release note for a given version", + arguments = { + { name = "version", description = "Tag, e.g. v0.7.1", required = true }, + { name = "since", description = "Previous tag", required = false }, + }, +}, function(args) + return "Write concise release notes for version " .. (args.version or "?") + .. " since " .. (args.since or "the previous tag") + .. ". Group by category (features / fixes / docs)." +end) + +-- Completion for the release_note prompt's `version` argument. Returned +-- list is filtered against `value` (prefix match) by the server's spec +-- contract is "candidates"; clients may further filter. +server:complete("ref/prompt", "release_note", "version", function(value, ctx) + local all = { "v0.5.0", "v0.5.1", "v0.5.2", "v0.5.3", "v0.5.4", + "v0.6.0", "v0.7.0", "v0.7.1", "v1.0.0-rc1" } + if value == "" then return all end + local out = {} + for _, v in ipairs(all) do + if v:sub(1, #value) == value then out[#out + 1] = v end + end + return out +end) + +local transport = os.getenv("LMCP_TRANSPORT") or "http" +if transport == "stdio" then + if os.getenv("LMCP_PORT") then + io.stderr:write("lmcp: LMCP_PORT ignored in stdio mode\n") + end + server:run_stdio() +else + io.stderr:write("Starting lmcp example server...\n") + server:run() +end diff --git a/hub.lua b/hub.lua index 2f839cf..ab774c5 100644 --- a/hub.lua +++ b/hub.lua @@ -505,7 +505,14 @@ server:tool("remote_list_hosts", ) end return table.concat(lines, "\n") - end + end, + { annotations = { + title = "List fleet hosts", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = true, + } } ) server:tool("remote_shell", "Run a shell command on a fleet host. lmcp-primary with ssh fallback.", @@ -515,7 +522,14 @@ server:tool("remote_shell", "Run a shell command on a fleet host. lmcp-primary w cwd = { type = "string", description = "Working directory" }, timeout = { type = "integer", description = "Timeout (seconds)", default = 120 }, }, required = { "host", "command" } }, - function(a) return call_remote("shell", a, true, ssh_shell) end + function(a) return call_remote("shell", a, true, ssh_shell) end, + { annotations = { + title = "Remote shell", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = true, + } } ) server:tool("remote_read_file", "Read a file from a fleet host.", @@ -523,7 +537,14 @@ server:tool("remote_read_file", "Read a file from a fleet host.", host = HOST_ARG, path = { type = "string", description = "File path" }, }, required = { "host", "path" } }, - function(a) return call_remote("read_file", a, true, ssh_read_file) end + function(a) return call_remote("read_file", a, true, ssh_read_file) end, + { annotations = { + title = "Remote read file", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = true, + } } ) server:tool("remote_write_file", "Write content to a file on a fleet host.", @@ -532,7 +553,14 @@ server:tool("remote_write_file", "Write content to a file on a fleet host.", path = { type = "string" }, content = { type = "string" }, }, required = { "host", "path", "content" } }, - function(a) return call_remote("write_file", a, true, ssh_write_file) end + function(a) return call_remote("write_file", a, true, ssh_write_file) end, + { annotations = { + title = "Remote write file", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = true, + openWorldHint = true, + } } ) server:tool("remote_edit_file", @@ -544,7 +572,14 @@ server:tool("remote_edit_file", new_string = { type = "string" }, replace_all = { type = "boolean", default = false }, }, required = { "host", "path", "old_string", "new_string" } }, - function(a) return call_remote("edit_file", a, false, nil) end + function(a) return call_remote("edit_file", a, false, nil) end, + { annotations = { + title = "Remote edit file", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = true, + } } ) server:tool("remote_shell_bg", @@ -555,7 +590,14 @@ server:tool("remote_shell_bg", cwd = { type = "string" }, log = { type = "string", description = "Log file path" }, }, required = { "host", "command" } }, - function(a) return call_remote("shell_bg", a, false, nil) end + function(a) return call_remote("shell_bg", a, false, nil) end, + { annotations = { + title = "Remote shell (background)", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = true, + } } ) server:tool("remote_list_dir", "List directory entries on a fleet host.", @@ -563,7 +605,14 @@ server:tool("remote_list_dir", "List directory entries on a fleet host.", host = HOST_ARG, path = { type = "string", default = "." }, }, required = { "host" } }, - function(a) return call_remote("list_dir", a, true, ssh_list_dir) end + function(a) return call_remote("list_dir", a, true, ssh_list_dir) end, + { annotations = { + title = "Remote list directory", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = true, + } } ) server:tool("remote_search_files", "find-by-pattern on a fleet host.", @@ -572,7 +621,14 @@ server:tool("remote_search_files", "find-by-pattern on a fleet host.", pattern = { type = "string" }, path = { type = "string", default = "/" }, }, required = { "host", "pattern" } }, - function(a) return call_remote("search_files", a, true, ssh_search_files) end + function(a) return call_remote("search_files", a, true, ssh_search_files) end, + { annotations = { + title = "Remote find files", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = true, + } } ) io.stderr:write(string.format("lmcp-hub starting on port %d with %d backends from %s\n", diff --git a/json.lua b/json.lua index 6f2eb28..272510f 100644 --- a/json.lua +++ b/json.lua @@ -60,6 +60,13 @@ encode_value = function(v) local t = type(v) if v == nil or v == json.null then return 'null' + elseif v == json.empty_object then + -- Sentinel for forcing {} (object) instead of [] (array) when + -- the field semantically requires an object but is empty. + -- Without this, every empty Lua table goes through is_array() + -- and emits as [], breaking spec-strict JSON-RPC consumers + -- (e.g. ping result, MUST be {}). + return '{}' elseif t == 'boolean' then return v and 'true' or 'false' elseif t == 'number' then @@ -110,9 +117,20 @@ local function decode_string(s, pos) pos = pos + 1 c = s:sub(pos, pos) if c == 'u' then - local hex = s:sub(pos + 1, pos + 4) - parts[#parts + 1] = utf8.char(tonumber(hex, 16)) + local cp = tonumber(s:sub(pos + 1, pos + 4), 16) pos = pos + 5 + -- Combine UTF-16 surrogate pair so non-BMP chars (emoji, + -- supplementary CJK) decode correctly instead of as two + -- lone surrogates → invalid UTF-8. + if cp and cp >= 0xD800 and cp <= 0xDBFF + and s:sub(pos, pos + 1) == "\\u" then + local lo = tonumber(s:sub(pos + 2, pos + 5), 16) + if lo and lo >= 0xDC00 and lo <= 0xDFFF then + cp = (cp - 0xD800) * 0x400 + (lo - 0xDC00) + 0x10000 + pos = pos + 6 + end + end + parts[#parts + 1] = utf8.char(cp) else local esc = { n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' } parts[#parts + 1] = esc[c] or c @@ -210,6 +228,12 @@ end -- Sentinel for JSON null json.null = setmetatable({}, { __tostring = function() return 'null' end }) +-- Sentinel for an empty JSON object ({}). Use when a field semantically +-- requires an object but is empty — e.g. `ping` result, MCP _meta = {}. +-- Without this, an empty Lua table goes through is_array() → '[]'. +-- See memory project_json_empty_table_gotcha.md. +json.empty_object = setmetatable({}, { __tostring = function() return '{}' end }) + -- Helper: encode a table as a JSON array even if empty function json.array(t) return setmetatable(t or {}, { __is_array = true }) diff --git a/lmcp.lua b/lmcp.lua index b04b24e..8dc7075 100644 --- a/lmcp.lua +++ b/lmcp.lua @@ -21,7 +21,7 @@ local function read_conf(path) end -- Protocol constants -local MCP_VERSION = "2025-03-26" +local MCP_VERSION = "2025-06-18" local JSONRPC = "2.0" function lmcp.new(name, opts) @@ -32,6 +32,48 @@ function lmcp.new(name, opts) 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 @@ -48,17 +90,303 @@ function lmcp.new(name, opts) return self end --- Register a tool -function lmcp:tool(name, description, params_schema, handler) +-- Cursor pagination helper for list methods (MCP issue #12). Cursor is +-- an opaque base64 string per spec; we use it to encode the next offset. +-- Page size default 50 covers every plausible lmcp deployment today; +-- larger registered sets are still handled correctly. +local _PAGE_SIZE = 50 + +local function paginate(items, cursor) + local n = #items + -- Decode incoming cursor. Malformed → start from 0. + local offset = 0 + if type(cursor) == "string" and cursor ~= "" then + local mime_ok, mime = pcall(require, "mime") + if mime_ok then + local decoded = mime.unb64(cursor) or "" + local parsed = tonumber(decoded) + if parsed and parsed >= 0 and parsed <= n then + offset = math.floor(parsed) + end + end + end + local page = {} + local stop = math.min(offset + _PAGE_SIZE, n) + for i = offset + 1, stop do page[#page + 1] = items[i] end + local next_cursor + if stop < n then + local mime_ok, mime = pcall(require, "mime") + if mime_ok then + next_cursor = (mime.b64(tostring(stop)) or ""):gsub("[\r\n]", "") + end + end + return page, next_cursor +end + +-- Register a tool. +-- opts (optional, 5th arg): +-- annotations = { title?, readOnlyHint?, destructiveHint?, +-- idempotentHint?, openWorldHint? } +-- outputSchema = -- shape of structuredContent (issue #13) +-- Handler signature: function(args, ctx) where ctx = { _meta = … } from +-- the request. ctx is optional — existing 1-arg handlers keep working. +-- Handler return shapes: +-- string → single text content block (no structured) +-- { type = "...", ... } → typed content block (image/etc.); no structured +-- table without `type` → JSON-encoded into text content AND mirrored as +-- structuredContent (issue #13; spec-strict clients get first-class +-- structured access) +function lmcp:tool(name, description, params_schema, handler, opts) 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 }) @@ -77,35 +405,76 @@ 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 = { - tools = { listChanged = false }, - }, + capabilities = caps, serverInfo = { name = self.name, version = self.version, }, }) - elseif method == "notifications/initialized" then - return nil -- notification, no response - elseif method == "ping" then - return jsonrpc_result(id, {}) + return jsonrpc_result(id, json.empty_object) elseif method == "tools/list" then local tool_list = {} for _, t in pairs(self.tools) do - tool_list[#tool_list + 1] = { + 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 - return jsonrpc_result(id, { tools = tool_list }) + 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 {} @@ -115,19 +484,43 @@ function lmcp:handle_request(req) if not tool then return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name)) end - local ok, result = pcall(tool.handler, arguments) + -- 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 content + local resp = { isError = false } + local meta_out if type(result) == "string" then - content = {{ type = "text", text = result }} + resp.content = {{ type = "text", text = result }} elseif type(result) == "table" and result.type then - content = { result } + -- Typed content block (e.g. image). No structured emission. + resp.content = { result } elseif type(result) == "table" then - content = {{ type = "text", text = json.encode(result) }} + -- 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 - content = {{ type = "text", text = tostring(result) }} + resp.content = {{ type = "text", text = tostring(result) }} end - return jsonrpc_result(id, { content = content, isError = false }) + 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) }}, @@ -135,6 +528,134 @@ function lmcp:handle_request(req) }) 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 @@ -192,161 +713,723 @@ local function send_sse_event(client, data) client:send('event: message\r\ndata: ' .. data .. '\r\n\r\n') end -function lmcp:serve_request(client) - client:settimeout(5) - local req, err = parse_http_request(client) - if not req then - client:close() +-- ---- 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 - - local path = req.path - local accept = req.headers['accept'] or '' - - -- Auth check (skip for OPTIONS, already handled above) - if self._auth_token and req.method ~= 'OPTIONS' then - local auth = req.headers['authorization'] or '' - local token = auth:match('^Bearer%s+(.+)$') - if token ~= self._auth_token then - send_response(client, '401 Unauthorized', - { ['Content-Type'] = 'application/json', - ['WWW-Authenticate'] = 'Bearer' }, - '{"error":"unauthorized"}') - client:close() + -- 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 - end + if not ok then return end -- still waiting for full head - -- GET /mcp — SSE endpoint (for session establishment) - if req.method == 'GET' and path:match('^/mcp') then - -- SSE stream — send headers and keep alive briefly - local sse_headers = { - 'HTTP/1.1 200 OK', - 'Content-Type: text/event-stream', - 'Cache-Control: no-cache', - 'Connection: keep-alive', - 'Access-Control-Allow-Origin: *', - } - client:send(table.concat(sse_headers, '\r\n') .. '\r\n\r\n') - - -- Send endpoint event pointing to POST /mcp - local endpoint_data = json.encode({ - endpoint = '/mcp', - sessionId = self._session_id or tostring(os.time()), - }) - client:send('event: endpoint\r\ndata: ' .. endpoint_data .. '\r\n\r\n') - - -- Keep connection open briefly for any SSE messages - client:settimeout(0.1) - -- In a full implementation we'd keep this open for server-initiated messages - -- For now, the POST endpoint handles request-response - client:close() - return - end - - -- POST /mcp — JSON-RPC endpoint - if req.method == 'POST' and path:match('^/mcp') then - if req.body == '' then - send_response(client, '400 Bad Request', - { ['Content-Type'] = 'application/json' }, - jsonrpc_error(nil, -32700, 'Empty body')) - client:close() + -- 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"] - local ok, rpc_req = pcall(json.decode, req.body) - if not ok then - send_response(client, '400 Bad Request', - { ['Content-Type'] = 'application/json' }, - jsonrpc_error(nil, -32700, 'Parse error')) - client:close() - return - end - - -- Handle request - local response = self:handle_request(rpc_req) - - if response then - -- Check if client accepts SSE - if accept:find('text/event%-stream') then - local sse_headers = { - 'HTTP/1.1 200 OK', - 'Content-Type: text/event-stream', - 'Cache-Control: no-cache', - 'Access-Control-Allow-Origin: *', - 'Connection: close', - } - client:send(table.concat(sse_headers, '\r\n') .. '\r\n\r\n') - send_sse_event(client, response) - else - send_response(client, '200 OK', - { ['Content-Type'] = 'application/json', - ['Access-Control-Allow-Origin'] = '*' }, - response) - end + 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 - -- Notification — no response body - send_response(client, '202 Accepted', - { ['Content-Type'] = 'application/json', - ['Access-Control-Allow-Origin'] = '*' }, - '') + conn.state = "dispatching" end - client:close() + 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 - - -- OPTIONS (CORS preflight). Echo back whatever the client asked for - -- (MCP adds e.g. Mcp-Session-Id, Mcp-Protocol-Version); fall back to *. - if req.method == 'OPTIONS' then - local acrh = req.headers and (req.headers['access-control-request-headers'] - or req.headers['Access-Control-Request-Headers']) - send_response(client, '204 No Content', { - ['Access-Control-Allow-Origin'] = '*', - ['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS', - -- CORS spec: '*' does NOT cover Authorization; must list it explicitly. - -- Echo back whatever the client requested plus Authorization. - ['Access-Control-Allow-Headers'] = acrh and (acrh .. ', Authorization') - or 'Content-Type, Accept, Authorization, Mcp-Session-Id, Mcp-Protocol-Version', - ['Access-Control-Max-Age'] = '86400', - }, '') - client:close() + 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 - -- Fallback - send_response(client, '404 Not Found', - { ['Content-Type'] = 'text/plain' }, - 'Not Found') - client:close() +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() - -- Try luasocket first, fall back to Lua 5.4+ built-in (if available) - local socket - local ok, sock = pcall(require, 'socket') - if ok then - socket = sock - else - error('luasocket required: install with "luarocks install luasocket" or your package manager') - end + 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 server = assert(socket.bind(self.host, self.port)) - server:settimeout(1) - - local addr, port = server:getsockname() + 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)) - local running = true - -- Handle SIGINT gracefully (Lua doesn't have signal handlers, - -- but the timeout-based accept loop means Ctrl+C works) - while running do - local client = server:accept() - if client then - local ok, err = pcall(self.serve_request, self, client) - if not ok then - io.stderr:write("lmcp: request error: " .. tostring(err) .. "\n") - pcall(client.close, client) + 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 diff --git a/server.lua b/server.lua index aa7438a..05fa311 100644 --- a/server.lua +++ b/server.lua @@ -168,7 +168,15 @@ server:tool("shell", "Execute a shell command.", { cmd = 'powershell -NoProfile -Command "' .. cmd:gsub('"', '\\"') .. '"' end return run(cmd, a.timeout or 120) -end) +end, { + annotations = { + title = "Run shell", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = true, + }, +}) server:tool("shell_bg", "Fire-and-forget shell command (Linux-only). Fully detaches via setsid+nohup+stdio-redirect and returns immediately with PID and log path. Use for daemons that must outlive the lmcp request.", @@ -211,7 +219,15 @@ server:tool("shell_bg", os.remove(pid_file) end return string.format("launched pid=%s log=%s", pid, log) - end) + end, { + annotations = { + title = "Run shell (background)", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = true, + }, + }) server:tool("read_file", "Read a file.", { type = "object", @@ -221,7 +237,15 @@ server:tool("read_file", "Read a file.", { local c = read_file(a.path) if not c then return "Error: could not read " .. a.path end return c -end) +end, { + annotations = { + title = "Read file", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = false, + }, +}) server:tool("write_file", "Write content to a file.", { type = "object", @@ -235,7 +259,15 @@ server:tool("write_file", "Write content to a file.", { if not f then return "Error: could not write " .. a.path end f:write(a.content); f:close() return string.format("Written %d bytes to %s", #a.content, a.path) -end) +end, { + annotations = { + title = "Write file", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = true, + openWorldHint = false, + }, +}) server:tool("edit_file", "Replace exact text in a file (literal match). Fails unless old_string is unique, unless replace_all=true.", { type = "object", @@ -289,7 +321,15 @@ server:tool("edit_file", "Replace exact text in a file (literal match). Fails un w:write(table.concat(parts)); w:close() return string.format("Edited %s: %d replacement(s)", a.path, replaced) -end) +end, { + annotations = { + title = "Edit file", + readOnlyHint = false, + destructiveHint = true, + idempotentHint = false, + openWorldHint = false, + }, +}) server:tool("list_dir", "List directory contents.", { type = "object", @@ -301,7 +341,254 @@ server:tool("list_dir", "List directory contents.", { else return run("ls -1 '" .. path:gsub("'", "'\\''") .. "'", 10) end -end) +end, { + annotations = { + title = "List directory", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = false, + }, +}) + +-- ---- fetch: HTTP GET/HEAD with bounded body and optional HTML→plain rendering ---- +-- +-- Contract (per Phase 4 plan, issue #3): +-- 1. Transfer cap is enforced by curl --max-filesize, not by post-hoc +-- slicing. curl aborts mid-stream with exit 63 and the body file +-- holds up-to-N bytes (verified Phase 0). +-- 2. Curl exit code is recovered via -w "exit=%{exitcode}\n" because +-- run() captures stdout-only. Line-anchored parsing because +-- run()'s 2>&1 merges curl's stderr into the same stream. +-- 3. ok = (exit == 0 or exit == 63). exit 63 is a deliberate +-- truncation, not a failure — set truncated=true and ok=true. +-- 4. URL whitelist (RFC-3986-ish) rejects whitespace, control chars, +-- both quote styles in one shot — no per-platform branching. +-- 5. Renderer chain (plain, text/html only): pandoc → lynx → w3m → +-- pure-Lua strip. Probe results are process-local cached. +-- 6. os.execute return shape differs between Lua 5.1/LuaJIT (number) +-- and Lua 5.4 (boolean,...). fetch_have normalises both. +-- 7. timeout_s covers fetch *and* render combined. + +local function fetch_html_strip(s) + if not s or s == "" then return "" end + s = s:gsub(".-", " ") + s = s:gsub(".-", " ") + s = s:gsub("", " ") + s = s:gsub("<[^>]+>", " ") + local ents = { amp = "&", lt = "<", gt = ">", quot = '"', apos = "'", nbsp = " " } + s = s:gsub("&(%a+);", function(n) return ents[n] or ("&" .. n .. ";") end) + s = s:gsub("&#(%d+);", function(n) return string.char(tonumber(n)) end) + s = s:gsub("&#x(%x+);", function(n) return string.char(tonumber(n, 16)) end) + s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + return s +end + +local _fetch_have_cache = {} +local function fetch_have(cmd) + local cached = _fetch_have_cache[cmd] + if cached ~= nil then return cached end + local probe + if WINDOWS then + probe = "where " .. cmd .. " >NUL 2>&1" + else + probe = "command -v " .. cmd .. " >/dev/null 2>&1" + end + local rc = os.execute(probe) + if type(rc) == "number" then rc = (rc == 0) end + rc = rc and true or false + _fetch_have_cache[cmd] = rc + return rc +end + +local function fetch_safe_url(url) + if type(url) ~= "string" or url == "" then + return false, "url required" + end + if not url:match("^https?://") then + return false, "url scheme must be http or https" + end + if not url:match("^https?://[%w%-._~:/?#%[%]@!%$&()*+,;=%%]+$") then + return false, "url contains disallowed characters (whitespace, quote, control)" + end + return true +end + +local function fetch_parse_kv(blob) + local out = {} + for line in blob:gmatch("[^\r\n]+") do + local k, v = line:match("^(http_code)=(.*)$") + if k then out[k] = v end + k, v = line:match("^(content_type)=(.*)$") + if k then out[k] = v end + k, v = line:match("^(size_download)=(.*)$") + if k then out[k] = v end + k, v = line:match("^(exit)=(.*)$") + if k then out[k] = v end + end + return out +end + +local function fetch_render_plain(body, body_file) + -- Try external renderers in order; each receives body_file on stdin. + local order = { "pandoc", "lynx", "w3m" } + for _, r in ipairs(order) do + if fetch_have(r) then + local cmd + if r == "pandoc" then + cmd = "pandoc -f html -t plain" + elseif r == "lynx" then + cmd = "lynx -stdin -dump -nolist -force_html" + else -- w3m + cmd = "w3m -dump -T text/html" + end + local pipe + if WINDOWS then + pipe = cmd .. ' < "' .. body_file .. '"' + else + pipe = cmd .. " < '" .. body_file:gsub("'", "'\\''") .. "'" + end + local out = run(pipe, 15) + if out and out ~= "" and not out:match("^Error:") then + return out, r + end + end + end + return fetch_html_strip(body), "lua-strip" +end + +server:tool("fetch", + "HTTP GET/HEAD with bounded body and optional HTML→plain rendering. " .. + "timeout_s covers the entire fetch+render combined.", + { + type = "object", + properties = { + url = { type = "string", description = "http(s) URL" }, + method = { type = "string", description = "GET or HEAD", default = "GET" }, + render = { type = "string", description = "plain | html | raw", default = "plain" }, + max_bytes = { type = "integer", description = "Hard cap on body bytes returned", default = 65536 }, + timeout_s = { type = "integer", description = "Wall-clock cap for entire call", default = 20 }, + user_agent = { type = "string", description = "Custom User-Agent", default = "lmcp-fetch/1.0" }, + }, + required = { "url" }, + }, + function(a) + local ok_url, url_err = fetch_safe_url(a.url) + if not ok_url then + return { ok = false, status = 0, content_type = "", bytes_read = 0, + truncated = false, renderer = "raw", body = "", error = url_err } + end + + local method = (a.method or "GET"):upper() + if method ~= "GET" and method ~= "HEAD" then + return { ok = false, status = 0, content_type = "", bytes_read = 0, + truncated = false, renderer = "raw", body = "", + error = "method must be GET or HEAD" } + end + + local render = a.render or "plain" + local max_bytes = tonumber(a.max_bytes) or 65536 + local timeout_s = tonumber(a.timeout_s) or 20 + local ua = a.user_agent or "lmcp-fetch/1.0" + + local base = tmpname() + local hdr_file = base .. ".hdr" + local body_file = base .. ".body" + + local wfmt = "http_code=%{http_code}\\ncontent_type=%{content_type}\\nsize_download=%{size_download}\\nexit=%{exitcode}\\n" + + local curl_cmd + if WINDOWS then + local head_flag = (method == "HEAD") and " -I" or "" + curl_cmd = string.format( + 'curl -sS --proto =http,https%s -X %s --max-time %d --max-filesize %d -A "%s" -D "%s" -o "%s" -w "%s" "%s"', + head_flag, method, timeout_s, max_bytes, ua, hdr_file, body_file, wfmt, a.url + ) + else + local head_flag = (method == "HEAD") and " -I" or "" + curl_cmd = string.format( + "curl -sS --proto =http,https%s -X %s --max-time %d --max-filesize %d -A '%s' -D '%s' -o '%s' -w '%s' '%s'", + head_flag, method, timeout_s, max_bytes, ua, hdr_file, body_file, wfmt, a.url + ) + end + + local raw_out = run(curl_cmd, timeout_s + 5) or "" + local kv = fetch_parse_kv(raw_out) + local exit = tonumber(kv.exit or "") or -1 + local http_code = tonumber(kv.http_code or "0") or 0 + local content_type = kv.content_type or "" + + local body = "" + if method ~= "HEAD" then + local bf = io.open(body_file, 'rb') + if bf then body = bf:read('*a') or ""; bf:close() end + end + remove_silent(hdr_file) + remove_silent(body_file) + + -- Defensive cap (curl already capped, but enforce on the wire). + if #body > max_bytes then body = body:sub(1, max_bytes) end + local bytes_read = #body + local truncated = (exit == 63) + local transport_ok = (exit == 0 or exit == 63) + + if not transport_ok then + -- Strip the -w block from raw_out for a clean error message. + local err_msg = raw_out:gsub("http_code=[^\n]*\n?", "") + :gsub("content_type=[^\n]*\n?", "") + :gsub("size_download=[^\n]*\n?", "") + :gsub("exit=[^\n]*\n?", "") + :gsub("^%s+", ""):gsub("%s+$", "") + if err_msg == "" then err_msg = "curl exit " .. tostring(exit) end + return { ok = false, status = 0, content_type = content_type, + bytes_read = 0, truncated = false, renderer = "raw", + body = "", error = err_msg } + end + + local renderer, out_body + if render == "raw" or render == "html" or method == "HEAD" then + renderer, out_body = "raw", body + elseif render == "plain" then + local is_html = content_type:match("text/html") or content_type:match("xml") + if is_html and body ~= "" then + -- Re-materialise body to a temp for the renderer pipe. + local rf = tmpname() .. ".rbody" + local f = io.open(rf, 'wb') + if f then f:write(body); f:close() end + out_body, renderer = fetch_render_plain(body, rf) + remove_silent(rf) + else + renderer, out_body = "raw", body + end + else + return { ok = false, status = 0, content_type = content_type, + bytes_read = 0, truncated = false, renderer = "raw", + body = "", error = "render must be plain, html, or raw" } + end + + if #out_body > max_bytes then out_body = out_body:sub(1, max_bytes) end + + return { + ok = true, + status = http_code, + content_type = content_type, + bytes_read = bytes_read, + truncated = truncated, + renderer = renderer, + body = out_body, + } + end, { + annotations = { + title = "HTTP GET/HEAD", + readOnlyHint = true, + destructiveHint = false, + -- Idempotent in MCP sense: the tool itself has no effect on + -- its own environment. World-side variability is conveyed + -- by openWorldHint. + idempotentHint = true, + openWorldHint = true, + }, + }) server:tool("search_files", "Search for files by pattern.", { type = "object", @@ -320,14 +607,415 @@ server:tool("search_files", "Search for files by pattern.", { -- (common on Homebrew, e.g. /usr/local/share/lua -> Cellar/…/share/lua). return run("find -L '" .. path:gsub("'", "'\\''") .. "' -name '" .. a.pattern:gsub("'", "'\\''") .. "' 2>/dev/null", 30) end -end) +end, { + annotations = { + title = "Find files by pattern", + readOnlyHint = true, + destructiveHint = false, + idempotentHint = true, + openWorldHint = false, + }, +}) + +-- ---- web_search: pluggable-backend search with normalised result shape ---- +-- +-- Contract (per Phase 4 plan + Phase 5 review actions, issue #4): +-- 1. Backend selection: explicit LMCP_SEARCH_BACKEND (lower+trim) wins; +-- else first-present of SEARXNG_URL, TAVILY_API_KEY, BRAVE_API_KEY; +-- else "ddg" zero-config. +-- 2. Result envelope is always: +-- { ok, backend, query, results=[{title,url,snippet,age?}], error? } +-- On failure: ok=false, results=[], error=string. +-- 3. DDG is best-effort. The HTML endpoint serves anti-bot 202 pages +-- from many IP ranges; when the parser matches 0 results from a +-- 200/202, surface a structured "parser found 0" error rather +-- than a silent empty list. +-- 4. DDG parser iterates per-result-block, not per-class globally — +-- otherwise a missing snippet shifts later snippets onto wrong titles. +-- 5. DDG result URLs are unwrapped from /l/?uddg=. If +-- unwrap fails (no uddg= or non-http(s) result), the row is dropped. +-- 6. JSON backends (searxng/tavily/brave) use json.decode under pcall. +-- json.lua patched in this issue to combine UTF-16 surrogate pairs +-- so emoji/non-BMP CJK in snippets render correctly. +-- 7. Tavily uses Authorization: Bearer header, not body, so the +-- key never lands in a tempfile. +-- 8. URL query strings are RFC-3986 unreserved-only encoded. After +-- encoding, the only attacker-controlled portion is shell-safe +-- inside single quotes. + +local function ws_url_encode(s) + return (s:gsub("([^%w%-._~])", function(c) + return string.format("%%%02X", string.byte(c)) + end)) +end + +local function ws_url_decode(s) + s = s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end) + return s +end + +local function ws_ddg_unwrap(href) + -- href shape: //duckduckgo.com/l/?uddg=&rut= + -- & in raw HTML; pattern strips the entity first. + href = href:gsub("&", "&") + local enc = href:match("[?&]uddg=([^&]+)") + if not enc then return nil end + local decoded = ws_url_decode(enc) + if not decoded:match("^https?://") then return nil end + return decoded +end + +local function ws_safe_envurl(url) + if not url or url == "" then return false, "url empty" end + if not url:match("^https?://") then return false, "url scheme must be http(s)" end + if not url:match("^https?://[%w%-._~:/?#%[%]@!%$&()*+,;=%%]+$") then + return false, "url contains disallowed characters" + end + return true +end + +local function ws_safe_key(s) + if not s or s == "" then return false, "empty" end + if s:find("['\"\n\r]") then return false, "contains quote or newline" end + return true +end + +local function ws_curl_run(curl_cmd, body_file, timeout_s) + local raw_out = run(curl_cmd, timeout_s + 5) or "" + local http_code = tonumber(raw_out:match("http_code=(%d+)") or "0") or 0 + local exit = tonumber(raw_out:match("exit=(%-?%d+)") or "-1") or -1 + local body = "" + local bf = io.open(body_file, 'rb') + if bf then body = bf:read('*a') or ""; bf:close() end + remove_silent(body_file) + return body, http_code, exit, raw_out +end + +local function ws_curl_err(raw_out, http_code, exit, default) + local err = raw_out:gsub("http_code=[^\n]*\n?", "") + :gsub("exit=[^\n]*\n?", "") + :gsub("^%s+", ""):gsub("%s+$", "") + if err ~= "" then return err end + if http_code ~= 0 and http_code ~= 200 then + return string.format("HTTP %d", http_code) + end + return default or ("curl exit " .. tostring(exit)) +end + +-- ---- DDG (HTML scrape, zero-config) ---- +local function ws_ddg(query, n, region, time_range, safesearch) + local kp = ({off = -2, moderate = -1, strict = 1})[safesearch] or -1 + local df = ({day = "d", week = "w", month = "m", year = "y"})[time_range or ""] or "" + local url = "https://html.duckduckgo.com/html/?q=" .. ws_url_encode(query) + .. "&kp=" .. tostring(kp) + if df ~= "" then url = url .. "&df=" .. df end + if region and region ~= "" then url = url .. "&kl=" .. ws_url_encode(region) end + + local body_file = tmpname() .. ".body" + local wfmt = "http_code=%{http_code}\\nexit=%{exitcode}\\n" + local cmd + if WINDOWS then + cmd = string.format( + 'curl -sS --proto =https --max-time 15 -A "lmcp-search/1.0" -o "%s" -w "%s" "%s"', + body_file, wfmt, url) + else + cmd = string.format( + "curl -sS --proto =https --max-time 15 -A 'lmcp-search/1.0' -o '%s' -w '%s' '%s'", + body_file, wfmt, url) + end + local body, http_code, exit, raw = ws_curl_run(cmd, body_file, 15) + if exit ~= 0 then + return nil, ws_curl_err(raw, http_code, exit, "ddg request failed") + end + + -- Per-result-block iteration (avoids title↔snippet mispairing). + -- Split on the opening