deb73d129e
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>
243 lines
7.2 KiB
Lua
243 lines
7.2 KiB
Lua
-- lmcp/json.lua — Minimal JSON encoder/decoder, zero dependencies
|
|
-- SPDX-License-Identifier: MIT
|
|
|
|
local json = {}
|
|
|
|
-- Encode --
|
|
|
|
local encode_value
|
|
|
|
local escape_chars = {
|
|
['"'] = '\\"',
|
|
['\\'] = '\\\\',
|
|
['\b'] = '\\b',
|
|
['\f'] = '\\f',
|
|
['\n'] = '\\n',
|
|
['\r'] = '\\r',
|
|
['\t'] = '\\t',
|
|
}
|
|
|
|
local function encode_string(s)
|
|
return '"' .. s:gsub('[%z\1-\31"\\]', function(c)
|
|
return escape_chars[c] or string.format('\\u%04x', c:byte())
|
|
end) .. '"'
|
|
end
|
|
|
|
local function encode_array(t)
|
|
local parts = {}
|
|
for i = 1, #t do
|
|
parts[i] = encode_value(t[i])
|
|
end
|
|
return '[' .. table.concat(parts, ',') .. ']'
|
|
end
|
|
|
|
local function encode_object(t)
|
|
local parts = {}
|
|
for k, v in pairs(t) do
|
|
if type(k) == 'string' then
|
|
parts[#parts + 1] = encode_string(k) .. ':' .. encode_value(v)
|
|
end
|
|
end
|
|
return '{' .. table.concat(parts, ',') .. '}'
|
|
end
|
|
|
|
local function is_array(t)
|
|
if type(t) ~= 'table' then return false end
|
|
local n = #t
|
|
if n == 0 then
|
|
-- empty table: check if it has any keys
|
|
return next(t) == nil
|
|
end
|
|
for k in pairs(t) do
|
|
if type(k) ~= 'number' or k < 1 or k > n or k ~= math.floor(k) then
|
|
return false
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
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
|
|
if v ~= v then return 'null' end -- NaN
|
|
if v == math.huge or v == -math.huge then return 'null' end
|
|
if v == math.floor(v) and v >= -2^53 and v <= 2^53 then
|
|
return string.format('%.0f', v)
|
|
end
|
|
return tostring(v)
|
|
elseif t == 'string' then
|
|
return encode_string(v)
|
|
elseif t == 'table' then
|
|
if is_array(v) then
|
|
return encode_array(v)
|
|
else
|
|
return encode_object(v)
|
|
end
|
|
else
|
|
return 'null'
|
|
end
|
|
end
|
|
|
|
function json.encode(v)
|
|
return encode_value(v)
|
|
end
|
|
|
|
-- Decode --
|
|
|
|
local decode_value
|
|
local ws_chars = { [' '] = true, ['\t'] = true, ['\n'] = true, ['\r'] = true }
|
|
|
|
local function skip_ws(s, pos)
|
|
while pos <= #s and ws_chars[s:sub(pos, pos)] do
|
|
pos = pos + 1
|
|
end
|
|
return pos
|
|
end
|
|
|
|
local function decode_string(s, pos)
|
|
-- pos is at opening quote
|
|
pos = pos + 1
|
|
local parts = {}
|
|
while pos <= #s do
|
|
local c = s:sub(pos, pos)
|
|
if c == '"' then
|
|
return table.concat(parts), pos + 1
|
|
elseif c == '\\' then
|
|
pos = pos + 1
|
|
c = s:sub(pos, pos)
|
|
if c == 'u' then
|
|
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
|
|
pos = pos + 1
|
|
end
|
|
else
|
|
local next_special = s:find('["\\]', pos)
|
|
if next_special then
|
|
parts[#parts + 1] = s:sub(pos, next_special - 1)
|
|
pos = next_special
|
|
else
|
|
parts[#parts + 1] = s:sub(pos)
|
|
break
|
|
end
|
|
end
|
|
end
|
|
error('unterminated string')
|
|
end
|
|
|
|
local function decode_number(s, pos)
|
|
local start = pos
|
|
if s:sub(pos, pos) == '-' then pos = pos + 1 end
|
|
while pos <= #s and s:sub(pos, pos):match('[%d%.eE%+%-]') do
|
|
pos = pos + 1
|
|
end
|
|
local n = tonumber(s:sub(start, pos - 1))
|
|
if not n then error('invalid number at ' .. start) end
|
|
return n, pos
|
|
end
|
|
|
|
local function decode_array(s, pos)
|
|
pos = pos + 1 -- skip [
|
|
local arr = {}
|
|
pos = skip_ws(s, pos)
|
|
if s:sub(pos, pos) == ']' then return arr, pos + 1 end
|
|
while true do
|
|
local val
|
|
val, pos = decode_value(s, pos)
|
|
arr[#arr + 1] = val
|
|
pos = skip_ws(s, pos)
|
|
local c = s:sub(pos, pos)
|
|
if c == ']' then return arr, pos + 1 end
|
|
if c ~= ',' then error('expected , or ] at ' .. pos) end
|
|
pos = skip_ws(s, pos + 1)
|
|
end
|
|
end
|
|
|
|
local function decode_object(s, pos)
|
|
pos = pos + 1 -- skip {
|
|
local obj = {}
|
|
pos = skip_ws(s, pos)
|
|
if s:sub(pos, pos) == '}' then return obj, pos + 1 end
|
|
while true do
|
|
pos = skip_ws(s, pos)
|
|
if s:sub(pos, pos) ~= '"' then error('expected string key at ' .. pos) end
|
|
local key
|
|
key, pos = decode_string(s, pos)
|
|
pos = skip_ws(s, pos)
|
|
if s:sub(pos, pos) ~= ':' then error('expected : at ' .. pos) end
|
|
pos = skip_ws(s, pos + 1)
|
|
local val
|
|
val, pos = decode_value(s, pos)
|
|
obj[key] = val
|
|
pos = skip_ws(s, pos)
|
|
local c = s:sub(pos, pos)
|
|
if c == '}' then return obj, pos + 1 end
|
|
if c ~= ',' then error('expected , or } at ' .. pos) end
|
|
pos = pos + 1
|
|
end
|
|
end
|
|
|
|
decode_value = function(s, pos)
|
|
pos = skip_ws(s, pos)
|
|
local c = s:sub(pos, pos)
|
|
if c == '"' then return decode_string(s, pos)
|
|
elseif c == '{' then return decode_object(s, pos)
|
|
elseif c == '[' then return decode_array(s, pos)
|
|
elseif c == 't' then
|
|
if s:sub(pos, pos + 3) == 'true' then return true, pos + 4 end
|
|
elseif c == 'f' then
|
|
if s:sub(pos, pos + 4) == 'false' then return false, pos + 5 end
|
|
elseif c == 'n' then
|
|
if s:sub(pos, pos + 3) == 'null' then return json.null, pos + 4 end
|
|
elseif c == '-' or c:match('%d') then
|
|
return decode_number(s, pos)
|
|
end
|
|
error('unexpected character at ' .. pos .. ': ' .. c)
|
|
end
|
|
|
|
function json.decode(s)
|
|
local val, pos = decode_value(s, 1)
|
|
return val
|
|
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 })
|
|
end
|
|
|
|
return json
|