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
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
|
-- Keyless web access: readable page fetches and Exa's public MCP search.
local uv = require("luv")
local panto = require("panto")
local M = { name = "web" }
local MAX_DOWNLOAD = 2 * 1024 * 1024
local MAX_RESULT = 50 * 1024
local META = "__PANTO_WEB_META__"
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)
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)
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",
}
end
local function add(args, ...)
for i = 1, select("#", ...) do args[#args + 1] = select(i, ...) end
end
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,
}
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
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])
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
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)
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))
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 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"
end
return { host = normalized, resolve_host = host:lower(), port = port }
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 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
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")
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
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
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
end
end
local entities = {
amp = "&", lt = "<", gt = ">", quot = '"', apos = "'", nbsp = " ",
ndash = "–", mdash = "—", hellip = "…", middot = "·", copy = "©", reg = "®",
}
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)
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*>", " ")
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
end
return text, title
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]"
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
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)
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
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")
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"))
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" },
},
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
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] = ""
end
return trim_result(table.concat(out, "\n"):gsub("%s+$", ""))
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 #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"
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
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
end
if not rpc then
local ok, value = pcall(panto.ext.json.decode, response.body)
if ok then rpc = value end
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")
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)
end
function M.activate()
register_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.",
schema = {
type = "object",
properties = {
query = { type = "string", description = "Web search query." },
num_results = { type = "integer", minimum = 1, maximum = 10,
description = "Number of results (default 5)." },
},
required = { "query" },
},
handler = M.on_search,
}
end
M.format_search = format_search
return M
|