Files
lmcp/lmcp.lua
T
test0r 6bf0f450dc Security hardening: body size limit, JSON depth limit, timing-safe auth
- Add MAX_BODY_SIZE (64KB) check before reading body — prevents pre-auth
  OOM on internet-facing deployments
- Add JSON nesting depth limit (64 levels) — prevents C stack overflow
  that bypasses pcall and crashes the process
- Timing-safe token comparison via XOR accumulate — prevents timing
  oracle on Bearer token
- Auth token from LMCP_TOKEN env var (highest priority) — avoids storing
  token in a file readable by the read_file tool
- Silent handling of unknown JSON-RPC notifications (spec compliance)
- Exact path matching on /mcp endpoint (was prefix-based)
- Remove dead json.array() function

Findings from architecture review + security audit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:45:16 +02:00

364 lines
12 KiB
Lua

-- lmcp.lua — Lightweight MCP server in pure Lua
-- Zero external dependencies (uses built-in socket or luasocket)
-- SPDX-License-Identifier: MIT
local json = require('json')
local lmcp = {}
lmcp.__index = lmcp
-- Read auth token from config file if present
local function read_conf(path)
local conf = {}
local f = io.open(path, 'r')
if not f then return conf end
for line in f:lines() do
local k, v = line:match('^%s*(%S+)%s*=%s*(.-)%s*$')
if k and not k:match('^#') then conf[k] = v end
end
f:close()
return conf
end
-- Protocol constants
-- Constant-time string comparison (prevents timing oracle on auth token)
local function constant_time_eq(a, b)
if type(a) ~= "string" or type(b) ~= "string" then return false end
if #a ~= #b then return false end -- length leak is acceptable (token length is not secret)
local diff = 0
for i = 1, #a do
diff = diff + (a:byte(i) ~ b:byte(i)) -- Lua 5.4 bitwise XOR
end
return diff == 0
end
local MCP_VERSION = "2025-03-26"
local JSONRPC = "2.0"
local MAX_BODY_SIZE = 65536 -- 64KB, generous for MCP JSON-RPC
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
-- Auth: explicit opt > conf file > nil (no auth)
if os.getenv("LMCP_TOKEN") then
self._auth_token = os.getenv("LMCP_TOKEN")
elseif opts.auth_token then
self._auth_token = opts.auth_token
elseif opts.conf then
local conf = read_conf(opts.conf)
self._auth_token = conf['.godparticle']
end
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
-- Unknown notifications must be silently ignored (JSON-RPC spec)
if id == nil then return nil end
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 > MAX_BODY_SIZE then
return nil, "body too large"
end
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 ''
-- 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 not constant_time_eq(token, self._auth_token) then
send_response(client, '401 Unauthorized',
{ ['Content-Type'] = 'application/json',
['WWW-Authenticate'] = 'Bearer' },
'{"error":"unauthorized"}')
client:close()
return
end
end
-- GET /mcp — SSE endpoint (for session establishment)
if req.method == 'GET' and (path:match('^/mcp$') or 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$') or 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, Authorization',
}, '')
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