Initial release: Lua MCP server library

Zero-dependency MCP (Model Context Protocol) server in pure Lua.
Only requires luasocket. 2MB RSS vs Python FastMCP's 97MB.

- json.lua: pure Lua JSON encoder/decoder (~150 lines)
- lmcp.lua: MCP server with streamable-http transport (~230 lines)
- example_server.lua: shell/file tools demo

Implements MCP 2025-03-26: initialize, tools/list, tools/call,
notifications/initialized, ping. JSON-RPC 2.0. SSE support. CORS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 15:54:25 +00:00
commit 2bd661a8c9
3 changed files with 600 additions and 0 deletions
+218
View File
@@ -0,0 +1,218 @@
-- 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 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 hex = s:sub(pos + 1, pos + 4)
parts[#parts + 1] = utf8.char(tonumber(hex, 16))
pos = pos + 5
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 })
-- 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