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:
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env lua
|
||||
-- Example lmcp server — shell tools
|
||||
-- Usage: lua example_server.lua [port]
|
||||
|
||||
local dir = arg[0]:match('(.*/)') or './'
|
||||
package.path = package.path .. ';' .. dir .. '?.lua'
|
||||
|
||||
local lmcp = require('lmcp')
|
||||
|
||||
local server = lmcp.new("example-tools", {
|
||||
port = tonumber(arg[1]) or 8080,
|
||||
})
|
||||
|
||||
server:tool("shell", "Execute a shell command", {
|
||||
type = "object",
|
||||
properties = {
|
||||
command = { type = "string", description = "Shell command to execute" },
|
||||
timeout = { type = "integer", description = "Timeout in seconds", default = 30 },
|
||||
},
|
||||
required = { "command" },
|
||||
}, function(args)
|
||||
local handle = io.popen(args.command .. ' 2>&1', 'r')
|
||||
if not handle then return "Error: could not execute command" end
|
||||
local result = handle:read('*a')
|
||||
handle:close()
|
||||
return result ~= '' and result or '(no output)'
|
||||
end)
|
||||
|
||||
server:tool("read_file", "Read a file", {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = { type = "string", description = "File path to read" },
|
||||
},
|
||||
required = { "path" },
|
||||
}, function(args)
|
||||
local f = io.open(args.path, 'r')
|
||||
if not f then return "Error: could not open " .. args.path end
|
||||
local content = f:read('*a')
|
||||
f:close()
|
||||
return content
|
||||
end)
|
||||
|
||||
server:tool("write_file", "Write content to a file", {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = { type = "string", description = "File path to write" },
|
||||
content = { type = "string", description = "Content to write" },
|
||||
},
|
||||
required = { "path", "content" },
|
||||
}, function(args)
|
||||
local f = io.open(args.path, 'w')
|
||||
if not f then return "Error: could not open " .. args.path .. " for writing" end
|
||||
f:write(args.content)
|
||||
f:close()
|
||||
return string.format("Written %d bytes to %s", #args.content, args.path)
|
||||
end)
|
||||
|
||||
server:tool("list_dir", "List directory contents", {
|
||||
type = "object",
|
||||
properties = {
|
||||
path = { type = "string", description = "Directory path", default = "." },
|
||||
},
|
||||
}, function(args)
|
||||
local path = args.path or '.'
|
||||
local handle = io.popen('ls -1 ' .. path:gsub("'", "'\\''") .. ' 2>&1')
|
||||
if not handle then return "Error: could not list " .. path end
|
||||
local result = handle:read('*a')
|
||||
handle:close()
|
||||
return result
|
||||
end)
|
||||
|
||||
io.stderr:write("Starting lmcp example server...\n")
|
||||
server:run()
|
||||
@@ -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
|
||||
@@ -0,0 +1,309 @@
|
||||
-- lmcp.lua — Lightweight MCP server in pure Lua
|
||||
-- Zero external dependencies (uses built-in socket or luasocket)
|
||||
-- SPDX-License-Identifier: MIT
|
||||
|
||||
local json = require('json')
|
||||
|
||||
local lmcp = {}
|
||||
lmcp.__index = lmcp
|
||||
|
||||
-- Protocol constants
|
||||
local MCP_VERSION = "2025-03-26"
|
||||
local JSONRPC = "2.0"
|
||||
|
||||
function lmcp.new(name, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({}, lmcp)
|
||||
self.name = name or "lmcp"
|
||||
self.version = opts.version or "0.1.0"
|
||||
self.host = opts.host or "0.0.0.0"
|
||||
self.port = opts.port or 8080
|
||||
self.tools = {}
|
||||
self._session_id = nil
|
||||
return self
|
||||
end
|
||||
|
||||
-- Register a tool
|
||||
function lmcp:tool(name, description, params_schema, handler)
|
||||
self.tools[name] = {
|
||||
name = name,
|
||||
description = description,
|
||||
inputSchema = params_schema or { type = "object", properties = {} },
|
||||
handler = handler,
|
||||
}
|
||||
return self
|
||||
end
|
||||
|
||||
-- JSON-RPC response helpers
|
||||
local function jsonrpc_result(id, result)
|
||||
return json.encode({ jsonrpc = JSONRPC, id = id, result = result })
|
||||
end
|
||||
|
||||
local function jsonrpc_error(id, code, message)
|
||||
return json.encode({
|
||||
jsonrpc = JSONRPC,
|
||||
id = id,
|
||||
error = { code = code, message = message },
|
||||
})
|
||||
end
|
||||
|
||||
-- Handle a single JSON-RPC request
|
||||
function lmcp:handle_request(req)
|
||||
local method = req.method
|
||||
local id = req.id -- nil for notifications
|
||||
|
||||
if method == "initialize" then
|
||||
self._session_id = self._session_id or tostring(os.time())
|
||||
return jsonrpc_result(id, {
|
||||
protocolVersion = MCP_VERSION,
|
||||
capabilities = {
|
||||
tools = { listChanged = false },
|
||||
},
|
||||
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, {})
|
||||
|
||||
elseif method == "tools/list" then
|
||||
local tool_list = {}
|
||||
for _, t in pairs(self.tools) do
|
||||
tool_list[#tool_list + 1] = {
|
||||
name = t.name,
|
||||
description = t.description,
|
||||
inputSchema = t.inputSchema,
|
||||
}
|
||||
end
|
||||
return jsonrpc_result(id, { tools = tool_list })
|
||||
|
||||
elseif method == "tools/call" then
|
||||
local params = req.params or {}
|
||||
local tool_name = params.name
|
||||
local arguments = params.arguments or {}
|
||||
local tool = self.tools[tool_name]
|
||||
if not tool then
|
||||
return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name))
|
||||
end
|
||||
local ok, result = pcall(tool.handler, arguments)
|
||||
if ok then
|
||||
local content
|
||||
if type(result) == "string" then
|
||||
content = {{ type = "text", text = result }}
|
||||
elseif type(result) == "table" and result.type then
|
||||
content = { result }
|
||||
elseif type(result) == "table" then
|
||||
content = {{ type = "text", text = json.encode(result) }}
|
||||
else
|
||||
content = {{ type = "text", text = tostring(result) }}
|
||||
end
|
||||
return jsonrpc_result(id, { content = content, isError = false })
|
||||
else
|
||||
return jsonrpc_result(id, {
|
||||
content = {{ type = "text", text = "Error: " .. tostring(result) }},
|
||||
isError = true,
|
||||
})
|
||||
end
|
||||
|
||||
else
|
||||
return jsonrpc_error(id, -32601, "Method not found: " .. tostring(method))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- HTTP Server (raw sockets) ----
|
||||
|
||||
local function parse_http_request(client)
|
||||
-- Read request line
|
||||
local line, err = client:receive('*l')
|
||||
if not line then return nil, err end
|
||||
|
||||
local method, path, version = line:match('^(%S+)%s+(%S+)%s+(%S+)')
|
||||
if not method then return nil, 'bad request line' end
|
||||
|
||||
-- Read headers
|
||||
local headers = {}
|
||||
while true do
|
||||
line, err = client:receive('*l')
|
||||
if not line or line == '' then break end
|
||||
local k, v = line:match('^(%S+):%s*(.*)')
|
||||
if k then headers[k:lower()] = v end
|
||||
end
|
||||
|
||||
-- Read body
|
||||
local body = ''
|
||||
local content_length = tonumber(headers['content-length'] or 0)
|
||||
if content_length > 0 then
|
||||
body, err = client:receive(content_length)
|
||||
if not body then return nil, err end
|
||||
end
|
||||
|
||||
return {
|
||||
method = method,
|
||||
path = path,
|
||||
version = version,
|
||||
headers = headers,
|
||||
body = body,
|
||||
}
|
||||
end
|
||||
|
||||
local function send_response(client, status, headers, body)
|
||||
local parts = { string.format('HTTP/1.1 %s', status) }
|
||||
headers['Content-Length'] = tostring(#body)
|
||||
headers['Connection'] = 'close'
|
||||
for k, v in pairs(headers) do
|
||||
parts[#parts + 1] = k .. ': ' .. v
|
||||
end
|
||||
parts[#parts + 1] = ''
|
||||
parts[#parts + 1] = body
|
||||
client:send(table.concat(parts, '\r\n'))
|
||||
end
|
||||
|
||||
local function send_sse_event(client, data)
|
||||
client:send('event: message\r\ndata: ' .. data .. '\r\n\r\n')
|
||||
end
|
||||
|
||||
function lmcp:serve_request(client)
|
||||
client:settimeout(5)
|
||||
local req, err = parse_http_request(client)
|
||||
if not req then
|
||||
client:close()
|
||||
return
|
||||
end
|
||||
|
||||
local path = req.path
|
||||
local accept = req.headers['accept'] or ''
|
||||
|
||||
-- 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()
|
||||
return
|
||||
end
|
||||
|
||||
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
|
||||
else
|
||||
-- Notification — no response body
|
||||
send_response(client, '202 Accepted',
|
||||
{ ['Content-Type'] = 'application/json',
|
||||
['Access-Control-Allow-Origin'] = '*' },
|
||||
'')
|
||||
end
|
||||
client:close()
|
||||
return
|
||||
end
|
||||
|
||||
-- OPTIONS (CORS preflight)
|
||||
if req.method == 'OPTIONS' then
|
||||
send_response(client, '204 No Content', {
|
||||
['Access-Control-Allow-Origin'] = '*',
|
||||
['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS',
|
||||
['Access-Control-Allow-Headers'] = 'Content-Type, Accept',
|
||||
}, '')
|
||||
client:close()
|
||||
return
|
||||
end
|
||||
|
||||
-- Fallback
|
||||
send_response(client, '404 Not Found',
|
||||
{ ['Content-Type'] = 'text/plain' },
|
||||
'Not Found')
|
||||
client:close()
|
||||
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 server = assert(socket.bind(self.host, self.port))
|
||||
server:settimeout(1)
|
||||
|
||||
local addr, port = server: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)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return lmcp
|
||||
Reference in New Issue
Block a user