Compare commits
2 Commits
v0.5.3
...
v1.0.0-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
| deb73d129e | |||
| b81b021b5b |
+111
-5
@@ -11,6 +11,10 @@ local server = lmcp.new("example-tools", {
|
||||
port = tonumber(arg[1]) or 8080,
|
||||
})
|
||||
|
||||
-- The optional 5th `opts` arg to server:tool carries MCP annotations.
|
||||
-- Omit it and clients assume the worst (destructive, openWorld) — fine
|
||||
-- for prototypes; declare annotations once you know each tool's stance.
|
||||
|
||||
server:tool("shell", "Execute a shell command", {
|
||||
type = "object",
|
||||
properties = {
|
||||
@@ -24,7 +28,15 @@ server:tool("shell", "Execute a shell command", {
|
||||
local result = handle:read('*a')
|
||||
handle:close()
|
||||
return result ~= '' and result or '(no output)'
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Run shell",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("read_file", "Read a file", {
|
||||
type = "object",
|
||||
@@ -38,7 +50,15 @@ server:tool("read_file", "Read a file", {
|
||||
local content = f:read('*a')
|
||||
f:close()
|
||||
return content
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Read file",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("write_file", "Write content to a file", {
|
||||
type = "object",
|
||||
@@ -53,7 +73,15 @@ server:tool("write_file", "Write content to a file", {
|
||||
f:write(args.content)
|
||||
f:close()
|
||||
return string.format("Written %d bytes to %s", #args.content, args.path)
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Write file",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("list_dir", "List directory contents", {
|
||||
type = "object",
|
||||
@@ -67,7 +95,85 @@ server:tool("list_dir", "List directory contents", {
|
||||
local result = handle:read('*a')
|
||||
handle:close()
|
||||
return result
|
||||
end, {
|
||||
annotations = {
|
||||
title = "List directory",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
-- ---- Resources (MCP primitive — see issue #5) ----
|
||||
-- Tools-only servers force the client to spend a tools/call round-trip
|
||||
-- for every read. Resources let the client list and read by URI, with a
|
||||
-- stable identity it can cache and reference in prompts.
|
||||
|
||||
server:resource("text://greeting", {
|
||||
name = "Greeting",
|
||||
mimeType = "text/plain",
|
||||
}, function() return "Hello from lmcp!" end)
|
||||
|
||||
-- Tiny binary resource: 8-byte PNG signature, demonstrates blob handling.
|
||||
server:resource("data://lmcp.png", {
|
||||
name = "PNG signature",
|
||||
mimeType = "image/png",
|
||||
}, function()
|
||||
return { blob_bytes = "\x89PNG\r\n\x1a\n", mimeType = "image/png" }
|
||||
end)
|
||||
|
||||
io.stderr:write("Starting lmcp example server...\n")
|
||||
server:run()
|
||||
-- Template: any local file. `args.path` is captured greedily (no leading
|
||||
-- slash because the template literal already includes ///).
|
||||
server:resource_template("file:///{path}", {
|
||||
name = "Local file",
|
||||
mimeType = "text/plain",
|
||||
}, function(args)
|
||||
local f = io.open("/" .. args.path, "r")
|
||||
if not f then error("file not found: /" .. args.path) end
|
||||
local content = f:read("*a"); f:close()
|
||||
return content
|
||||
end)
|
||||
|
||||
-- ---- Prompts (MCP primitive — see issue #6) ----
|
||||
-- Parameterised prompt templates the client surfaces as a menu
|
||||
-- (slash-commands, snippets). Handler returns either a plain string (one
|
||||
-- user-role text message) or a full { description?, messages = {...} }
|
||||
-- shape for finer control.
|
||||
|
||||
server:prompt("release_note", {
|
||||
description = "Draft a release note for a given version",
|
||||
arguments = {
|
||||
{ name = "version", description = "Tag, e.g. v0.7.1", required = true },
|
||||
{ name = "since", description = "Previous tag", required = false },
|
||||
},
|
||||
}, function(args)
|
||||
return "Write concise release notes for version " .. (args.version or "?")
|
||||
.. " since " .. (args.since or "the previous tag")
|
||||
.. ". Group by category (features / fixes / docs)."
|
||||
end)
|
||||
|
||||
-- Completion for the release_note prompt's `version` argument. Returned
|
||||
-- list is filtered against `value` (prefix match) by the server's spec
|
||||
-- contract is "candidates"; clients may further filter.
|
||||
server:complete("ref/prompt", "release_note", "version", function(value, ctx)
|
||||
local all = { "v0.5.0", "v0.5.1", "v0.5.2", "v0.5.3", "v0.5.4",
|
||||
"v0.6.0", "v0.7.0", "v0.7.1", "v1.0.0-rc1" }
|
||||
if value == "" then return all end
|
||||
local out = {}
|
||||
for _, v in ipairs(all) do
|
||||
if v:sub(1, #value) == value then out[#out + 1] = v end
|
||||
end
|
||||
return out
|
||||
end)
|
||||
|
||||
local transport = os.getenv("LMCP_TRANSPORT") or "http"
|
||||
if transport == "stdio" then
|
||||
if os.getenv("LMCP_PORT") then
|
||||
io.stderr:write("lmcp: LMCP_PORT ignored in stdio mode\n")
|
||||
end
|
||||
server:run_stdio()
|
||||
else
|
||||
io.stderr:write("Starting lmcp example server...\n")
|
||||
server:run()
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=lmcp MCP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
# Arch ships the Lua 5.4 binary as /usr/bin/lua; Debian ships /usr/bin/lua5.4.
|
||||
# Override ExecStart if your distro differs.
|
||||
ExecStart=/usr/bin/lua /usr/share/lua/5.4/server.lua
|
||||
# Distinct name per host: foo-tools appears in /mcp listings and logs.
|
||||
Environment=LMCP_NAME=CHANGEME-tools
|
||||
Environment=LMCP_PORT=8080
|
||||
# Bearer token. Generate with: openssl rand -hex 24
|
||||
# For untrusted networks, bind to LAN-only via firewall; the server itself
|
||||
# listens on 0.0.0.0 by default.
|
||||
Environment=LMCP_TOKEN=CHANGEME
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -468,7 +468,7 @@ math.randomseed(os.time())
|
||||
|
||||
local server = lmcp.new(os.getenv("LMCP_NAME") or "hub-tools", {
|
||||
port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8090,
|
||||
version = "0.5.3",
|
||||
version = "0.5.4",
|
||||
conf = os.getenv("LMCP_HUB_CONF") or "/opt/herding/etc/lmcp-hub.conf",
|
||||
})
|
||||
|
||||
@@ -505,7 +505,14 @@ server:tool("remote_list_hosts",
|
||||
)
|
||||
end
|
||||
return table.concat(lines, "\n")
|
||||
end
|
||||
end,
|
||||
{ annotations = {
|
||||
title = "List fleet hosts",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_shell", "Run a shell command on a fleet host. lmcp-primary with ssh fallback.",
|
||||
@@ -515,7 +522,14 @@ server:tool("remote_shell", "Run a shell command on a fleet host. lmcp-primary w
|
||||
cwd = { type = "string", description = "Working directory" },
|
||||
timeout = { type = "integer", description = "Timeout (seconds)", default = 120 },
|
||||
}, required = { "host", "command" } },
|
||||
function(a) return call_remote("shell", a, true, ssh_shell) end
|
||||
function(a) return call_remote("shell", a, true, ssh_shell) end,
|
||||
{ annotations = {
|
||||
title = "Remote shell",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_read_file", "Read a file from a fleet host.",
|
||||
@@ -523,7 +537,14 @@ server:tool("remote_read_file", "Read a file from a fleet host.",
|
||||
host = HOST_ARG,
|
||||
path = { type = "string", description = "File path" },
|
||||
}, required = { "host", "path" } },
|
||||
function(a) return call_remote("read_file", a, true, ssh_read_file) end
|
||||
function(a) return call_remote("read_file", a, true, ssh_read_file) end,
|
||||
{ annotations = {
|
||||
title = "Remote read file",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_write_file", "Write content to a file on a fleet host.",
|
||||
@@ -532,7 +553,14 @@ server:tool("remote_write_file", "Write content to a file on a fleet host.",
|
||||
path = { type = "string" },
|
||||
content = { type = "string" },
|
||||
}, required = { "host", "path", "content" } },
|
||||
function(a) return call_remote("write_file", a, true, ssh_write_file) end
|
||||
function(a) return call_remote("write_file", a, true, ssh_write_file) end,
|
||||
{ annotations = {
|
||||
title = "Remote write file",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_edit_file",
|
||||
@@ -544,7 +572,14 @@ server:tool("remote_edit_file",
|
||||
new_string = { type = "string" },
|
||||
replace_all = { type = "boolean", default = false },
|
||||
}, required = { "host", "path", "old_string", "new_string" } },
|
||||
function(a) return call_remote("edit_file", a, false, nil) end
|
||||
function(a) return call_remote("edit_file", a, false, nil) end,
|
||||
{ annotations = {
|
||||
title = "Remote edit file",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_shell_bg",
|
||||
@@ -555,7 +590,14 @@ server:tool("remote_shell_bg",
|
||||
cwd = { type = "string" },
|
||||
log = { type = "string", description = "Log file path" },
|
||||
}, required = { "host", "command" } },
|
||||
function(a) return call_remote("shell_bg", a, false, nil) end
|
||||
function(a) return call_remote("shell_bg", a, false, nil) end,
|
||||
{ annotations = {
|
||||
title = "Remote shell (background)",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_list_dir", "List directory entries on a fleet host.",
|
||||
@@ -563,7 +605,14 @@ server:tool("remote_list_dir", "List directory entries on a fleet host.",
|
||||
host = HOST_ARG,
|
||||
path = { type = "string", default = "." },
|
||||
}, required = { "host" } },
|
||||
function(a) return call_remote("list_dir", a, true, ssh_list_dir) end
|
||||
function(a) return call_remote("list_dir", a, true, ssh_list_dir) end,
|
||||
{ annotations = {
|
||||
title = "Remote list directory",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
server:tool("remote_search_files", "find-by-pattern on a fleet host.",
|
||||
@@ -572,7 +621,14 @@ server:tool("remote_search_files", "find-by-pattern on a fleet host.",
|
||||
pattern = { type = "string" },
|
||||
path = { type = "string", default = "/" },
|
||||
}, required = { "host", "pattern" } },
|
||||
function(a) return call_remote("search_files", a, true, ssh_search_files) end
|
||||
function(a) return call_remote("search_files", a, true, ssh_search_files) end,
|
||||
{ annotations = {
|
||||
title = "Remote find files",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
} }
|
||||
)
|
||||
|
||||
io.stderr:write(string.format("lmcp-hub starting on port %d with %d backends from %s\n",
|
||||
|
||||
@@ -60,6 +60,13 @@ encode_value = function(v)
|
||||
local t = type(v)
|
||||
if v == nil or v == json.null then
|
||||
return 'null'
|
||||
elseif v == json.empty_object then
|
||||
-- Sentinel for forcing {} (object) instead of [] (array) when
|
||||
-- the field semantically requires an object but is empty.
|
||||
-- Without this, every empty Lua table goes through is_array()
|
||||
-- and emits as [], breaking spec-strict JSON-RPC consumers
|
||||
-- (e.g. ping result, MUST be {}).
|
||||
return '{}'
|
||||
elseif t == 'boolean' then
|
||||
return v and 'true' or 'false'
|
||||
elseif t == 'number' then
|
||||
@@ -110,9 +117,20 @@ local function decode_string(s, pos)
|
||||
pos = pos + 1
|
||||
c = s:sub(pos, pos)
|
||||
if c == 'u' then
|
||||
local hex = s:sub(pos + 1, pos + 4)
|
||||
parts[#parts + 1] = utf8.char(tonumber(hex, 16))
|
||||
local cp = tonumber(s:sub(pos + 1, pos + 4), 16)
|
||||
pos = pos + 5
|
||||
-- Combine UTF-16 surrogate pair so non-BMP chars (emoji,
|
||||
-- supplementary CJK) decode correctly instead of as two
|
||||
-- lone surrogates → invalid UTF-8.
|
||||
if cp and cp >= 0xD800 and cp <= 0xDBFF
|
||||
and s:sub(pos, pos + 1) == "\\u" then
|
||||
local lo = tonumber(s:sub(pos + 2, pos + 5), 16)
|
||||
if lo and lo >= 0xDC00 and lo <= 0xDFFF then
|
||||
cp = (cp - 0xD800) * 0x400 + (lo - 0xDC00) + 0x10000
|
||||
pos = pos + 6
|
||||
end
|
||||
end
|
||||
parts[#parts + 1] = utf8.char(cp)
|
||||
else
|
||||
local esc = { n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' }
|
||||
parts[#parts + 1] = esc[c] or c
|
||||
@@ -210,6 +228,12 @@ end
|
||||
-- Sentinel for JSON null
|
||||
json.null = setmetatable({}, { __tostring = function() return 'null' end })
|
||||
|
||||
-- Sentinel for an empty JSON object ({}). Use when a field semantically
|
||||
-- requires an object but is empty — e.g. `ping` result, MCP _meta = {}.
|
||||
-- Without this, an empty Lua table goes through is_array() → '[]'.
|
||||
-- See memory project_json_empty_table_gotcha.md.
|
||||
json.empty_object = setmetatable({}, { __tostring = function() return '{}' end })
|
||||
|
||||
-- Helper: encode a table as a JSON array even if empty
|
||||
function json.array(t)
|
||||
return setmetatable(t or {}, { __is_array = true })
|
||||
|
||||
+699
-11
@@ -168,7 +168,15 @@ server:tool("shell", "Execute a shell command.", {
|
||||
cmd = 'powershell -NoProfile -Command "' .. cmd:gsub('"', '\\"') .. '"'
|
||||
end
|
||||
return run(cmd, a.timeout or 120)
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Run shell",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("shell_bg",
|
||||
"Fire-and-forget shell command (Linux-only). Fully detaches via setsid+nohup+stdio-redirect and returns immediately with PID and log path. Use for daemons that must outlive the lmcp request.",
|
||||
@@ -211,7 +219,15 @@ server:tool("shell_bg",
|
||||
os.remove(pid_file)
|
||||
end
|
||||
return string.format("launched pid=%s log=%s", pid, log)
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Run shell (background)",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("read_file", "Read a file.", {
|
||||
type = "object",
|
||||
@@ -221,7 +237,15 @@ server:tool("read_file", "Read a file.", {
|
||||
local c = read_file(a.path)
|
||||
if not c then return "Error: could not read " .. a.path end
|
||||
return c
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Read file",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("write_file", "Write content to a file.", {
|
||||
type = "object",
|
||||
@@ -235,7 +259,15 @@ server:tool("write_file", "Write content to a file.", {
|
||||
if not f then return "Error: could not write " .. a.path end
|
||||
f:write(a.content); f:close()
|
||||
return string.format("Written %d bytes to %s", #a.content, a.path)
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Write file",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("edit_file", "Replace exact text in a file (literal match). Fails unless old_string is unique, unless replace_all=true.", {
|
||||
type = "object",
|
||||
@@ -289,7 +321,15 @@ server:tool("edit_file", "Replace exact text in a file (literal match). Fails un
|
||||
w:write(table.concat(parts)); w:close()
|
||||
|
||||
return string.format("Edited %s: %d replacement(s)", a.path, replaced)
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Edit file",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("list_dir", "List directory contents.", {
|
||||
type = "object",
|
||||
@@ -301,7 +341,254 @@ server:tool("list_dir", "List directory contents.", {
|
||||
else
|
||||
return run("ls -1 '" .. path:gsub("'", "'\\''") .. "'", 10)
|
||||
end
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "List directory",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
-- ---- fetch: HTTP GET/HEAD with bounded body and optional HTML→plain rendering ----
|
||||
--
|
||||
-- Contract (per Phase 4 plan, issue #3):
|
||||
-- 1. Transfer cap is enforced by curl --max-filesize, not by post-hoc
|
||||
-- slicing. curl aborts mid-stream with exit 63 and the body file
|
||||
-- holds up-to-N bytes (verified Phase 0).
|
||||
-- 2. Curl exit code is recovered via -w "exit=%{exitcode}\n" because
|
||||
-- run() captures stdout-only. Line-anchored parsing because
|
||||
-- run()'s 2>&1 merges curl's stderr into the same stream.
|
||||
-- 3. ok = (exit == 0 or exit == 63). exit 63 is a deliberate
|
||||
-- truncation, not a failure — set truncated=true and ok=true.
|
||||
-- 4. URL whitelist (RFC-3986-ish) rejects whitespace, control chars,
|
||||
-- both quote styles in one shot — no per-platform branching.
|
||||
-- 5. Renderer chain (plain, text/html only): pandoc → lynx → w3m →
|
||||
-- pure-Lua strip. Probe results are process-local cached.
|
||||
-- 6. os.execute return shape differs between Lua 5.1/LuaJIT (number)
|
||||
-- and Lua 5.4 (boolean,...). fetch_have normalises both.
|
||||
-- 7. timeout_s covers fetch *and* render combined.
|
||||
|
||||
local function fetch_html_strip(s)
|
||||
if not s or s == "" then return "" end
|
||||
s = s:gsub("<script.->.-</script>", " ")
|
||||
s = s:gsub("<style.->.-</style>", " ")
|
||||
s = s:gsub("<!%-%-.-%-%->", " ")
|
||||
s = s:gsub("<[^>]+>", " ")
|
||||
local ents = { amp = "&", lt = "<", gt = ">", quot = '"', apos = "'", nbsp = " " }
|
||||
s = s:gsub("&(%a+);", function(n) return ents[n] or ("&" .. n .. ";") end)
|
||||
s = s:gsub("&#(%d+);", function(n) return string.char(tonumber(n)) end)
|
||||
s = s:gsub("&#x(%x+);", function(n) return string.char(tonumber(n, 16)) end)
|
||||
s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return s
|
||||
end
|
||||
|
||||
local _fetch_have_cache = {}
|
||||
local function fetch_have(cmd)
|
||||
local cached = _fetch_have_cache[cmd]
|
||||
if cached ~= nil then return cached end
|
||||
local probe
|
||||
if WINDOWS then
|
||||
probe = "where " .. cmd .. " >NUL 2>&1"
|
||||
else
|
||||
probe = "command -v " .. cmd .. " >/dev/null 2>&1"
|
||||
end
|
||||
local rc = os.execute(probe)
|
||||
if type(rc) == "number" then rc = (rc == 0) end
|
||||
rc = rc and true or false
|
||||
_fetch_have_cache[cmd] = rc
|
||||
return rc
|
||||
end
|
||||
|
||||
local function fetch_safe_url(url)
|
||||
if type(url) ~= "string" or url == "" then
|
||||
return false, "url required"
|
||||
end
|
||||
if not url:match("^https?://") then
|
||||
return false, "url scheme must be http or https"
|
||||
end
|
||||
if not url:match("^https?://[%w%-._~:/?#%[%]@!%$&()*+,;=%%]+$") then
|
||||
return false, "url contains disallowed characters (whitespace, quote, control)"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function fetch_parse_kv(blob)
|
||||
local out = {}
|
||||
for line in blob:gmatch("[^\r\n]+") do
|
||||
local k, v = line:match("^(http_code)=(.*)$")
|
||||
if k then out[k] = v end
|
||||
k, v = line:match("^(content_type)=(.*)$")
|
||||
if k then out[k] = v end
|
||||
k, v = line:match("^(size_download)=(.*)$")
|
||||
if k then out[k] = v end
|
||||
k, v = line:match("^(exit)=(.*)$")
|
||||
if k then out[k] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function fetch_render_plain(body, body_file)
|
||||
-- Try external renderers in order; each receives body_file on stdin.
|
||||
local order = { "pandoc", "lynx", "w3m" }
|
||||
for _, r in ipairs(order) do
|
||||
if fetch_have(r) then
|
||||
local cmd
|
||||
if r == "pandoc" then
|
||||
cmd = "pandoc -f html -t plain"
|
||||
elseif r == "lynx" then
|
||||
cmd = "lynx -stdin -dump -nolist -force_html"
|
||||
else -- w3m
|
||||
cmd = "w3m -dump -T text/html"
|
||||
end
|
||||
local pipe
|
||||
if WINDOWS then
|
||||
pipe = cmd .. ' < "' .. body_file .. '"'
|
||||
else
|
||||
pipe = cmd .. " < '" .. body_file:gsub("'", "'\\''") .. "'"
|
||||
end
|
||||
local out = run(pipe, 15)
|
||||
if out and out ~= "" and not out:match("^Error:") then
|
||||
return out, r
|
||||
end
|
||||
end
|
||||
end
|
||||
return fetch_html_strip(body), "lua-strip"
|
||||
end
|
||||
|
||||
server:tool("fetch",
|
||||
"HTTP GET/HEAD with bounded body and optional HTML→plain rendering. " ..
|
||||
"timeout_s covers the entire fetch+render combined.",
|
||||
{
|
||||
type = "object",
|
||||
properties = {
|
||||
url = { type = "string", description = "http(s) URL" },
|
||||
method = { type = "string", description = "GET or HEAD", default = "GET" },
|
||||
render = { type = "string", description = "plain | html | raw", default = "plain" },
|
||||
max_bytes = { type = "integer", description = "Hard cap on body bytes returned", default = 65536 },
|
||||
timeout_s = { type = "integer", description = "Wall-clock cap for entire call", default = 20 },
|
||||
user_agent = { type = "string", description = "Custom User-Agent", default = "lmcp-fetch/1.0" },
|
||||
},
|
||||
required = { "url" },
|
||||
},
|
||||
function(a)
|
||||
local ok_url, url_err = fetch_safe_url(a.url)
|
||||
if not ok_url then
|
||||
return { ok = false, status = 0, content_type = "", bytes_read = 0,
|
||||
truncated = false, renderer = "raw", body = "", error = url_err }
|
||||
end
|
||||
|
||||
local method = (a.method or "GET"):upper()
|
||||
if method ~= "GET" and method ~= "HEAD" then
|
||||
return { ok = false, status = 0, content_type = "", bytes_read = 0,
|
||||
truncated = false, renderer = "raw", body = "",
|
||||
error = "method must be GET or HEAD" }
|
||||
end
|
||||
|
||||
local render = a.render or "plain"
|
||||
local max_bytes = tonumber(a.max_bytes) or 65536
|
||||
local timeout_s = tonumber(a.timeout_s) or 20
|
||||
local ua = a.user_agent or "lmcp-fetch/1.0"
|
||||
|
||||
local base = tmpname()
|
||||
local hdr_file = base .. ".hdr"
|
||||
local body_file = base .. ".body"
|
||||
|
||||
local wfmt = "http_code=%{http_code}\\ncontent_type=%{content_type}\\nsize_download=%{size_download}\\nexit=%{exitcode}\\n"
|
||||
|
||||
local curl_cmd
|
||||
if WINDOWS then
|
||||
local head_flag = (method == "HEAD") and " -I" or ""
|
||||
curl_cmd = string.format(
|
||||
'curl -sS --proto =http,https%s -X %s --max-time %d --max-filesize %d -A "%s" -D "%s" -o "%s" -w "%s" "%s"',
|
||||
head_flag, method, timeout_s, max_bytes, ua, hdr_file, body_file, wfmt, a.url
|
||||
)
|
||||
else
|
||||
local head_flag = (method == "HEAD") and " -I" or ""
|
||||
curl_cmd = string.format(
|
||||
"curl -sS --proto =http,https%s -X %s --max-time %d --max-filesize %d -A '%s' -D '%s' -o '%s' -w '%s' '%s'",
|
||||
head_flag, method, timeout_s, max_bytes, ua, hdr_file, body_file, wfmt, a.url
|
||||
)
|
||||
end
|
||||
|
||||
local raw_out = run(curl_cmd, timeout_s + 5) or ""
|
||||
local kv = fetch_parse_kv(raw_out)
|
||||
local exit = tonumber(kv.exit or "") or -1
|
||||
local http_code = tonumber(kv.http_code or "0") or 0
|
||||
local content_type = kv.content_type or ""
|
||||
|
||||
local body = ""
|
||||
if method ~= "HEAD" then
|
||||
local bf = io.open(body_file, 'rb')
|
||||
if bf then body = bf:read('*a') or ""; bf:close() end
|
||||
end
|
||||
remove_silent(hdr_file)
|
||||
remove_silent(body_file)
|
||||
|
||||
-- Defensive cap (curl already capped, but enforce on the wire).
|
||||
if #body > max_bytes then body = body:sub(1, max_bytes) end
|
||||
local bytes_read = #body
|
||||
local truncated = (exit == 63)
|
||||
local transport_ok = (exit == 0 or exit == 63)
|
||||
|
||||
if not transport_ok then
|
||||
-- Strip the -w block from raw_out for a clean error message.
|
||||
local err_msg = raw_out:gsub("http_code=[^\n]*\n?", "")
|
||||
:gsub("content_type=[^\n]*\n?", "")
|
||||
:gsub("size_download=[^\n]*\n?", "")
|
||||
:gsub("exit=[^\n]*\n?", "")
|
||||
:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if err_msg == "" then err_msg = "curl exit " .. tostring(exit) end
|
||||
return { ok = false, status = 0, content_type = content_type,
|
||||
bytes_read = 0, truncated = false, renderer = "raw",
|
||||
body = "", error = err_msg }
|
||||
end
|
||||
|
||||
local renderer, out_body
|
||||
if render == "raw" or render == "html" or method == "HEAD" then
|
||||
renderer, out_body = "raw", body
|
||||
elseif render == "plain" then
|
||||
local is_html = content_type:match("text/html") or content_type:match("xml")
|
||||
if is_html and body ~= "" then
|
||||
-- Re-materialise body to a temp for the renderer pipe.
|
||||
local rf = tmpname() .. ".rbody"
|
||||
local f = io.open(rf, 'wb')
|
||||
if f then f:write(body); f:close() end
|
||||
out_body, renderer = fetch_render_plain(body, rf)
|
||||
remove_silent(rf)
|
||||
else
|
||||
renderer, out_body = "raw", body
|
||||
end
|
||||
else
|
||||
return { ok = false, status = 0, content_type = content_type,
|
||||
bytes_read = 0, truncated = false, renderer = "raw",
|
||||
body = "", error = "render must be plain, html, or raw" }
|
||||
end
|
||||
|
||||
if #out_body > max_bytes then out_body = out_body:sub(1, max_bytes) end
|
||||
|
||||
return {
|
||||
ok = true,
|
||||
status = http_code,
|
||||
content_type = content_type,
|
||||
bytes_read = bytes_read,
|
||||
truncated = truncated,
|
||||
renderer = renderer,
|
||||
body = out_body,
|
||||
}
|
||||
end, {
|
||||
annotations = {
|
||||
title = "HTTP GET/HEAD",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
-- Idempotent in MCP sense: the tool itself has no effect on
|
||||
-- its own environment. World-side variability is conveyed
|
||||
-- by openWorldHint.
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("search_files", "Search for files by pattern.", {
|
||||
type = "object",
|
||||
@@ -320,14 +607,415 @@ server:tool("search_files", "Search for files by pattern.", {
|
||||
-- (common on Homebrew, e.g. /usr/local/share/lua -> Cellar/…/share/lua).
|
||||
return run("find -L '" .. path:gsub("'", "'\\''") .. "' -name '" .. a.pattern:gsub("'", "'\\''") .. "' 2>/dev/null", 30)
|
||||
end
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Find files by pattern",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
-- ---- web_search: pluggable-backend search with normalised result shape ----
|
||||
--
|
||||
-- Contract (per Phase 4 plan + Phase 5 review actions, issue #4):
|
||||
-- 1. Backend selection: explicit LMCP_SEARCH_BACKEND (lower+trim) wins;
|
||||
-- else first-present of SEARXNG_URL, TAVILY_API_KEY, BRAVE_API_KEY;
|
||||
-- else "ddg" zero-config.
|
||||
-- 2. Result envelope is always:
|
||||
-- { ok, backend, query, results=[{title,url,snippet,age?}], error? }
|
||||
-- On failure: ok=false, results=[], error=string.
|
||||
-- 3. DDG is best-effort. The HTML endpoint serves anti-bot 202 pages
|
||||
-- from many IP ranges; when the parser matches 0 results from a
|
||||
-- 200/202, surface a structured "parser found 0" error rather
|
||||
-- than a silent empty list.
|
||||
-- 4. DDG parser iterates per-result-block, not per-class globally —
|
||||
-- otherwise a missing snippet shifts later snippets onto wrong titles.
|
||||
-- 5. DDG result URLs are unwrapped from /l/?uddg=<URLENCODED>. If
|
||||
-- unwrap fails (no uddg= or non-http(s) result), the row is dropped.
|
||||
-- 6. JSON backends (searxng/tavily/brave) use json.decode under pcall.
|
||||
-- json.lua patched in this issue to combine UTF-16 surrogate pairs
|
||||
-- so emoji/non-BMP CJK in snippets render correctly.
|
||||
-- 7. Tavily uses Authorization: Bearer <key> header, not body, so the
|
||||
-- key never lands in a tempfile.
|
||||
-- 8. URL query strings are RFC-3986 unreserved-only encoded. After
|
||||
-- encoding, the only attacker-controlled portion is shell-safe
|
||||
-- inside single quotes.
|
||||
|
||||
local function ws_url_encode(s)
|
||||
return (s:gsub("([^%w%-._~])", function(c)
|
||||
return string.format("%%%02X", string.byte(c))
|
||||
end))
|
||||
end
|
||||
|
||||
local function ws_url_decode(s)
|
||||
s = s:gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
|
||||
return s
|
||||
end
|
||||
|
||||
local function ws_ddg_unwrap(href)
|
||||
-- href shape: //duckduckgo.com/l/?uddg=<URLENC>&rut=<hex>
|
||||
-- & in raw HTML; pattern strips the entity first.
|
||||
href = href:gsub("&", "&")
|
||||
local enc = href:match("[?&]uddg=([^&]+)")
|
||||
if not enc then return nil end
|
||||
local decoded = ws_url_decode(enc)
|
||||
if not decoded:match("^https?://") then return nil end
|
||||
return decoded
|
||||
end
|
||||
|
||||
local function ws_safe_envurl(url)
|
||||
if not url or url == "" then return false, "url empty" end
|
||||
if not url:match("^https?://") then return false, "url scheme must be http(s)" end
|
||||
if not url:match("^https?://[%w%-._~:/?#%[%]@!%$&()*+,;=%%]+$") then
|
||||
return false, "url contains disallowed characters"
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function ws_safe_key(s)
|
||||
if not s or s == "" then return false, "empty" end
|
||||
if s:find("['\"\n\r]") then return false, "contains quote or newline" end
|
||||
return true
|
||||
end
|
||||
|
||||
local function ws_curl_run(curl_cmd, body_file, timeout_s)
|
||||
local raw_out = run(curl_cmd, timeout_s + 5) or ""
|
||||
local http_code = tonumber(raw_out:match("http_code=(%d+)") or "0") or 0
|
||||
local exit = tonumber(raw_out:match("exit=(%-?%d+)") or "-1") or -1
|
||||
local body = ""
|
||||
local bf = io.open(body_file, 'rb')
|
||||
if bf then body = bf:read('*a') or ""; bf:close() end
|
||||
remove_silent(body_file)
|
||||
return body, http_code, exit, raw_out
|
||||
end
|
||||
|
||||
local function ws_curl_err(raw_out, http_code, exit, default)
|
||||
local err = raw_out:gsub("http_code=[^\n]*\n?", "")
|
||||
:gsub("exit=[^\n]*\n?", "")
|
||||
:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if err ~= "" then return err end
|
||||
if http_code ~= 0 and http_code ~= 200 then
|
||||
return string.format("HTTP %d", http_code)
|
||||
end
|
||||
return default or ("curl exit " .. tostring(exit))
|
||||
end
|
||||
|
||||
-- ---- DDG (HTML scrape, zero-config) ----
|
||||
local function ws_ddg(query, n, region, time_range, safesearch)
|
||||
local kp = ({off = -2, moderate = -1, strict = 1})[safesearch] or -1
|
||||
local df = ({day = "d", week = "w", month = "m", year = "y"})[time_range or ""] or ""
|
||||
local url = "https://html.duckduckgo.com/html/?q=" .. ws_url_encode(query)
|
||||
.. "&kp=" .. tostring(kp)
|
||||
if df ~= "" then url = url .. "&df=" .. df end
|
||||
if region and region ~= "" then url = url .. "&kl=" .. ws_url_encode(region) end
|
||||
|
||||
local body_file = tmpname() .. ".body"
|
||||
local wfmt = "http_code=%{http_code}\\nexit=%{exitcode}\\n"
|
||||
local cmd
|
||||
if WINDOWS then
|
||||
cmd = string.format(
|
||||
'curl -sS --proto =https --max-time 15 -A "lmcp-search/1.0" -o "%s" -w "%s" "%s"',
|
||||
body_file, wfmt, url)
|
||||
else
|
||||
cmd = string.format(
|
||||
"curl -sS --proto =https --max-time 15 -A 'lmcp-search/1.0' -o '%s' -w '%s' '%s'",
|
||||
body_file, wfmt, url)
|
||||
end
|
||||
local body, http_code, exit, raw = ws_curl_run(cmd, body_file, 15)
|
||||
if exit ~= 0 then
|
||||
return nil, ws_curl_err(raw, http_code, exit, "ddg request failed")
|
||||
end
|
||||
|
||||
-- Per-result-block iteration (avoids title↔snippet mispairing).
|
||||
-- Split on the opening <div class="result results_links"… boundary
|
||||
-- rather than on close-tag depth — DDG nests multiple <div>s inside
|
||||
-- each block, so a fixed close-tag pattern is fragile.
|
||||
local block_pat = '<div class="result results_links[^"]-"[^>]*>'
|
||||
local positions = {}
|
||||
for s in body:gmatch("()" .. block_pat) do positions[#positions + 1] = s end
|
||||
positions[#positions + 1] = #body + 1 -- sentinel end-of-body
|
||||
|
||||
local results = {}
|
||||
for i = 1, #positions - 1 do
|
||||
local block = body:sub(positions[i], positions[i + 1] - 1)
|
||||
local href, title_raw = block:match('<a[^>]-class="result__a"[^>]-href="([^"]+)"[^>]*>(.-)</a>')
|
||||
if href and title_raw then
|
||||
local real_url = ws_ddg_unwrap(href)
|
||||
if real_url then
|
||||
local snip_raw = block:match('<a[^>]-class="result__snippet"[^>]*>(.-)</a>') or ""
|
||||
local title = fetch_html_strip(title_raw):sub(1, 200)
|
||||
local snippet = fetch_html_strip(snip_raw):sub(1, 280)
|
||||
results[#results + 1] = { title = title, url = real_url, snippet = snippet }
|
||||
if #results >= n then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #results == 0 then
|
||||
return nil, "ddg parser matched no results (anti-bot challenge or markup change)"
|
||||
end
|
||||
return results, nil
|
||||
end
|
||||
|
||||
-- ---- SearXNG (JSON) ----
|
||||
local function ws_searxng(query, n, region, time_range, safesearch)
|
||||
local base = os.getenv("SEARXNG_URL")
|
||||
if not base or base == "" then return nil, "searxng requires SEARXNG_URL" end
|
||||
base = base:gsub("/+$", "")
|
||||
local ok, errmsg = ws_safe_envurl(base)
|
||||
if not ok then return nil, "SEARXNG_URL: " .. errmsg end
|
||||
|
||||
local ss_map = { off = 0, moderate = 1, strict = 2 }
|
||||
local url = base .. "/search?q=" .. ws_url_encode(query)
|
||||
.. "&format=json&safesearch=" .. tostring(ss_map[safesearch] or 1)
|
||||
if time_range and time_range ~= "" then
|
||||
url = url .. "&time_range=" .. ws_url_encode(time_range)
|
||||
end
|
||||
if region and region ~= "" then
|
||||
url = url .. "&language=" .. ws_url_encode(region)
|
||||
end
|
||||
|
||||
local body_file = tmpname() .. ".body"
|
||||
local wfmt = "http_code=%{http_code}\\nexit=%{exitcode}\\n"
|
||||
local cmd
|
||||
if WINDOWS then
|
||||
cmd = string.format(
|
||||
'curl -sS --proto =https --max-time 15 -A "lmcp-search/1.0" -o "%s" -w "%s" "%s"',
|
||||
body_file, wfmt, url)
|
||||
else
|
||||
cmd = string.format(
|
||||
"curl -sS --proto =https --max-time 15 -A 'lmcp-search/1.0' -o '%s' -w '%s' '%s'",
|
||||
body_file, wfmt, url)
|
||||
end
|
||||
local body, http_code, exit, raw = ws_curl_run(cmd, body_file, 15)
|
||||
if exit ~= 0 then
|
||||
return nil, ws_curl_err(raw, http_code, exit, "searxng request failed")
|
||||
end
|
||||
if http_code ~= 200 then
|
||||
return nil, string.format("searxng HTTP %d", http_code)
|
||||
end
|
||||
|
||||
local pj_ok, d = pcall(require('json').decode, body)
|
||||
if not pj_ok or type(d) ~= "table" or type(d.results) ~= "table" then
|
||||
return nil, "searxng response is not valid JSON or missing 'results'"
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, r in ipairs(d.results) do
|
||||
if r.url and r.url ~= "" then
|
||||
out[#out + 1] = {
|
||||
title = (r.title or ""):sub(1, 200),
|
||||
url = r.url,
|
||||
snippet = (r.content or ""):sub(1, 280),
|
||||
age = r.publishedDate,
|
||||
}
|
||||
if #out >= n then break end
|
||||
end
|
||||
end
|
||||
if #out == 0 then
|
||||
return nil, "searxng returned 0 results"
|
||||
end
|
||||
return out, nil
|
||||
end
|
||||
|
||||
-- ---- Tavily (JSON POST) ----
|
||||
local function ws_tavily(query, n)
|
||||
local key = os.getenv("TAVILY_API_KEY")
|
||||
if not key or key == "" then return nil, "tavily requires TAVILY_API_KEY" end
|
||||
local ok, errmsg = ws_safe_key(key)
|
||||
if not ok then return nil, "TAVILY_API_KEY: " .. errmsg end
|
||||
|
||||
local body_in = string.format(
|
||||
'{"query":%s,"max_results":%d,"search_depth":"basic","include_answer":false}',
|
||||
require('json').encode(query), n)
|
||||
|
||||
local in_file = tmpname() .. ".json"
|
||||
local out_file = tmpname() .. ".body"
|
||||
local fw = io.open(in_file, 'wb')
|
||||
if not fw then return nil, "could not write tavily request body" end
|
||||
fw:write(body_in); fw:close()
|
||||
|
||||
local wfmt = "http_code=%{http_code}\\nexit=%{exitcode}\\n"
|
||||
local cmd
|
||||
if WINDOWS then
|
||||
cmd = string.format(
|
||||
'curl -sS --proto =https --max-time 20 -X POST -H "Content-Type: application/json" -H "Authorization: Bearer %s" --data-binary "@%s" -o "%s" -w "%s" "https://api.tavily.com/search"',
|
||||
key, in_file, out_file, wfmt)
|
||||
else
|
||||
cmd = string.format(
|
||||
"curl -sS --proto =https --max-time 20 -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' --data-binary '@%s' -o '%s' -w '%s' 'https://api.tavily.com/search'",
|
||||
key, in_file, out_file, wfmt)
|
||||
end
|
||||
local body, http_code, exit, raw = ws_curl_run(cmd, out_file, 20)
|
||||
remove_silent(in_file)
|
||||
if exit ~= 0 then
|
||||
return nil, ws_curl_err(raw, http_code, exit, "tavily request failed")
|
||||
end
|
||||
if http_code ~= 200 then
|
||||
return nil, string.format("tavily HTTP %d", http_code)
|
||||
end
|
||||
|
||||
local pj_ok, d = pcall(require('json').decode, body)
|
||||
if not pj_ok or type(d) ~= "table" or type(d.results) ~= "table" then
|
||||
return nil, "tavily response is not valid JSON or missing 'results'"
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, r in ipairs(d.results) do
|
||||
if r.url and r.url ~= "" then
|
||||
out[#out + 1] = {
|
||||
title = (r.title or ""):sub(1, 200),
|
||||
url = r.url,
|
||||
snippet = (r.content or ""):sub(1, 280),
|
||||
}
|
||||
if #out >= n then break end
|
||||
end
|
||||
end
|
||||
if #out == 0 then return nil, "tavily returned 0 results" end
|
||||
return out, nil
|
||||
end
|
||||
|
||||
-- ---- Brave Search (JSON GET, header auth) ----
|
||||
local function ws_brave(query, n, region, safesearch)
|
||||
local key = os.getenv("BRAVE_API_KEY")
|
||||
if not key or key == "" then return nil, "brave requires BRAVE_API_KEY" end
|
||||
local ok, errmsg = ws_safe_key(key)
|
||||
if not ok then return nil, "BRAVE_API_KEY: " .. errmsg end
|
||||
|
||||
local url = "https://api.search.brave.com/res/v1/web/search?q=" .. ws_url_encode(query)
|
||||
.. "&count=" .. tostring(n)
|
||||
.. "&safesearch=" .. (safesearch or "moderate")
|
||||
if region and region ~= "" then url = url .. "&country=" .. ws_url_encode(region) end
|
||||
|
||||
local body_file = tmpname() .. ".body"
|
||||
local wfmt = "http_code=%{http_code}\\nexit=%{exitcode}\\n"
|
||||
local cmd
|
||||
if WINDOWS then
|
||||
cmd = string.format(
|
||||
'curl -sS --proto =https --max-time 15 -A "lmcp-search/1.0" -H "Accept: application/json" -H "X-Subscription-Token: %s" -o "%s" -w "%s" "%s"',
|
||||
key, body_file, wfmt, url)
|
||||
else
|
||||
cmd = string.format(
|
||||
"curl -sS --proto =https --max-time 15 -A 'lmcp-search/1.0' -H 'Accept: application/json' -H 'X-Subscription-Token: %s' -o '%s' -w '%s' '%s'",
|
||||
key, body_file, wfmt, url)
|
||||
end
|
||||
local body, http_code, exit, raw = ws_curl_run(cmd, body_file, 15)
|
||||
if exit ~= 0 then
|
||||
return nil, ws_curl_err(raw, http_code, exit, "brave request failed")
|
||||
end
|
||||
if http_code ~= 200 then
|
||||
return nil, string.format("brave HTTP %d", http_code)
|
||||
end
|
||||
|
||||
local pj_ok, d = pcall(require('json').decode, body)
|
||||
if not pj_ok or type(d) ~= "table" or type(d.web) ~= "table" or type(d.web.results) ~= "table" then
|
||||
return nil, "brave response is not valid JSON or missing 'web.results'"
|
||||
end
|
||||
|
||||
local out = {}
|
||||
for _, r in ipairs(d.web.results) do
|
||||
if r.url and r.url ~= "" then
|
||||
out[#out + 1] = {
|
||||
title = (r.title or ""):sub(1, 200),
|
||||
url = r.url,
|
||||
snippet = (r.description or ""):sub(1, 280),
|
||||
age = r.age,
|
||||
}
|
||||
if #out >= n then break end
|
||||
end
|
||||
end
|
||||
if #out == 0 then return nil, "brave returned 0 results" end
|
||||
return out, nil
|
||||
end
|
||||
|
||||
local function ws_pick_backend()
|
||||
local explicit = os.getenv("LMCP_SEARCH_BACKEND") or ""
|
||||
explicit = explicit:lower():match("^%s*(.-)%s*$") or ""
|
||||
if explicit ~= "" then return explicit end
|
||||
if (os.getenv("SEARXNG_URL") or "") ~= "" then return "searxng" end
|
||||
if (os.getenv("TAVILY_API_KEY") or "") ~= "" then return "tavily" end
|
||||
if (os.getenv("BRAVE_API_KEY") or "") ~= "" then return "brave" end
|
||||
return "ddg"
|
||||
end
|
||||
|
||||
server:tool("web_search",
|
||||
"Web search returning [{title, url, snippet, age?}]. Backend selected " ..
|
||||
"via LMCP_SEARCH_BACKEND env (searxng|tavily|brave|ddg); auto-picks the " ..
|
||||
"first configured backend, falling back to ddg (best-effort, often anti-bot blocked).",
|
||||
{
|
||||
type = "object",
|
||||
properties = {
|
||||
query = { type = "string", description = "Search query" },
|
||||
max_results = { type = "integer", description = "1..25", default = 8 },
|
||||
region = { type = "string", description = "Backend-specific locale (e.g. 'de-de')", default = "" },
|
||||
time_range = { type = "string", description = "'' | day | week | month | year", default = "" },
|
||||
safesearch = { type = "string", description = "off | moderate | strict", default = "moderate" },
|
||||
},
|
||||
required = { "query" },
|
||||
},
|
||||
function(a)
|
||||
local query = (a.query or ""):match("^%s*(.-)%s*$") or ""
|
||||
if query == "" then
|
||||
return { ok = false, backend = "", query = "", results = {}, error = "query required" }
|
||||
end
|
||||
local n = tonumber(a.max_results) or 8
|
||||
if n < 1 then n = 1 elseif n > 25 then n = 25 end
|
||||
|
||||
local backend = ws_pick_backend()
|
||||
local region, time_range, safesearch = a.region or "", a.time_range or "", a.safesearch or "moderate"
|
||||
|
||||
local results, err
|
||||
if backend == "ddg" then
|
||||
results, err = ws_ddg(query, n, region, time_range, safesearch)
|
||||
elseif backend == "searxng" then
|
||||
results, err = ws_searxng(query, n, region, time_range, safesearch)
|
||||
elseif backend == "tavily" then
|
||||
results, err = ws_tavily(query, n)
|
||||
elseif backend == "brave" then
|
||||
results, err = ws_brave(query, n, region, safesearch)
|
||||
else
|
||||
return { ok = false, backend = backend, query = query, results = {},
|
||||
error = "unknown backend: " .. backend }
|
||||
end
|
||||
|
||||
if err then
|
||||
return { ok = false, backend = backend, query = query, results = {}, error = err }
|
||||
end
|
||||
return { ok = true, backend = backend, query = query, results = results }
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Web search",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = true,
|
||||
},
|
||||
})
|
||||
|
||||
if WINDOWS then
|
||||
server:tool("systeminfo", "Get Windows system information.", {
|
||||
type = "object", properties = {},
|
||||
}, function() return run("systeminfo", 30) end)
|
||||
}, function() return run("systeminfo", 30) end, {
|
||||
annotations = {
|
||||
title = "Windows system info",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
io.stderr:write(string.format("lmcp %s starting on port %d (%s)\n",
|
||||
server_name, server.port, WINDOWS and "Windows" or "POSIX"))
|
||||
server:run()
|
||||
local transport = os.getenv("LMCP_TRANSPORT") or "http"
|
||||
if transport == "stdio" then
|
||||
if os.getenv("LMCP_PORT") then
|
||||
io.stderr:write("lmcp: LMCP_PORT ignored in stdio mode\n")
|
||||
end
|
||||
server:run_stdio()
|
||||
else
|
||||
io.stderr:write(string.format("lmcp %s starting on port %d (%s)\n",
|
||||
server_name, server.port, WINDOWS and "Windows" or "POSIX"))
|
||||
server:run()
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user