Spaces:
Running on Zero
Running on Zero
Download mcp.lua from jeorgexyz/lua-agent-lab: direct link, hf CLI and curl.
- Browser
- Download file 15.3 kB
-
https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/mcp.lua
- Command line
-
hf download hf://spaces/jeorgexyz/lua-agent-lab/mcp.lua
-
curl -L -o mcp.lua https://huggingface.co/spaces/jeorgexyz/lua-agent-lab/resolve/main/mcp.lua
15.3 kB
| -- mcp.lua - A Model Context Protocol client, in pure Lua. | |
| -- | |
| -- MCP is JSON-RPC 2.0 over stdio. Three methods get you a working client: | |
| -- | |
| -- initialize handshake, exchange capabilities | |
| -- notifications/initialized client says it is ready (no reply) | |
| -- tools/list what the server offers | |
| -- tools/call run one | |
| -- | |
| -- That is the whole surface this needs. Mount a server's tools into the | |
| -- registry and the agent loop calls them exactly like the built-ins -- same | |
| -- validation, same approval gate, same "a failure is an observation" rule. | |
| -- | |
| -- lua54 main.lua "Echo hello" --backend ollama \ | |
| -- --mcp "npx -y @modelcontextprotocol/server-everything" | |
| -- | |
| -- | |
| -- TWO TRANSPORTS, AND WHY THERE ARE TWO | |
| -- | |
| -- Lua has no bidirectional pipes. io.popen opens a stream for reading OR | |
| -- writing, never both: | |
| -- | |
| -- io.popen("cat", "rw") --> bad argument #2 to 'popen' (invalid mode) | |
| -- | |
| -- A long-lived stdio session needs both halves at once, so it is out of | |
| -- reach without a C extension. The stdio transport here works around that: | |
| -- write the whole request sequence to a file, run the server with it as | |
| -- stdin, read what it writes before it exits on EOF. | |
| -- | |
| -- server < requests.jsonl > responses.jsonl | |
| -- | |
| -- Every call therefore re-spawns the server and re-runs the handshake. That | |
| -- is slow -- and worse, it is WRONG for any server holding state, because a | |
| -- re-spawned server loses the session, the open handle, the cursor. Speed | |
| -- is only the visible symptom. | |
| -- | |
| -- Streamable HTTP fixes both without leaving pure Lua: the server is | |
| -- long-lived because it is not ours to spawn, and curl only ever needs | |
| -- request/response. Measured against server-everything -- 3.4s -> 0.24s to | |
| -- discover, 3.4s -> 0.06s per call, one handshake instead of one per call. | |
| -- | |
| -- Prefer --mcp-url. Use stdio for servers that speak nothing else. | |
| -- | |
| -- | |
| -- TWO THINGS THE REAL SERVER TAUGHT THAT THE SPEC READS PAST | |
| -- | |
| -- 1. Responses are NOT in request order, and notifications are interleaved | |
| -- with them. Against server-everything, notifications/tools/list_changed | |
| -- arrived before the initialize result, and a tools/call for id 3 came | |
| -- back before id 2. Match on `id`; never on position. | |
| -- | |
| -- 2. A tool that fails returns a SUCCESSFUL JSON-RPC result carrying | |
| -- isError: true, with the message in `content`. It is not a JSON-RPC | |
| -- error object. Treating it as one loses the message the model needs -- | |
| -- and that message is genuinely good ("expected string, received | |
| -- undefined at message"), exactly the kind of observation this loop | |
| -- feeds back for recovery. | |
| local trace = require('trace') | |
| local tmpfile = require('tmpfile') | |
| local mcp = {} | |
| local Client = {} | |
| Client.__index = Client | |
| -- Bump when the servers you use require it; the handshake echoes back what | |
| -- the server actually chose, and mismatches surface there rather than here. | |
| mcp.PROTOCOL_VERSION = "2025-06-18" | |
| mcp.CLIENT_INFO = { name = "lua-agent", version = "0.1" } | |
| -- opts: | |
| -- command the server's launch command, e.g. | |
| -- "npx -y @modelcontextprotocol/server-everything" | |
| -- transport function(request_text) -> ok, response_text, code | |
| -- Swap in a persistent one, or a fixture for tests. | |
| -- protocol_version | |
| -- prefix namespace mounted tool names, e.g. "fs_" (see mcp.mount) | |
| function mcp.new(opts) | |
| opts = opts or {} | |
| if not opts.command and not opts.transport and not opts.url then | |
| error("mcp.new: needs a command, a url, or a transport", 0) | |
| end | |
| return setmetatable({ | |
| command = opts.command, | |
| url = opts.url, | |
| transport = opts.transport, | |
| protocol_version = opts.protocol_version or mcp.PROTOCOL_VERSION, | |
| prefix = opts.prefix, | |
| next_id = 0, | |
| }, Client) | |
| end | |
| function Client:id() | |
| self.next_id = self.next_id + 1 | |
| return self.next_id | |
| end | |
| -- Run the server over a file of newline-delimited JSON-RPC and collect what | |
| -- it writes. stderr goes to its own file: servers log startup banners there | |
| -- ("Starting default (STDIO) server...") and mixing them into stdout breaks | |
| -- the parse. | |
| function Client:stdio(request_text) | |
| local req_path, err = tmpfile.write(request_text, ".jsonl") | |
| if not req_path then return false, err, -1 end | |
| local out_path, errp = tmpfile.name(".out"), tmpfile.name(".err") | |
| local cmd = string.format('%s < "%s" > "%s" 2> "%s"', | |
| self.command, req_path, out_path, errp) | |
| local ok, _, code = os.execute(cmd) | |
| local raw = tmpfile.slurp(out_path) or "" | |
| local stderr = tmpfile.slurp(errp) or "" | |
| tmpfile.remove(req_path, out_path, errp) | |
| if raw == "" then | |
| -- A server that produced nothing failed to start. Its stderr is the | |
| -- only useful thing we have, so pass it through rather than | |
| -- reporting a bare exit code. | |
| return false, string.format( | |
| "MCP server produced no output (%s)%s", tostring(self.command), | |
| stderr ~= "" and (": " .. stderr:gsub("%s+$", ""):sub(1, 300)) or ""), code | |
| end | |
| return ok or true, raw, code | |
| end | |
| -- Streamable HTTP: POST one JSON-RPC message to a server that is already | |
| -- running. This is the transport that does NOT have the stdio problem -- | |
| -- the process is long-lived because it is not ours, and request/response | |
| -- over curl needs no bidirectional pipe. | |
| -- | |
| -- Two details the shape of this depends on: | |
| -- | |
| -- * The server issues an Mcp-Session-Id header on initialize. Echoing it | |
| -- back on later requests is what keeps one session alive across calls, | |
| -- which is the whole point: a re-spawned stdio server loses any state | |
| -- it was holding, and no amount of speed work fixes that. | |
| -- * Replies come back as SSE ("event: message" / "data: {...}"), not bare | |
| -- JSON, even for a single response. Hence the data: unwrapping in | |
| -- decode_stream below. | |
| function Client:http(request_text) | |
| local body_path, err = tmpfile.write(request_text, ".json") | |
| if not body_path then return false, err, -1 end | |
| local out_path, hdr_path = tmpfile.name(".out"), tmpfile.name(".hdr") | |
| local session = "" | |
| if self.session_id then | |
| session = string.format(' -H "mcp-session-id: %s"', self.session_id) | |
| end | |
| local cmd = string.format( | |
| 'curl -sS -X POST %s -D "%s" -H "content-type: application/json" ' | |
| .. '-H "accept: application/json, text/event-stream"%s -d @"%s" -o "%s"', | |
| self.url, hdr_path, session, body_path, out_path) | |
| local ok, _, code = os.execute(cmd) | |
| local raw = tmpfile.slurp(out_path) or "" | |
| local headers = tmpfile.slurp(hdr_path) or "" | |
| tmpfile.remove(body_path, out_path, hdr_path) | |
| -- Capture the session on the way past; the server only sends it once. | |
| if not self.session_id then | |
| self.session_id = headers:match("[Mm]cp%-[Ss]ession%-[Ii]d:%s*([^\r\n]+)") | |
| end | |
| if not ok and raw == "" then | |
| return false, string.format("cannot reach the MCP server at %s", self.url), code | |
| end | |
| return true, raw, code | |
| end | |
| -- Responses arrive either as newline-delimited JSON (stdio) or as SSE | |
| -- frames (HTTP). Both reduce to "find the JSON objects", so one reader | |
| -- handles them: lines that are not JSON -- SSE "event:"/"id:" fields, | |
| -- server banners on stdout -- are simply skipped. | |
| local function decode_stream(raw, by_id, notifications) | |
| for line in tostring(raw):gmatch("[^\n]+") do | |
| local payload = line:match("^data:%s*(.*)$") or line | |
| if payload:match("%S") then | |
| local msg = trace.decode(payload) | |
| if type(msg) == "table" then | |
| if msg.id ~= nil then | |
| by_id[msg.id] = msg | |
| elseif msg.method then | |
| notifications[#notifications + 1] = msg | |
| end | |
| end | |
| end | |
| end | |
| end | |
| -- Send messages and return responses keyed by id, plus any notifications. | |
| -- | |
| -- stdio batches everything into one stdin stream because the process only | |
| -- lives for the length of that stream. HTTP posts one message at a time | |
| -- against a session that outlives the call. | |
| function Client:exchange(messages) | |
| local by_id, notifications = {}, {} | |
| local function send(text) | |
| local ok, raw, code | |
| if self.transport then | |
| ok, raw, code = self.transport(text) | |
| elseif self.url then | |
| ok, raw, code = self:http(text) | |
| else | |
| ok, raw, code = self:stdio(text) | |
| end | |
| if not ok then | |
| error(string.format("MCP transport failed (%s): %s", | |
| tostring(code), tostring(raw):sub(1, 400)), 0) | |
| end | |
| decode_stream(raw, by_id, notifications) | |
| end | |
| if self.url and not self.transport then | |
| for _, m in ipairs(messages) do send(trace.encode(m)) end | |
| else | |
| local lines = {} | |
| for _, m in ipairs(messages) do lines[#lines + 1] = trace.encode(m) end | |
| send(table.concat(lines, "\n") .. "\n") | |
| end | |
| return by_id, notifications | |
| end | |
| function Client:initialize_msg() | |
| return { jsonrpc = "2.0", id = self:id(), method = "initialize", params = { | |
| protocolVersion = self.protocol_version, | |
| capabilities = trace.ordered({}, {}), | |
| clientInfo = mcp.CLIENT_INFO, | |
| } } | |
| end | |
| -- Messages to prepend to an exchange. | |
| -- | |
| -- Over stdio the server is re-spawned every time, so every exchange has to | |
| -- re-handshake -- there is no session to resume. Over HTTP the handshake | |
| -- happens once and the session id carries it forward, so this returns | |
| -- nothing after the first call. | |
| function Client:handshake() | |
| if self.url and self.session_ready then return {} end | |
| return { | |
| self:initialize_msg(), | |
| { jsonrpc = "2.0", method = "notifications/initialized" }, | |
| } | |
| end | |
| local function result_or_raise(msg, what) | |
| if not msg then | |
| error(string.format("MCP: no response to %s", what), 0) | |
| end | |
| if msg.error then | |
| error(string.format("MCP %s failed: %s (code %s)", what, | |
| tostring(msg.error.message), tostring(msg.error.code)), 0) | |
| end | |
| return msg.result | |
| end | |
| -- Handshake + tools/list. Returns the tool descriptors the server offers | |
| -- and records serverInfo. | |
| function Client:discover() | |
| local msgs = self:handshake() | |
| local list_id = self:id() | |
| msgs[#msgs + 1] = { jsonrpc = "2.0", id = list_id, method = "tools/list", | |
| params = trace.ordered({}, {}) } | |
| local init_id = msgs[1] and msgs[1].id | |
| local by_id = self:exchange(msgs) | |
| if init_id then | |
| local init = result_or_raise(by_id[init_id], "initialize") | |
| self.server_info = init.serverInfo | |
| self.negotiated_version = init.protocolVersion | |
| -- From here HTTP exchanges skip the handshake and ride the session. | |
| self.session_ready = true | |
| end | |
| local listed = result_or_raise(by_id[list_id], "tools/list") | |
| self.tools = listed.tools or {} | |
| return self.tools | |
| end | |
| -- Handshake + one tools/call. Returns text, is_error. | |
| -- | |
| -- A failing tool is a normal result with isError set, so this returns the | |
| -- message rather than raising: the loop's job is to hand it to the model as | |
| -- an observation, which is how recovery works. | |
| function Client:call(name, args) | |
| local msgs = self:handshake() | |
| local call_id = self:id() | |
| msgs[#msgs + 1] = { jsonrpc = "2.0", id = call_id, method = "tools/call", | |
| params = { name = name, arguments = args or trace.ordered({}, {}) } } | |
| local by_id = self:exchange(msgs) | |
| local res = result_or_raise(by_id[call_id], "tools/call " .. name) | |
| local parts = {} | |
| for _, block in ipairs(res.content or {}) do | |
| if block.type == "text" and block.text then | |
| parts[#parts + 1] = block.text | |
| elseif block.type then | |
| -- Images, audio, resource links. Name them rather than dropping | |
| -- them silently, so a transcript shows what came back. | |
| parts[#parts + 1] = string.format("[%s content]", block.type) | |
| end | |
| end | |
| local text = table.concat(parts, "\n") | |
| if text == "" then text = "(no content)" end | |
| return text, res.isError == true | |
| end | |
| -------------------------------------------------------------------------- | |
| -- Mounting MCP tools into the agent's registry | |
| -------------------------------------------------------------------------- | |
| -- MCP describes arguments with JSON Schema; tools.lua wants a flat list of | |
| -- {name, type, required}. Only the parts the grammar can express survive: | |
| -- required scalars. An argument this cannot represent is dropped from the | |
| -- schema the model sees, which is better than offering it a field it has no | |
| -- way to fill correctly. | |
| local function args_from_schema(schema) | |
| local out = {} | |
| if type(schema) ~= "table" or type(schema.properties) ~= "table" then | |
| return out | |
| end | |
| local required = {} | |
| for _, r in ipairs(schema.required or {}) do required[r] = true end | |
| -- Sorted, so the generated grammar and catalogue are stable across runs. | |
| local names = {} | |
| for k in pairs(schema.properties) do names[#names + 1] = k end | |
| table.sort(names) | |
| for _, k in ipairs(names) do | |
| local prop = schema.properties[k] or {} | |
| local ty = prop.type | |
| if ty == "integer" then ty = "number" end | |
| if ty ~= "number" then ty = "string" end | |
| out[#out + 1] = { name = k, type = ty, required = required[k] == true } | |
| end | |
| return out | |
| end | |
| -- Add a server's tools to a registry. | |
| -- | |
| -- Collisions are refused rather than resolved: silently shadowing `calc` | |
| -- with a remote tool of the same name would be a genuinely nasty surprise. | |
| -- Pass a prefix to namespace them instead. | |
| function mcp.mount(registry, client, opts) | |
| opts = opts or {} | |
| local prefix = opts.prefix or client.prefix or "" | |
| local tools = client.tools or client:discover() | |
| local mounted = {} | |
| for _, t in ipairs(tools) do | |
| local name = prefix .. t.name | |
| if registry:get(name) then | |
| error(string.format( | |
| "mcp.mount: %q already exists in the registry. Pass a prefix " | |
| .. "to namespace this server's tools.", name), 0) | |
| end | |
| local desc = (t.description or "MCP tool"):gsub("%s+", " ") | |
| if #desc > 120 then desc = desc:sub(1, 117) .. "..." end | |
| registry:add({ | |
| name = name, | |
| description = desc, | |
| args = args_from_schema(t.inputSchema), | |
| -- Remote calls reach outside the process. Read-only built-ins | |
| -- run unattended; a tool whose behaviour this repo cannot see | |
| -- does not get that by default. | |
| requires_approval = opts.trust ~= true, | |
| run = function(a) | |
| local text, is_error = client:call(t.name, a) | |
| if is_error then error(text, 0) end | |
| return text | |
| end, | |
| }) | |
| mounted[#mounted + 1] = name | |
| end | |
| return mounted | |
| end | |
| return mcp | |