From abd9db30f225e868c3cacbee2549623a2afd86b9 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 11 Apr 2026 19:15:27 +0200 Subject: [PATCH] Add Bearer auth, rewrite example server, add README - lmcp.lua: optional Bearer token auth via conf file or explicit token - lmcp.lua: CORS Authorization header allowed - example_server.lua: rewritten with non-blocking shell, file ops, search - README.md: usage, auth config, Claude Code integration, tool examples Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 109 +++++++++++++++++++++++++++++++++++++++ example_server.lua | 125 +++++++++++++++++++++++++++------------------ lmcp.lua | 37 +++++++++++++- 3 files changed, 221 insertions(+), 50 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..76b8ccc --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# lmcp — Lightweight MCP Server in Lua + +A minimal [Model Context Protocol](https://modelcontextprotocol.io/) server in pure Lua. ~350 lines, 2.4MB RSS, zero compiled dependencies beyond Lua + LuaSocket. + +## Features + +- MCP 2025-03-26 protocol (JSON-RPC over HTTP) +- Tool registration with JSON Schema validation +- Optional Bearer token authentication (config file or explicit) +- SSE and plain JSON response modes +- Non-blocking shell execution with timeout +- Runs on any Linux with Lua 5.4 + LuaSocket + +## Quick Start + +```sh +# Install dependencies (Debian/Ubuntu) +apt install lua5.4 lua-socket + +# Or use a self-contained archive (see releases) +tar xzf lmcp-linux-amd64.tar.gz +export PATH=$PWD/lmcp-linux-amd64/bin:$PATH + +# Run +lua5.4 example_server.lua +# lmcp: my-tools v0.1.0 listening on 0.0.0.0:8080/mcp +``` + +## Files + +| File | Description | +|------|-------------| +| `lmcp.lua` | Core library — HTTP server, JSON-RPC, MCP protocol, auth | +| `json.lua` | Minimal JSON encoder/decoder (bundled, no external dep) | +| `example_server.lua` | Example server with shell, read/write file, list/search tools | + +## Authentication + +To require a Bearer token, create an `lmcp.conf` next to your server: + +``` +.godparticle = your-secret-token-here +``` + +Then reference it when creating the server: + +```lua +local server = lmcp.new("my-tools", { + port = 8080, + conf = dir .. "lmcp.conf", +}) +``` + +Clients must send `Authorization: Bearer ` on every request. OPTIONS (CORS preflight) is exempt. + +You can also set the token directly: + +```lua +local server = lmcp.new("my-tools", { + auth_token = "my-secret-token", +}) +``` + +## Claude Code Integration + +Add to `~/.claude/settings.json`: + +```json +{ + "mcpServers": { + "my-host": { + "type": "url", + "url": "http://my-host:8080/mcp", + "headers": { + "Authorization": "Bearer your-token-here" + } + } + } +} +``` + +## Adding Tools + +```lua +server:tool("greet", "Say hello to someone.", { + type = "object", + properties = { + name = { type = "string", description = "Who to greet" }, + }, + required = { "name" }, +}, function(args) + return "Hello, " .. args.name .. "!" +end) +``` + +Tool handlers return a string (wrapped as text content) or a table (encoded as JSON). Errors thrown via `error()` are caught and returned as MCP error responses. + +## Self-Contained Archives + +Pre-built archives with Lua 5.4 + LuaSocket for Linux: + +- `lmcp-linux-amd64.tar.gz` — x86_64 +- `lmcp-linux-aarch64.tar.gz` — ARM64 (Pi 5, RK3588, etc.) + +These include the Lua interpreter and LuaSocket shared library so no system packages are needed. + +## License + +MIT diff --git a/example_server.lua b/example_server.lua index b644a52..94eb4bd 100644 --- a/example_server.lua +++ b/example_server.lua @@ -1,73 +1,100 @@ #!/usr/bin/env lua --- Example lmcp server — shell tools --- Usage: lua example_server.lua [port] +-- Example lmcp server — customize tools for your host +local dir = arg[0]:match("(.*/)") or "./" +package.path = package.path .. ";" .. dir .. "?.lua" +local lmcp = require("lmcp") -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, +local server = lmcp.new("my-tools", { + port = tonumber(os.getenv("LMCP_PORT")) or 8080, + -- Optional: Bearer token auth from config file + -- conf = dir .. "lmcp.conf", + -- Or set directly: + -- auth_token = "my-secret-token", }) -server:tool("shell", "Execute a shell command", { +-- Non-blocking shell execution with timeout +local function run(cmd, timeout_sec) + timeout_sec = timeout_sec or 120 + local base = os.tmpname() + local out_file = base .. ".out" + local done_file = base .. ".done" + local sh_cmd = string.format("(%s) > '%s' 2>&1; echo $? > '%s'", cmd, out_file, done_file) + os.execute("sh -c ' " .. sh_cmd .. " ' &") + local elapsed = 0 + local interval = 50 + while elapsed < timeout_sec * 1000 do + local f = io.open(done_file, "r") + if f then f:close(); break end + if interval < 1000 then + os.execute("sleep 0." .. string.format("%03d", interval)) + else + os.execute("sleep " .. math.ceil(interval / 1000)) + end + elapsed = elapsed + interval + if interval < 2000 then interval = math.floor(interval * 1.5) end + end + local f = io.open(out_file, "r") + local output = f and f:read("*a") or "" + if f then f:close() end + os.remove(out_file); os.remove(done_file); os.remove(base) + if elapsed >= timeout_sec * 1000 then return "Error: timed out after " .. timeout_sec .. "s" end + return output ~= "" and output or "(no output)" +end + +-- Register tools +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 }, + command = { type = "string" }, + cwd = { type = "string" }, + timeout = { type = "integer", default = 120 }, }, 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)' +}, function(a) + local c = a.command + if a.cwd then c = "cd \"" .. a.cwd .. "\" && " .. c end + return run(c, a.timeout or 120) end) -server:tool("read_file", "Read a file", { +server:tool("read_file", "Read a file.", { type = "object", - properties = { - path = { type = "string", description = "File path to read" }, - }, + properties = { path = { type = "string" } }, 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 +}, function(a) + local f = io.open(a.path, "r") + if not f then return "Error: could not read " .. a.path end + local c = f:read("*a"); f:close(); return c end) -server:tool("write_file", "Write content to a file", { +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" }, - }, + properties = { path = { type = "string" }, content = { type = "string" } }, 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) +}, function(a) + local f = io.open(a.path, "w") + 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) -server:tool("list_dir", "List directory contents", { +server:tool("list_dir", "List directory contents.", { + type = "object", + properties = { path = { type = "string", default = "." } }, +}, function(a) + return run("ls -la '" .. (a.path or ".") .. "'", 10) +end) + +server:tool("search_files", "Search for files by name.", { type = "object", properties = { - path = { type = "string", description = "Directory path", default = "." }, + pattern = { type = "string" }, + path = { type = "string", default = "/" }, + max_depth = { type = "integer", default = 4 }, }, -}, 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 + required = { "pattern" }, +}, function(a) + return run(string.format("find '%s' -maxdepth %d -name '%s' 2>/dev/null", + a.path or "/", a.max_depth or 4, a.pattern), 30) end) -io.stderr:write("Starting lmcp example server...\n") server:run() diff --git a/lmcp.lua b/lmcp.lua index 757bed1..f6f713d 100644 --- a/lmcp.lua +++ b/lmcp.lua @@ -7,6 +7,19 @@ 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 local MCP_VERSION = "2025-03-26" local JSONRPC = "2.0" @@ -20,6 +33,13 @@ function lmcp.new(name, opts) self.port = opts.port or 8080 self.tools = {} self._session_id = nil + -- Auth: explicit opt > conf file > nil (no auth) + if 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 @@ -178,6 +198,20 @@ function lmcp:serve_request(client) 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() + return + end + end + -- GET /mcp — SSE endpoint (for session establishment) if req.method == 'GET' and path:match('^/mcp') then -- SSE stream — send headers and keep alive briefly @@ -261,7 +295,7 @@ function lmcp:serve_request(client) send_response(client, '204 No Content', { ['Access-Control-Allow-Origin'] = '*', ['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS', - ['Access-Control-Allow-Headers'] = 'Content-Type, Accept', + ['Access-Control-Allow-Headers'] = 'Content-Type, Accept, Authorization', }, '') client:close() return @@ -307,3 +341,4 @@ function lmcp:run() end return lmcp +