summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authort <t@tjp.lol>2026-08-04 12:56:25 -0600
committert <t@tjp.lol>2026-08-04 13:42:28 -0600
commitab6c0d1a9605be2e72de5095ff73bc4c005206aa (patch)
treef77cb14102240ecfbacdde43dd81c9577b52ed41
parent56c82d2bd38ba978313d18e9b5f6dc12138ce788 (diff)
Replace web tools with Lightpanda-backed implementation
Use a persistent MCP subprocess for rendered Markdown fetches and Exa search. Support keyless Exa MCP search with optional authenticated REST access.
-rw-r--r--README.md36
-rw-r--r--_selfcheck.lua93
-rw-r--r--panto-web-scm-1.rockspec8
-rw-r--r--panto-web/init.lua763
4 files changed, 415 insertions, 485 deletions
diff --git a/README.md b/README.md
index ef4a1db..a0539ce 100644
--- a/README.md
+++ b/README.md
@@ -1,16 +1,35 @@
# panto-web
The `web` extension for [pantograph](https://github.com/travisp/pantograph)'s
-`panto` CLI: keyless web access packaged as a luarocks rock.
+`panto` CLI, backed by a persistent [Lightpanda](https://lightpanda.io/) MCP
+subprocess.
## Tools
-- **`web.fetch`** — fetches a public HTTP(S) URL with `curl`, follows up to
- five public redirects, and converts HTML to compact readable text. Blocked
- and JavaScript-only pages get one keyless retry through Jina Reader.
-- **`web.search`** — searches through Exa's public MCP endpoint without
- configuration or an API key, returning compact titles, URLs, and excerpts
- for `web.fetch` to read.
+- **`web.fetch`** — renders a URL in Lightpanda and returns its DOM-derived
+ CommonMark, including JavaScript-rendered content.
+- **`web.search`** — searches through Exa's keyless MCP HTTP endpoint by
+ default. If `EXA_API_KEY` is set, it uses Exa's REST Search API instead.
+
+Tool output is capped at 100 KB.
+
+## Requirements
+
+Install `lightpanda` and ensure it is on `PATH`. The extension does not install
+or fall back to another browser. See [Lightpanda installation](https://lightpanda.io/docs/run-locally/installation/one-liner),
+or install the nightly with Lightpanda's Homebrew tap:
+
+```sh
+brew tap lightpanda-io/browser
+brew install lightpanda
+```
+
+An `EXA_API_KEY` is optional. When set, `web.search` uses the authenticated
+Exa REST API instead of the rate-limited keyless endpoint:
+
+```sh
+export EXA_API_KEY=...
+```
## Install
@@ -33,7 +52,8 @@ The extension can be disabled with the usual policy globs, e.g.
## Development
-Run the self-check (needs `panto` for its embedded Lua + luv):
+The self-check requires panto's embedded Lua and `luv`, but does not start
+Lightpanda or access the network:
```sh
panto lua _selfcheck.lua
diff --git a/_selfcheck.lua b/_selfcheck.lua
index b7f35be..7c779d0 100644
--- a/_selfcheck.lua
+++ b/_selfcheck.lua
@@ -1,15 +1,11 @@
--- Self-check for panto-web. Run with:
---
--- panto lua panto-web/_selfcheck.lua
+-- Self-check for panto-web. Run with: panto lua _selfcheck.lua
local registered = {}
+local json = require("panto").ext.json
package.loaded["panto"] = {
ext = {
register_tool = function(tool) registered[#registered + 1] = tool end,
- json = {
- decode = function() return {} end,
- encode = function() return "{}" end,
- },
+ json = json,
},
}
@@ -19,41 +15,60 @@ package.path = here .. "/panto-web/?.lua;" .. here .. "/panto-web/?/init.lua;" .
local web = require("init")
assert(web.name == "web", "entry name")
-local parsed = assert(web.parse_url("https://example.com:8443/docs?q=1"))
-assert(parsed.host == "example.com" and parsed.port == 8443, "URL host and port")
-local _, local_err = web.parse_url("http://user:pass@example.com/")
-assert(local_err:find("credentials", 1, true), "URL credentials rejected")
-assert(web.is_public_address("8.8.8.8"), "public IPv4 allowed")
-assert(not web.is_public_address("127.0.0.1"), "loopback IPv4 blocked")
-assert(not web.is_public_address("::1"), "loopback IPv6 blocked")
-assert(not web.is_public_address("::127.0.0.1"), "IPv4-compatible loopback blocked")
-
-local page, title = web.html_to_text([[<html><head><title>A &amp; B</title>
-<style>hidden</style></head><body><h1>Heading</h1><p>Hello &lt;world&gt;.</p>
-<script>also hidden</script><a href="https://example.com/x">Read</a></body></html>]])
-assert(title == "A & B", "HTML title decoded")
-assert(page:find("Heading", 1, true) and page:find("Hello <world>.", 1, true), "HTML text retained")
-assert(page:find("Read (https://example.com/x)", 1, true), "link destination retained")
-assert(not page:find("hidden", 1, true), "non-content HTML removed")
-
-local search_text = web.format_search([[Title: First result
-URL: https://example.com/one
-Highlights:
-Useful first excerpt.
-...
----
-
-Title: Second result
-URL: https://example.com/two
-Highlights:
-Useful second excerpt.]], "example query")
-assert(search_text:find("1. First result", 1, true), "first search result")
-assert(search_text:find("URL: https://example.com/two", 1, true), "second search URL")
-
web.activate()
assert(registered[1].name == "web.fetch", "fetch tool registered")
assert(registered[2].name == "web.search", "search tool registered")
-assert(web.on_fetch({ url = "file:///etc/passwd" }):find("only http", 1, true), "fetch rejects file URLs")
+assert(web.on_fetch({}):find("non-empty", 1, true), "fetch rejects missing URL")
+assert(web.on_fetch({ url = "" }):find("non-empty", 1, true), "fetch rejects empty URL")
assert(web.on_search({ query = "" }):find("non-empty", 1, true), "search rejects empty query")
+assert(web.on_search({ query = "ok", num_results = 0 }):find("integer", 1, true), "search rejects count")
+
+local markdown = web.build_request(7, "tools/call",
+ web.tool_params("markdown", { url = "https://example.com" }))
+assert(markdown.id == 7 and markdown.params.name == "markdown", "markdown MCP request")
+local evaluate = web.build_request(8, "tools/call",
+ web.tool_params("evaluate", { script = "1 + 1" }))
+assert(evaluate.id == 8 and evaluate.params.name == "evaluate", "evaluate MCP request")
+
+-- Replies must resume only the coroutine waiting for their id.
+local resumed = {}
+local fake = { pending = {}, buf = "" }
+for _, id in ipairs({ 1, 2 }) do
+ local co = coroutine.create(function()
+ coroutine.yield()
+ resumed[#resumed + 1] = id
+ end)
+ assert(coroutine.resume(co))
+ fake.pending[id] = { co = co }
+end
+assert(not web.feed(fake, json.encode({ jsonrpc = "2.0", id = 2, result = {} }) .. "\n"))
+assert(#resumed == 1 and resumed[1] == 2 and fake.pending[1], "response routed by id")
+assert(not web.feed(fake, json.encode({ jsonrpc = "2.0", id = 1, result = {} }) .. "\n"))
+assert(#resumed == 2 and resumed[2] == 1, "second response routed by id")
+
+local body = json.decode(web.exa_body("Lightpanda", 3))
+assert(body.query == "Lightpanda" and body.numResults == 3 and body.type == "auto", "Exa request fields")
+assert(body.contents.highlights == true, "Exa highlights requested")
+local mcp_body = json.decode(web.exa_mcp_body("Lightpanda", 3))
+assert(mcp_body.params.name == "web_search_exa", "keyless Exa MCP tool")
+assert(mcp_body.params.arguments.query == "Lightpanda" and mcp_body.params.arguments.numResults == 3,
+ "keyless Exa MCP arguments")
+local mcp_payload = json.encode({ jsonrpc = "2.0", id = 1, result = {
+ content = { { type = "text", text = "Title: Result" } }, isError = false,
+} })
+assert(web.exa_mcp_result("event: message\ndata: " .. mcp_payload .. "\n") == "Title: Result",
+ "keyless Exa SSE result")
+local fake_key = "fake-secret-key"
+assert(not web.exa_body("Lightpanda", 3):find(fake_key, 1, true), "key absent from Exa body")
+assert(not web.scrub("failed with " .. fake_key, fake_key):find(fake_key, 1, true), "key redacted from output")
+local oversized = web.truncate(string.rep("x", 101 * 1024))
+assert(#oversized < 101 * 1024 and oversized:find("truncated", 1, true), "output capped")
+
+local text = assert(web.result_text({ content = {
+ { type = "text", text = "first" }, { type = "text", text = "second" },
+} }))
+assert(text == "first\nsecond", "MCP text extracted")
+local _, tool_err = web.result_text({ isError = true, content = { { type = "text", text = "failed" } } })
+assert(tool_err == "failed", "MCP tool error extracted")
print("panto-web selfcheck: OK")
diff --git a/panto-web-scm-1.rockspec b/panto-web-scm-1.rockspec
index 221b2cf..e98cc2d 100644
--- a/panto-web-scm-1.rockspec
+++ b/panto-web-scm-1.rockspec
@@ -8,10 +8,12 @@ source = {
}
description = {
- summary = "Web fetch and search tools for pantograph",
+ summary = "Lightpanda-backed web fetch and Exa search tools for pantograph",
detailed = [[
-Keyless web access for the panto CLI: web.fetch returns readable text from
-public HTTP(S) URLs and web.search searches Exa's public MCP endpoint.
+Web access for the panto CLI: web.fetch returns JavaScript-rendered Markdown
+through a Lightpanda MCP subprocess, and web.search uses Exa's keyless MCP HTTP
+endpoint or its REST API when EXA_API_KEY is configured.
+Lightpanda must be on PATH.
Load by adding "panto-web" to `extensions.rocks` in panto's config.toml.
]],
license = "MIT",
diff --git a/panto-web/init.lua b/panto-web/init.lua
index 2e52b4d..637ee27 100644
--- a/panto-web/init.lua
+++ b/panto-web/init.lua
@@ -1,522 +1,417 @@
--- Keyless web access: readable page fetches and Exa's public MCP search.
+-- Web tools backed by a Lightpanda MCP subprocess.
+--
+-- web.fetch -> Lightpanda's `markdown` tool (renders JavaScript, returns CommonMark).
+-- web.search -> Exa search, using its REST API when configured and keyless MCP otherwise.
+--
+-- `lightpanda` must be on PATH; installing it is the user's responsibility.
local uv = require("luv")
local panto = require("panto")
+local json = panto.ext.json
local M = { name = "web" }
-local MAX_DOWNLOAD = 2 * 1024 * 1024
-local MAX_RESULT = 50 * 1024
-local META = "__PANTO_WEB_META__"
+-- Single documented cap so a huge page cannot flood the model's context.
+local MAX_OUTPUT = 100 * 1024
-local function await(label, arm)
- local co = assert(coroutine.running(), label .. ": await must run inside a coroutine")
- local remaining = 3
- arm(function()
- remaining = remaining - 1
- if remaining == 0 then
- local ok, err = coroutine.resume(co)
- if not ok then error(err, 0) end
- end
- end)
- coroutine.yield()
-end
-
-local function curl(args)
- local stdout = uv.new_pipe(false)
- local stderr = uv.new_pipe(false)
- local out, errors = {}, {}
- local out_bytes, error_bytes = 0, 0
- local exit_code, process
- local too_large = false
- local signal
-
- process = uv.spawn("curl", { args = args, stdio = { nil, stdout, stderr } }, function(code)
- exit_code = code
- if process and not process:is_closing() then process:close() end
- signal()
- end)
- if not process then
- stdout:close()
- stderr:close()
- return nil, "curl is not installed or could not be started"
- end
-
- await("curl", function(done)
- signal = done
- stdout:read_start(function(err, data)
- if err then errors[#errors + 1] = tostring(err) end
- if data then
- local room = MAX_DOWNLOAD + 4096 - out_bytes
- if room > 0 then
- local chunk = data:sub(1, room)
- out[#out + 1] = chunk
- out_bytes = out_bytes + #chunk
- end
- if #data > room and not too_large then
- too_large = true
- if not process:is_closing() then process:kill("sigterm") end
- end
- else
- done()
- end
- end)
- stderr:read_start(function(err, data)
- if err then errors[#errors + 1] = tostring(err) end
- if data then
- local room = 8192 - error_bytes
- if room > 0 then
- local chunk = data:sub(1, room)
- errors[#errors + 1] = chunk
- error_bytes = error_bytes + #chunk
- end
- else
- done()
- end
- end)
- end)
+local INSTALL_HINT = "lightpanda is not on PATH. Install it "
+ .. "(https://lightpanda.io/docs/run-locally/installation/one-liner or "
+ .. "`brew tap lightpanda-io/browser && brew install lightpanda`) "
+ .. "so `lightpanda mcp` can start."
- if not stdout:is_closing() then stdout:close() end
- if not stderr:is_closing() then stderr:close() end
- if too_large then return nil, "response exceeded 2 MB" end
- if exit_code ~= 0 then
- local message = table.concat(errors):gsub("%s+$", "")
- return nil, message ~= "" and message or "curl exited with code " .. tostring(exit_code)
- end
- return table.concat(out)
+function M.truncate(text)
+ if #text <= MAX_OUTPUT then return text end
+ return text:sub(1, MAX_OUTPUT) .. "\n\n[truncated at 100 KB]"
end
-local function curl_args()
- return {
- "--silent", "--show-error", "--compressed",
- "--connect-timeout", "10", "--max-time", "30",
- "--max-filesize", tostring(MAX_DOWNLOAD),
- "--proto", "=http,https", "--proto-redir", "=http,https",
- }
+-- Redact a secret from anything that may reach tool output.
+function M.scrub(text, secret)
+ if type(text) ~= "string" or type(secret) ~= "string" or secret == "" then return text end
+ return (text:gsub((secret:gsub("%W", "%%%0")), "[redacted]"))
end
-local function add(args, ...)
- for i = 1, select("#", ...) do args[#args + 1] = select(i, ...) end
-end
+--------------------------------------------------------------------------------
+-- Lightpanda MCP client (only the subset this extension needs)
+--------------------------------------------------------------------------------
-local function split_response(raw)
- local at, from = nil, 1
- while true do
- local found = raw:find(META, from, true)
- if not found then break end
- at, from = found, found + #META
- end
- if not at then return nil, "curl returned no response metadata" end
- local body = raw:sub(1, at - 1):gsub("\n$", "")
- local status, content_type, redirect = raw:sub(at + #META):match("^(%d%d%d)\t([^\t]*)\t(.-)%s*$")
- if not status then return nil, "curl returned malformed response metadata" end
- return {
- body = body,
- status = tonumber(status),
- content_type = content_type,
- redirect = redirect,
- }
+-- One lazily started `lightpanda mcp` process per extension lifetime. Idle
+-- handles are unreferenced so panto's `uv.run("default")` can return after a
+-- tool batch while the browser session remains available for later calls.
+local client
+
+function M.build_request(id, method, params)
+ return { jsonrpc = "2.0", id = id, method = method, params = params }
end
-local function parse_ipv4(address)
- local parts = {}
- for part in address:gmatch("[^.]+") do parts[#parts + 1] = tonumber(part) end
- if #parts ~= 4 or address:find("[^%d%.]") then return nil end
- for _, part in ipairs(parts) do
- if not part or part < 0 or part > 255 or part % 1 ~= 0 then return nil end
+-- Resolve a pending request exactly once, whatever the outcome.
+local function settle(req, message, err)
+ if req.done then return end
+ req.done, req.message, req.err = true, message, err
+ if req.co and coroutine.status(req.co) == "suspended" then
+ local ok, resume_err = coroutine.resume(req.co)
+ if not ok then error(resume_err, 0) end
end
- return parts
end
-local function parse_ipv6(address)
- local ipv4 = address:match("([^:]+%.[^:]+)$")
- if ipv4 then
- local octets = parse_ipv4(ipv4)
- if not octets then return nil end
- address = address:sub(1, #address - #ipv4)
- .. string.format("%x:%x", octets[1] * 256 + octets[2], octets[3] * 256 + octets[4])
+local function teardown(c, reason)
+ if c.dead then return end
+ c.dead = true
+ if client == c then client = nil end
+ local pending = c.pending
+ c.pending = {}
+ for _, req in pairs(pending) do settle(req, nil, reason) end
+ local waiters = c.ready_waiters or {}
+ c.ready_waiters = {}
+ for _, req in ipairs(waiters) do settle(req, nil, reason) end
+ for _, pipe in ipairs({ c.stdin, c.stdout, c.stderr }) do
+ if pipe and not pipe:is_closing() then pipe:close() end
end
- local compression = address:find("::", 1, true)
- if compression and address:find("::", compression + 2, true) then return nil end
- local left, right = address:match("^(.-)::(.-)$")
- if address:find("::", 1, true) and not left then return nil end
- local groups = {}
- local function append(side)
- if side == "" then return true end
- for part in side:gmatch("[^:]+") do
- if not part:match("^[%da-fA-F]+$") or #part > 4 then return false end
- groups[#groups + 1] = tonumber(part, 16)
- end
- return true
+ if c.proc and not c.proc:is_closing() then
+ if not c.exited then pcall(c.proc.kill, c.proc, "sigterm") end
+ c.proc:close()
end
- if left then
- if not append(left) then return nil end
- local left_count = #groups
- local tail = {}
- for part in right:gmatch("[^:]+") do
- if not part:match("^[%da-fA-F]+$") or #part > 4 then return nil end
- tail[#tail + 1] = tonumber(part, 16)
- end
- local missing = 8 - left_count - #tail
- if missing < 1 then return nil end
- for _ = 1, missing do groups[#groups + 1] = 0 end
- for _, part in ipairs(tail) do groups[#groups + 1] = part end
- elseif not append(address) or #groups ~= 8 then
- return nil
- end
- return #groups == 8 and groups or nil
end
-function M.is_public_address(address)
- local v4 = parse_ipv4(address)
- if v4 then
- local a, b, c = v4[1], v4[2], v4[3]
- return not (a == 0 or a == 10 or a == 127
- or (a == 100 and b >= 64 and b <= 127)
- or (a == 169 and b == 254)
- or (a == 172 and b >= 16 and b <= 31)
- or (a == 192 and b == 0 and c == 0)
- or (a == 192 and b == 0 and c == 2)
- or (a == 192 and b == 168)
- or (a == 198 and (b == 18 or b == 19 or (b == 51 and c == 100)))
- or (a == 203 and b == 0 and c == 113)
- or a >= 224)
+-- Consume newline-delimited stdout and route each reply to its waiting coroutine.
+-- Returns an error string when the stream is unusable.
+function M.feed(c, chunk)
+ c.buf = (c.buf or "") .. chunk
+ while true do
+ local line, rest = c.buf:match("^([^\n]*)\n(.*)$")
+ if not line then return nil end
+ c.buf = rest
+ if line:match("%S") then
+ local ok, message = pcall(json.decode, line)
+ if not ok or type(message) ~= "table" then
+ return "lightpanda mcp sent a malformed JSON-RPC message"
+ end
+ if message.jsonrpc ~= "2.0" then
+ return "lightpanda mcp sent a malformed JSON-RPC message"
+ end
+ local req = message.id ~= nil and c.pending[message.id]
+ if message.id ~= nil then
+ if message.result == nil and message.error == nil then
+ return "lightpanda mcp sent a malformed JSON-RPC response"
+ end
+ if req then
+ c.pending[message.id] = nil
+ settle(req, message)
+ end
+ elseif type(message.method) ~= "string" then
+ return "lightpanda mcp sent a malformed JSON-RPC message"
+ end
+ end
end
+end
- local v6 = parse_ipv6(address)
- if not v6 then return false end
- if v6[1] == 0 and v6[2] == 0 and v6[3] == 0 and v6[4] == 0
- and v6[5] == 0 and (v6[6] == 0 or v6[6] == 0xffff) then
- return M.is_public_address(string.format("%d.%d.%d.%d",
- v6[7] >> 8, v6[7] & 255, v6[8] >> 8, v6[8] & 255))
+local function write(c, payload)
+ local ok, req, immediate_err = pcall(c.stdin.write, c.stdin, payload, function(err)
+ if err then teardown(c, "lightpanda mcp write failed: " .. tostring(err)) end
+ end)
+ if not ok or not req then
+ teardown(c, "lightpanda mcp write failed: " .. tostring(immediate_err or req))
end
- local all_zero = true
- for _, part in ipairs(v6) do if part ~= 0 then all_zero = false break end end
- return not (all_zero
- or (v6[1] == 0 and v6[2] == 0 and v6[3] == 0 and v6[4] == 0
- and v6[5] == 0 and v6[6] == 0 and v6[7] == 0 and v6[8] == 1)
- or (v6[1] & 0xfe00) == 0xfc00
- or (v6[1] & 0xffc0) == 0xfe80
- or (v6[1] & 0xff00) == 0xff00
- or (v6[1] == 0x2001 and v6[2] == 0x0db8))
end
-function M.parse_url(url)
- if type(url) ~= "string" or url == "" then return nil, "url must be a non-empty string" end
- if #url > 4096 then return nil, "url is too long" end
- if url:find("%c") then return nil, "url contains control characters" end
- local scheme, authority = url:match("^([%a][%w+.-]*)://([^/?#]+)")
- scheme = scheme and scheme:lower()
- if scheme ~= "http" and scheme ~= "https" then return nil, "only http and https URLs are supported" end
- if authority:find("@", 1, true) then return nil, "URLs with credentials are not supported" end
+local function request(c, method, params)
+ local co, is_main = coroutine.running()
+ if not co or is_main then return nil, "web tools must run inside a coroutine" end
- local host, port
- if authority:sub(1, 1) == "[" then
- host, port = authority:match("^%[([^]]+)%]:?(%d*)$")
- if not host or not parse_ipv6(host) then return nil, "invalid IPv6 URL host" end
- else
- host, port = authority:match("^(.-):(%d+)$")
- if not host then host, port = authority, "" end
- if host:find(":", 1, true) or not host:match("^[%w.-]+$") then
- return nil, "invalid URL host"
- end
- end
- port = port ~= "" and tonumber(port) or (scheme == "https" and 443 or 80)
- if not port or port < 1 or port > 65535 then return nil, "invalid URL port" end
- local normalized = host:lower():gsub("%.$", "")
- if normalized == "" or normalized == "localhost" or normalized:match("%.localhost$") then
- return nil, "internal host is not allowed"
+ c.next_id = c.next_id + 1
+ local id = c.next_id
+ local req = { co = co }
+ c.pending[id] = req
+ write(c, json.encode(M.build_request(id, method, params)) .. "\n")
+ if not req.done then coroutine.yield() end
+ c.pending[id] = nil
+
+ if not req.message then return nil, req.err or "lightpanda mcp did not respond" end
+ if req.message.error then
+ local rpc_error = req.message.error
+ return nil, type(rpc_error) == "table"
+ and tostring(rpc_error.message or "lightpanda mcp returned an error")
+ or "lightpanda mcp returned an error"
end
- return { host = normalized, resolve_host = host:lower(), port = port }
+ return req.message
end
-local function resolve_url(url)
- local parsed, err = M.parse_url(url)
- if not parsed then return nil, err end
- if parse_ipv4(parsed.host) or parse_ipv6(parsed.host) then
- if not M.is_public_address(parsed.host) then return nil, "internal address is not allowed" end
- parsed.address = parsed.host
- return parsed
- end
+local function start()
+ local c = { pending = {}, ready_waiters = {}, next_id = 0, buf = "" }
+ c.stdin, c.stdout, c.stderr = uv.new_pipe(false), uv.new_pipe(false), uv.new_pipe(false)
- local dns_err, addresses
- local co = assert(coroutine.running(), "DNS lookup requires a coroutine")
- uv.getaddrinfo(parsed.host, tostring(parsed.port), { socktype = "stream" }, function(e, found)
- dns_err, addresses = e, found
- local ok, resume_err = coroutine.resume(co)
- if not ok then error(resume_err, 0) end
+ local proc = uv.spawn("lightpanda", {
+ args = { "mcp" },
+ stdio = { c.stdin, c.stdout, c.stderr },
+ }, function(code)
+ c.exited = true
+ teardown(c, "lightpanda mcp exited (code " .. tostring(code) .. ")")
end)
- coroutine.yield()
- if dns_err or not addresses or #addresses == 0 then
- return nil, "failed to resolve " .. parsed.host .. ": " .. tostring(dns_err or "no addresses")
+ if not proc then
+ teardown(c, INSTALL_HINT)
+ return nil, INSTALL_HINT
end
- for _, item in ipairs(addresses) do
- local address = item.addr or item.address
- if not address or not M.is_public_address(address) then
- return nil, "internal address resolved for " .. parsed.host .. ": " .. tostring(address)
- end
- parsed.address = parsed.address or address
- end
- return parsed
-end
+ c.proc = proc
+ client = c
-local redirect_status = { [301] = true, [302] = true, [303] = true, [307] = true, [308] = true }
-
-local function request_once(url, resolved)
- local args = curl_args()
- local address = resolved.address:find(":", 1, true) and "[" .. resolved.address .. "]" or resolved.address
- add(args,
- "--resolve", resolved.resolve_host .. ":" .. resolved.port .. ":" .. address,
- "--user-agent", "panto-web/web",
- "--header", "Accept: text/html,application/xhtml+xml,text/plain,application/json,application/xml;q=0.9,*/*;q=0.1",
- "--write-out", "\n" .. META .. "%{http_code}\t%{content_type}\t%{redirect_url}",
- "--url", url)
- local raw, err = curl(args)
- if not raw then return nil, err end
- return split_response(raw)
-end
+ c.stdout:read_start(function(err, data)
+ if err then return teardown(c, "lightpanda mcp read error: " .. tostring(err)) end
+ if not data then return teardown(c, "lightpanda mcp closed its output") end
+ local feed_err = M.feed(c, data)
+ if feed_err then teardown(c, feed_err) end
+ end)
+ c.stderr:read_start(function() end)
-local function fetch_http(url)
- local current = url
- for redirects = 0, 5 do
- local resolved, err = resolve_url(current)
- if not resolved then return nil, err end
- local response
- response, err = request_once(current, resolved)
- if not response then return nil, err end
- if redirect_status[response.status] and response.redirect ~= "" then
- if redirects == 5 then return nil, "too many redirects" end
- current = response.redirect
- else
- response.url = current
- return response
- end
+ local _, err = request(c, "initialize", {
+ protocolVersion = "2024-11-05",
+ capabilities = {},
+ clientInfo = { name = "panto-web", version = "scm" },
+ })
+ if err then
+ teardown(c, err)
+ return nil, err
end
+ write(c, json.encode(M.build_request(nil, "notifications/initialized", {})) .. "\n")
+ c.ready = true
+ for _, waiter in ipairs(c.ready_waiters) do settle(waiter, true) end
+ c.ready_waiters = {}
+ return c
end
-local entities = {
- amp = "&", lt = "<", gt = ">", quot = '"', apos = "'", nbsp = " ",
- ndash = "–", mdash = "—", hellip = "…", middot = "·", copy = "©", reg = "®",
-}
+local function ready_client()
+ if not client or client.dead then return start() end
+ if client.ready then return client end
-local function codepoint(n)
- n = tonumber(n)
- if not n or n < 1 or n > 0x10ffff or (n >= 0xd800 and n <= 0xdfff) then return "�" end
- return utf8.char(n)
+ local co, is_main = coroutine.running()
+ if not co or is_main then return nil, "web tools must run inside a coroutine" end
+ local c = client
+ local waiter = { co = co }
+ c.ready_waiters[#c.ready_waiters + 1] = waiter
+ coroutine.yield()
+ if waiter.err then return nil, waiter.err end
+ if c.dead or not c.ready then return nil, "lightpanda mcp failed to initialize" end
+ return c
end
-function M.html_to_text(html)
- local title = html:match("<[Tt][Ii][Tt][Ll][Ee][^>]*>(.-)</[Tt][Ii][Tt][Ll][Ee]%s*>")
- local text = html
- for _, tag in ipairs({ "script", "style", "noscript", "svg", "head" }) do
- local letters = tag:gsub(".", function(c) return "[" .. c:lower() .. c:upper() .. "]" end)
- text = text:gsub("<" .. letters .. "[^>]*>.-</" .. letters .. "%s*>", " ")
+-- Flatten an MCP tool result into text.
+function M.result_text(result)
+ if type(result) ~= "table" then return nil, "lightpanda mcp returned no result" end
+ if result.content ~= nil and type(result.content) ~= "table" then
+ return nil, "lightpanda mcp returned malformed tool content"
+ end
+ local parts = {}
+ for _, item in ipairs(result.content or {}) do
+ if type(item) == "table" and type(item.text) == "string" then parts[#parts + 1] = item.text end
end
- text = text:gsub("<!%-%-.-%-%->", " ")
- text = text:gsub('<[Aa][^>]-[Hh][Rr][Ee][Ff]%s*=%s*"([^"]+)"[^>]*>(.-)</[Aa]%s*>',
- function(href, label) return label .. " (" .. href .. ")" end)
- text = text:gsub("<[Aa][^>]-[Hh][Rr][Ee][Ff]%s*=%s*'([^']+)'[^>]*>(.-)</[Aa]%s*>",
- function(href, label) return label .. " (" .. href .. ")" end)
- text = text:gsub("<[Bb][Rr]%s*/?%s*>", "\n")
- text = text:gsub("<[Ll][Ii][^>]*>", "\n- ")
- text = text:gsub("</[Pp]%s*>", "\n\n")
- text = text:gsub("</[Dd][Ii][Vv]%s*>", "\n")
- text = text:gsub("</[Hh][1-6]%s*>", "\n\n")
- text = text:gsub("</[Tt][Rr]%s*>", "\n")
- text = text:gsub("</[Tt][DdHh]%s*>", "\t")
- text = text:gsub("<[^>]+>", " ")
- text = text:gsub("&#[xX]([%da-fA-F]+);", function(n) return codepoint(tonumber(n, 16)) end)
- text = text:gsub("&#(%d+);", codepoint)
- text = text:gsub("&([%a]+);", function(name) return entities[name] or "&" .. name .. ";" end)
- text = text:gsub("\r", ""):gsub("[\t\f\v ]+", " ")
- text = text:gsub(" *\n *", "\n"):gsub("\n\n\n+", "\n\n")
- text = text:match("^%s*(.-)%s*$") or ""
- if title then
- title = M.html_to_text(title)
- if title == "" then title = nil end
+ local text = table.concat(parts, "\n")
+ if result.isError then
+ return nil, text ~= "" and text or "lightpanda mcp reported a tool error"
end
- return text, title
+ return text
end
-local function trim_result(text)
- if #text <= MAX_RESULT then return text end
- return text:sub(1, MAX_RESULT) .. "\n\n[truncated after 50 KB]"
+local function set_referenced(c, referenced)
+ local method = referenced and "ref" or "unref"
+ for _, handle in ipairs({ c.stdin, c.stdout, c.stderr, c.proc }) do
+ if handle and not handle:is_closing() then handle[method](handle) end
+ end
end
-local function is_html(content_type, body)
- content_type = content_type:lower()
- return content_type:find("text/html", 1, true)
- or content_type:find("application/xhtml+xml", 1, true)
- or body:match("^%s*<[!%a]") ~= nil
+function M.tool_params(tool, arguments)
+ return { name = tool, arguments = arguments }
end
-local function needs_reader(response, text)
- local lower = text:lower()
- return response.status >= 400 or #text < 200
- or lower:find("please enable javascript", 1, true)
- or lower:find("sorry, you have been blocked", 1, true)
- or lower:find("please enable cookies", 1, true)
- or lower:find("just a moment", 1, true)
+local function call(tool, arguments)
+ local c, ready_err = ready_client()
+ if not c then return nil, ready_err end
+ set_referenced(c, true)
+ local message, err = request(c, "tools/call", M.tool_params(tool, arguments))
+ if not message then
+ if not c.dead and next(c.pending) == nil then set_referenced(c, false) end
+ return nil, err
+ end
+ local text, result_err = M.result_text(message.result)
+ if not c.dead and next(c.pending) == nil then set_referenced(c, false) end
+ return text, result_err
end
-local function jina(url)
- local response, err = fetch_http("https://r.jina.ai/" .. url)
- if not response or response.status < 200 or response.status >= 300 then return nil, err end
- local content = response.body:match("Markdown Content:%s*(.*)")
- if not content or #content < 100 then return nil end
- return content:match("^%s*(.-)%s*$")
-end
+--------------------------------------------------------------------------------
+-- Tools
+--------------------------------------------------------------------------------
function M.on_fetch(input)
local url = type(input) == "table" and input.url or nil
- local parsed, validation_error = M.parse_url(url)
- if not parsed then return "Error: " .. validation_error end
- local response, err = fetch_http(url)
- if not response then return "Error fetching " .. url .. ": " .. err end
-
- local text, title, via_reader
- if is_html(response.content_type, response.body) then
- text, title = M.html_to_text(response.body)
- if needs_reader(response, text) then
- local readable = jina(response.url)
- if readable then text, title, via_reader = readable, nil, true end
- end
- elseif response.content_type:lower():find("^text/")
- or response.content_type:lower():find("json", 1, true)
- or response.content_type:lower():find("xml", 1, true) then
- text = response.body
- else
- return "Error: unsupported content type: "
- .. (response.content_type ~= "" and response.content_type or "unknown")
+ if type(url) ~= "string" or url:match("^%s*$") then
+ return "Error: url must be a non-empty string"
end
- local lines = { "URL: " .. response.url }
- if title then lines[#lines + 1] = "Title: " .. title:sub(1, 200) end
- if via_reader then
- lines[#lines + 1] = "Reader: Jina"
- elseif response.status < 200 or response.status >= 300 then
- lines[#lines + 1] = "HTTP: " .. response.status
- end
- lines[#lines + 1] = ""
- lines[#lines + 1] = text
- return trim_result(table.concat(lines, "\n"))
+ local markdown, err = call("markdown", { url = url })
+ if not markdown then return "Error fetching " .. url .. ": " .. err end
+ if markdown:match("^%s*$") then return "URL: " .. url .. "\n\n[no content]" end
+ return M.truncate("URL: " .. url .. "\n\n" .. (markdown:gsub("^%s+", "")))
end
-local function register_fetch()
- panto.ext.register_tool {
- name = "web.fetch",
- description = "Fetch an HTTP(S) URL and return readable text instead of raw HTML. "
- .. "Follows validated public redirects and may retry blocked or JavaScript-only pages through Jina Reader.",
- schema = {
- type = "object",
- properties = {
- url = { type = "string", description = "Public HTTP(S) URL to fetch." },
- },
- required = { "url" },
+-- Exa request body; deliberately free of credentials.
+function M.exa_body(query, count)
+ return json.encode({
+ query = query,
+ numResults = count,
+ type = "auto",
+ contents = { highlights = true },
+ })
+end
+
+-- Page-side transport: POST to Exa and hand back a status/body envelope. The key
+-- arrives as a JSON literal over the MCP stdin pipe, never a shell argument.
+function M.exa_script(body, key)
+ return ("const r = await fetch(%s, { method: 'POST', headers:"
+ .. " { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + %s },"
+ .. " body: %s }); return { status: r.status, body: await r.text() };")
+ :format(json.encode("https://api.exa.ai/search"), json.encode(key), json.encode(body))
+end
+
+function M.exa_mcp_body(query, count)
+ return json.encode({
+ jsonrpc = "2.0",
+ id = 1,
+ method = "tools/call",
+ params = {
+ name = "web_search_exa",
+ arguments = { query = query, numResults = count },
},
- handler = M.on_fetch,
- }
+ })
end
-local function format_search(text, query)
- local results, current, collecting = {}, nil, false
- local function finish()
- if not current or not current.url then return end
- current.snippet = table.concat(current.parts, " "):gsub("%s+", " ")
- :gsub("%.%.%.", ""):match("^%s*(.-)%s*$") or ""
- if #current.snippet > 1000 then current.snippet = current.snippet:sub(1, 1000) .. "…" end
- results[#results + 1] = current
- end
- for line in (text:gsub("\r", "") .. "\n"):gmatch("(.-)\n") do
- local value = line:match("^Title:%s*(.*)$")
- if value then
- finish()
- current, collecting = { title = value, parts = {} }, false
- elseif current then
- value = line:match("^URL:%s*(.*)$")
- if value then
- current.url = value
- elseif line == "Highlights:" or line:match("^Text:%s*") then
- collecting = true
- local first = line:match("^Text:%s*(.+)$")
- if first then current.parts[#current.parts + 1] = first end
- elseif collecting and line ~= "..." and line ~= "---" then
- current.parts[#current.parts + 1] = line
- end
+function M.exa_mcp_script(body)
+ return ("const r = await fetch(%s, { method: 'POST', headers:"
+ .. " { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream',"
+ .. " 'x-exa-source': 'panto-web' }, body: %s });"
+ .. " return { status: r.status, body: await r.text() };")
+ :format(json.encode("https://mcp.exa.ai/mcp?tools=web_search_exa"), json.encode(body))
+end
+
+function M.exa_mcp_result(body)
+ local rpc
+ for payload in body:gmatch("data:%s*([^\r\n]+)") do
+ local ok, candidate = pcall(json.decode, payload)
+ if ok and type(candidate) == "table" and (candidate.result or candidate.error) then
+ rpc = candidate
+ break
end
end
- finish()
- if #results == 0 then return trim_result(text) end
- local out = { "Search results for: " .. query, "" }
- for i, result in ipairs(results) do
- out[#out + 1] = i .. ". " .. (result.title ~= "" and result.title or result.url)
- out[#out + 1] = "URL: " .. result.url
- if result.snippet ~= "" then out[#out + 1] = result.snippet end
- out[#out + 1] = ""
+ if not rpc then
+ local ok, candidate = pcall(json.decode, body)
+ if ok and type(candidate) == "table" then rpc = candidate end
end
- return trim_result(table.concat(out, "\n"):gsub("%s+$", ""))
+ if not rpc then return nil, "Exa MCP returned malformed JSON" end
+ if rpc.error then
+ return nil, type(rpc.error) == "table"
+ and tostring(rpc.error.message or "Exa MCP error") or "Exa MCP error"
+ end
+ local result = rpc.result
+ if type(result) ~= "table" or type(result.content) ~= "table" then
+ return nil, "Exa MCP returned malformed tool content"
+ end
+ local parts = {}
+ for _, item in ipairs(result.content) do
+ if type(item) == "table" and type(item.text) == "string" then parts[#parts + 1] = item.text end
+ end
+ local text = table.concat(parts, "\n")
+ if result.isError then return nil, text ~= "" and text or "Exa MCP error" end
+ if text == "" then return nil, "Exa MCP returned empty content" end
+ return text
+end
+
+local function decode_http_envelope(text)
+ local ok, envelope = pcall(json.decode, text)
+ if not ok or type(envelope) ~= "table" or type(envelope.status) ~= "number"
+ or type(envelope.body) ~= "string" then
+ return nil, "lightpanda returned an unexpected response"
+ end
+ return envelope
end
function M.on_search(input)
local query = type(input) == "table" and input.query or nil
- if type(query) ~= "string" or query:match("^%s*$") then return "Error: query must be a non-empty string" end
+ if type(query) ~= "string" or query:match("^%s*$") then
+ return "Error: query must be a non-empty string"
+ end
if #query > 2000 then return "Error: query is too long" end
- local count = input.num_results or 5
- if type(count) ~= "number" or count % 1 ~= 0 or count < 1 or count > 10 then
- return "Error: num_results must be an integer from 1 to 10"
+
+ local count = input.num_results
+ if count == nil then count = 5 end
+ if type(count) ~= "number" or count % 1 ~= 0 or count < 1 or count > 100 then
+ return "Error: num_results must be an integer from 1 to 100"
end
- local args = curl_args()
- add(args,
- "--header", "Content-Type: application/json",
- "--header", "Accept: application/json, text/event-stream",
- "--header", "x-exa-source: panto-web",
- "--data-binary", panto.ext.json.encode({
- jsonrpc = "2.0", id = 1, method = "tools/call",
- params = { name = "web_search_exa", arguments = { query = query, numResults = count } },
- }),
- "--write-out", "\n" .. META .. "%{http_code}\t%{content_type}\t%{redirect_url}",
- "--url", "https://mcp.exa.ai/mcp?tools=web_search_exa")
- local raw, err = curl(args)
- if not raw then return "Error searching the web: " .. err end
- local response
- response, err = split_response(raw)
- if not response then return "Error searching the web: " .. err end
- if response.status < 200 or response.status >= 300 then
- return "Error searching the web: Exa MCP returned HTTP " .. response.status
+ local key = os.getenv("EXA_API_KEY")
+ if type(key) ~= "string" or key == "" then
+ local text, err = call("evaluate", {
+ url = "https://example.com",
+ timeout = 60000,
+ script = M.exa_mcp_script(M.exa_mcp_body(query, count)),
+ })
+ if not text then return "Error searching the web: " .. err end
+ local envelope, envelope_err = decode_http_envelope(text)
+ if not envelope then return "Error searching the web: " .. envelope_err end
+ if envelope.status < 200 or envelope.status >= 300 then
+ return "Error searching the web: Exa MCP returned HTTP " .. math.floor(envelope.status)
+ end
+ local result, result_err = M.exa_mcp_result(envelope.body)
+ if not result then return "Error searching the web: " .. result_err end
+ return M.truncate(result)
end
- local rpc
- for payload in response.body:gmatch("data:%s*([^\r\n]+)") do
- local ok, value = pcall(panto.ext.json.decode, payload)
- if ok and type(value) == "table" and (value.result or value.error) then rpc = value break end
+ local text, err = call("evaluate", {
+ url = "https://example.com",
+ timeout = 60000,
+ script = M.exa_script(M.exa_body(query, count), key),
+ })
+ if not text then
+ if err == INSTALL_HINT then return "Error searching the web: " .. err end
+ if err == "lightpanda mcp sent a malformed JSON-RPC message"
+ or err == "lightpanda mcp sent a malformed JSON-RPC response"
+ or err == "lightpanda mcp returned malformed tool content" then
+ return "Error searching the web: " .. err
+ end
+ -- Tool/server errors can echo the evaluated script, which contains the key.
+ return "Error searching the web: Lightpanda request failed"
end
- if not rpc then
- local ok, value = pcall(panto.ext.json.decode, response.body)
- if ok then rpc = value end
+
+ local envelope, envelope_err = decode_http_envelope(text)
+ if not envelope then return "Error searching the web: " .. envelope_err end
+ if envelope.status < 200 or envelope.status >= 300 then
+ return "Error searching the web: Exa returned HTTP " .. math.floor(envelope.status)
end
- if type(rpc) ~= "table" then return "Error searching the web: Exa MCP returned malformed JSON" end
- if rpc.error then return "Error searching the web: " .. tostring(rpc.error.message or "Exa MCP error") end
- local content = rpc.result and rpc.result.content
- if rpc.result and rpc.result.isError then
- return "Error searching the web: " .. tostring(content and content[1] and content[1].text or "Exa MCP error")
+ local valid, response = pcall(json.decode, envelope.body)
+ if not valid or type(response) ~= "table" then
+ return "Error searching the web: Exa returned malformed JSON"
end
- local text = content and content[1] and content[1].text
- if type(text) ~= "string" or text == "" then return "No search results." end
- return format_search(text, query)
+ return M.truncate(M.scrub(envelope.body, key))
end
function M.activate()
- register_fetch()
+ panto.ext.register_tool {
+ name = "web.fetch",
+ description = "Fetch an HTTP(S) URL through the Lightpanda browser and return the page "
+ .. "as Markdown, including JavaScript-rendered content. Requires `lightpanda` on PATH.",
+ schema = {
+ type = "object",
+ properties = {
+ url = { type = "string", description = "HTTP(S) URL to fetch." },
+ },
+ required = { "url" },
+ },
+ handler = M.on_fetch,
+ }
panto.ext.register_tool {
name = "web.search",
- description = "Search the web without an API key through Exa's public MCP endpoint. "
- .. "Returns titles, URLs, and short excerpts; use web.fetch to read a result.",
+ description = "Search the web with Exa and return its results. Uses keyless Exa MCP by default; "
+ .. "EXA_API_KEY enables the REST API. Requires `lightpanda` on PATH.",
schema = {
type = "object",
properties = {
query = { type = "string", description = "Web search query." },
- num_results = { type = "integer", minimum = 1, maximum = 10,
+ num_results = { type = "integer", minimum = 1, maximum = 100,
description = "Number of results (default 5)." },
},
required = { "query" },
@@ -525,6 +420,4 @@ function M.activate()
}
end
-M.format_search = format_search
-
return M