lua-agent-lab / mcp.lua
jeorgexyz's picture
Publish reproducible Lua agent experiments and traces
98acb70 verified
Raw History Blame Contribute Delete
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