File size: 15,342 Bytes
98acb70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
-- 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