Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e62f71931 | |||
| 55ead8041f | |||
| 2ac502e50f | |||
| deb73d129e | |||
| b81b021b5b |
+13
@@ -0,0 +1,13 @@
|
|||||||
|
# Generated by windows/sync.sh — see windows/README.md
|
||||||
|
windows/pkg/lmcp.lua
|
||||||
|
windows/pkg/server.lua
|
||||||
|
windows/pkg/json.lua
|
||||||
|
|
||||||
|
# Bundled Lua + LuaSocket runtime for the Windows MSI; downloaded
|
||||||
|
# separately, not in git.
|
||||||
|
windows/pkg/lua/
|
||||||
|
|
||||||
|
# Editor / OS noise
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
+109
-3
@@ -11,6 +11,10 @@ local server = lmcp.new("example-tools", {
|
|||||||
port = tonumber(arg[1]) or 8080,
|
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", {
|
server:tool("shell", "Execute a shell command", {
|
||||||
type = "object",
|
type = "object",
|
||||||
properties = {
|
properties = {
|
||||||
@@ -24,7 +28,15 @@ server:tool("shell", "Execute a shell command", {
|
|||||||
local result = handle:read('*a')
|
local result = handle:read('*a')
|
||||||
handle:close()
|
handle:close()
|
||||||
return result ~= '' and result or '(no output)'
|
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", {
|
server:tool("read_file", "Read a file", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -38,7 +50,15 @@ server:tool("read_file", "Read a file", {
|
|||||||
local content = f:read('*a')
|
local content = f:read('*a')
|
||||||
f:close()
|
f:close()
|
||||||
return content
|
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", {
|
server:tool("write_file", "Write content to a file", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -53,7 +73,15 @@ server:tool("write_file", "Write content to a file", {
|
|||||||
f:write(args.content)
|
f:write(args.content)
|
||||||
f:close()
|
f:close()
|
||||||
return string.format("Written %d bytes to %s", #args.content, args.path)
|
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", {
|
server:tool("list_dir", "List directory contents", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -67,7 +95,85 @@ server:tool("list_dir", "List directory contents", {
|
|||||||
local result = handle:read('*a')
|
local result = handle:read('*a')
|
||||||
handle:close()
|
handle:close()
|
||||||
return result
|
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)
|
end)
|
||||||
|
|
||||||
|
-- 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")
|
io.stderr:write("Starting lmcp example server...\n")
|
||||||
server:run()
|
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", {
|
local server = lmcp.new(os.getenv("LMCP_NAME") or "hub-tools", {
|
||||||
port = tonumber(os.getenv("LMCP_PORT") or arg[1]) or 8090,
|
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",
|
conf = os.getenv("LMCP_HUB_CONF") or "/opt/herding/etc/lmcp-hub.conf",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -505,7 +505,14 @@ server:tool("remote_list_hosts",
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
return table.concat(lines, "\n")
|
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.",
|
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" },
|
cwd = { type = "string", description = "Working directory" },
|
||||||
timeout = { type = "integer", description = "Timeout (seconds)", default = 120 },
|
timeout = { type = "integer", description = "Timeout (seconds)", default = 120 },
|
||||||
}, required = { "host", "command" } },
|
}, 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.",
|
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,
|
host = HOST_ARG,
|
||||||
path = { type = "string", description = "File path" },
|
path = { type = "string", description = "File path" },
|
||||||
}, required = { "host", "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.",
|
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" },
|
path = { type = "string" },
|
||||||
content = { type = "string" },
|
content = { type = "string" },
|
||||||
}, required = { "host", "path", "content" } },
|
}, 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",
|
server:tool("remote_edit_file",
|
||||||
@@ -544,7 +572,14 @@ server:tool("remote_edit_file",
|
|||||||
new_string = { type = "string" },
|
new_string = { type = "string" },
|
||||||
replace_all = { type = "boolean", default = false },
|
replace_all = { type = "boolean", default = false },
|
||||||
}, required = { "host", "path", "old_string", "new_string" } },
|
}, 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",
|
server:tool("remote_shell_bg",
|
||||||
@@ -555,7 +590,14 @@ server:tool("remote_shell_bg",
|
|||||||
cwd = { type = "string" },
|
cwd = { type = "string" },
|
||||||
log = { type = "string", description = "Log file path" },
|
log = { type = "string", description = "Log file path" },
|
||||||
}, required = { "host", "command" } },
|
}, 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.",
|
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,
|
host = HOST_ARG,
|
||||||
path = { type = "string", default = "." },
|
path = { type = "string", default = "." },
|
||||||
}, required = { "host" } },
|
}, 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.",
|
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" },
|
pattern = { type = "string" },
|
||||||
path = { type = "string", default = "/" },
|
path = { type = "string", default = "/" },
|
||||||
}, required = { "host", "pattern" } },
|
}, 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",
|
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)
|
local t = type(v)
|
||||||
if v == nil or v == json.null then
|
if v == nil or v == json.null then
|
||||||
return 'null'
|
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
|
elseif t == 'boolean' then
|
||||||
return v and 'true' or 'false'
|
return v and 'true' or 'false'
|
||||||
elseif t == 'number' then
|
elseif t == 'number' then
|
||||||
@@ -110,9 +117,20 @@ local function decode_string(s, pos)
|
|||||||
pos = pos + 1
|
pos = pos + 1
|
||||||
c = s:sub(pos, pos)
|
c = s:sub(pos, pos)
|
||||||
if c == 'u' then
|
if c == 'u' then
|
||||||
local hex = s:sub(pos + 1, pos + 4)
|
local cp = tonumber(s:sub(pos + 1, pos + 4), 16)
|
||||||
parts[#parts + 1] = utf8.char(tonumber(hex, 16))
|
|
||||||
pos = pos + 5
|
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
|
else
|
||||||
local esc = { n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' }
|
local esc = { n = '\n', r = '\r', t = '\t', b = '\b', f = '\f' }
|
||||||
parts[#parts + 1] = esc[c] or c
|
parts[#parts + 1] = esc[c] or c
|
||||||
@@ -210,6 +228,12 @@ end
|
|||||||
-- Sentinel for JSON null
|
-- Sentinel for JSON null
|
||||||
json.null = setmetatable({}, { __tostring = function() return 'null' end })
|
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
|
-- Helper: encode a table as a JSON array even if empty
|
||||||
function json.array(t)
|
function json.array(t)
|
||||||
return setmetatable(t or {}, { __is_array = true })
|
return setmetatable(t or {}, { __is_array = true })
|
||||||
|
|||||||
+775
-29
@@ -35,7 +35,51 @@ local function tmpname()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Lazy-required luasocket — only needed in the coroutine path for
|
||||||
|
-- gettime(). Avoids forcing luasocket as a hard dep at server.lua
|
||||||
|
-- load time (callers like example_server already require it via lmcp).
|
||||||
|
local _socket = nil
|
||||||
|
local function gettime()
|
||||||
|
if not _socket then _socket = require("socket") end
|
||||||
|
return _socket.gettime()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lazy access to the lmcp module for cross-module ctx lookup (issue #11).
|
||||||
|
-- server.lua doesn't statically require lmcp (it's an example/runtime
|
||||||
|
-- server, not the library); but lmcp must already be loaded when we run.
|
||||||
|
-- Defensive: if the lookup fails for any reason, current_ctx returns nil
|
||||||
|
-- and run() falls back to non-cancellable behaviour.
|
||||||
|
local _lmcp_mod = nil
|
||||||
|
local function current_ctx()
|
||||||
|
if _lmcp_mod == false then return nil end
|
||||||
|
if _lmcp_mod == nil then
|
||||||
|
local ok, mod = pcall(require, "lmcp")
|
||||||
|
_lmcp_mod = ok and mod or false
|
||||||
|
if _lmcp_mod == false then return nil end
|
||||||
|
end
|
||||||
|
return _lmcp_mod.current_ctx and _lmcp_mod.current_ctx() or nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- in_coroutine() — true if we're running inside an lmcp dispatch
|
||||||
|
-- coroutine (issue #20). Handles both Lua 5.4 (coroutine.running →
|
||||||
|
-- (co, isMain)) and LuaJIT 5.1 (coroutine.running → nil on main).
|
||||||
|
local function in_coroutine()
|
||||||
|
local co, is_main = coroutine.running()
|
||||||
|
if co == nil then return false end -- 5.1 / LuaJIT main
|
||||||
|
if is_main then return false end -- 5.4 main thread
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
local function sleep_ms(ms)
|
local function sleep_ms(ms)
|
||||||
|
-- Coroutine-aware: yield with a wake deadline instead of busy-blocking.
|
||||||
|
-- The lmcp event loop services I/O for other connections while this
|
||||||
|
-- coroutine sleeps, then resumes it once the deadline elapses.
|
||||||
|
-- (Issue #20: gives concurrent tool dispatch without changing handler
|
||||||
|
-- source code — tools that go through run() get it for free.)
|
||||||
|
if in_coroutine() then
|
||||||
|
coroutine.yield({ wake_at = gettime() + (ms / 1000) })
|
||||||
|
return
|
||||||
|
end
|
||||||
if WINDOWS then
|
if WINDOWS then
|
||||||
-- ping loopback: ~1s per -n count. For sub-second, use busy-wait.
|
-- ping loopback: ~1s per -n count. For sub-second, use busy-wait.
|
||||||
if ms < 500 then
|
if ms < 500 then
|
||||||
@@ -78,6 +122,35 @@ local function run(cmd, timeout_sec)
|
|||||||
local out_file = base .. ".out"
|
local out_file = base .. ".out"
|
||||||
local done_file = base .. ".done"
|
local done_file = base .. ".done"
|
||||||
|
|
||||||
|
-- Wall-clock deadline rather than an accumulated interval-counter:
|
||||||
|
-- when we're inside a dispatch coroutine (issue #20), the scheduler
|
||||||
|
-- may delay our resume by more than `interval`, so an accumulator
|
||||||
|
-- diverges from real elapsed. gettime() comparison stays honest in
|
||||||
|
-- both busy-poll and yield-resume modes.
|
||||||
|
--
|
||||||
|
-- Auto-cancellation (issue #11): if a ctx is available on the
|
||||||
|
-- running coroutine AND it has been cancelled, exit the polling
|
||||||
|
-- loop early. The interval is capped at 500ms when a ctx is
|
||||||
|
-- present so worst-case cancel latency is ~0.5s, not ~2s.
|
||||||
|
local started = gettime()
|
||||||
|
local cancelled = false
|
||||||
|
local function poll_loop()
|
||||||
|
local interval = WINDOWS and 100 or 50 -- ms
|
||||||
|
while gettime() - started < timeout_sec do
|
||||||
|
if file_exists(done_file) then return true end
|
||||||
|
local ctx = current_ctx()
|
||||||
|
if ctx and ctx.cancelled and ctx.cancelled() then
|
||||||
|
cancelled = true
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
sleep_ms(interval)
|
||||||
|
if interval < 2000 then interval = math.floor(interval * 1.5) end
|
||||||
|
-- When cancellable, cap so we can respond to cancel quickly.
|
||||||
|
if ctx and interval > 500 then interval = 500 end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
if WINDOWS then
|
if WINDOWS then
|
||||||
-- Write a batch wrapper that runs the command and signals completion
|
-- Write a batch wrapper that runs the command and signals completion
|
||||||
local bat_file = base .. ".bat"
|
local bat_file = base .. ".bat"
|
||||||
@@ -89,22 +162,14 @@ local function run(cmd, timeout_sec)
|
|||||||
bf:close()
|
bf:close()
|
||||||
os.execute('start /B cmd /C "' .. bat_file .. '"')
|
os.execute('start /B cmd /C "' .. bat_file .. '"')
|
||||||
|
|
||||||
-- Poll for sentinel
|
local completed = poll_loop()
|
||||||
local elapsed = 0
|
|
||||||
local interval = 100 -- ms
|
|
||||||
while elapsed < timeout_sec * 1000 do
|
|
||||||
if file_exists(done_file) then break end
|
|
||||||
sleep_ms(interval)
|
|
||||||
elapsed = elapsed + interval
|
|
||||||
if interval < 2000 then interval = math.floor(interval * 1.5) end
|
|
||||||
end
|
|
||||||
|
|
||||||
local output = read_file(out_file)
|
local output = read_file(out_file)
|
||||||
remove_silent(bat_file)
|
remove_silent(bat_file)
|
||||||
remove_silent(out_file)
|
remove_silent(out_file)
|
||||||
remove_silent(done_file)
|
remove_silent(done_file)
|
||||||
|
|
||||||
if elapsed >= timeout_sec * 1000 then
|
if not completed then
|
||||||
|
if cancelled then return "(cancelled)" end
|
||||||
return output or ("Error: command timed out after " .. timeout_sec .. "s")
|
return output or ("Error: command timed out after " .. timeout_sec .. "s")
|
||||||
end
|
end
|
||||||
return output and output ~= "" and output or "(no output)"
|
return output and output ~= "" and output or "(no output)"
|
||||||
@@ -117,20 +182,13 @@ local function run(cmd, timeout_sec)
|
|||||||
)
|
)
|
||||||
os.execute("sh -c '" .. sh_cmd:gsub("'", "'\\''") .. "' &")
|
os.execute("sh -c '" .. sh_cmd:gsub("'", "'\\''") .. "' &")
|
||||||
|
|
||||||
local elapsed = 0
|
local completed = poll_loop()
|
||||||
local interval = 50 -- ms
|
|
||||||
while elapsed < timeout_sec * 1000 do
|
|
||||||
if file_exists(done_file) then break end
|
|
||||||
sleep_ms(interval)
|
|
||||||
elapsed = elapsed + interval
|
|
||||||
if interval < 2000 then interval = math.floor(interval * 1.5) end
|
|
||||||
end
|
|
||||||
|
|
||||||
local output = read_file(out_file)
|
local output = read_file(out_file)
|
||||||
remove_silent(out_file)
|
remove_silent(out_file)
|
||||||
remove_silent(done_file)
|
remove_silent(done_file)
|
||||||
|
|
||||||
if elapsed >= timeout_sec * 1000 then
|
if not completed then
|
||||||
|
if cancelled then return "(cancelled)" end
|
||||||
return output or ("Error: command timed out after " .. timeout_sec .. "s")
|
return output or ("Error: command timed out after " .. timeout_sec .. "s")
|
||||||
end
|
end
|
||||||
return output and output ~= "" and output or "(no output)"
|
return output and output ~= "" and output or "(no output)"
|
||||||
@@ -168,7 +226,15 @@ server:tool("shell", "Execute a shell command.", {
|
|||||||
cmd = 'powershell -NoProfile -Command "' .. cmd:gsub('"', '\\"') .. '"'
|
cmd = 'powershell -NoProfile -Command "' .. cmd:gsub('"', '\\"') .. '"'
|
||||||
end
|
end
|
||||||
return run(cmd, a.timeout or 120)
|
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",
|
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.",
|
"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 +277,15 @@ server:tool("shell_bg",
|
|||||||
os.remove(pid_file)
|
os.remove(pid_file)
|
||||||
end
|
end
|
||||||
return string.format("launched pid=%s log=%s", pid, log)
|
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.", {
|
server:tool("read_file", "Read a file.", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -221,7 +295,15 @@ server:tool("read_file", "Read a file.", {
|
|||||||
local c = read_file(a.path)
|
local c = read_file(a.path)
|
||||||
if not c then return "Error: could not read " .. a.path end
|
if not c then return "Error: could not read " .. a.path end
|
||||||
return c
|
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.", {
|
server:tool("write_file", "Write content to a file.", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -235,7 +317,15 @@ server:tool("write_file", "Write content to a file.", {
|
|||||||
if not f then return "Error: could not write " .. a.path end
|
if not f then return "Error: could not write " .. a.path end
|
||||||
f:write(a.content); f:close()
|
f:write(a.content); f:close()
|
||||||
return string.format("Written %d bytes to %s", #a.content, a.path)
|
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.", {
|
server:tool("edit_file", "Replace exact text in a file (literal match). Fails unless old_string is unique, unless replace_all=true.", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -289,7 +379,15 @@ server:tool("edit_file", "Replace exact text in a file (literal match). Fails un
|
|||||||
w:write(table.concat(parts)); w:close()
|
w:write(table.concat(parts)); w:close()
|
||||||
|
|
||||||
return string.format("Edited %s: %d replacement(s)", a.path, replaced)
|
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.", {
|
server:tool("list_dir", "List directory contents.", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -301,7 +399,254 @@ server:tool("list_dir", "List directory contents.", {
|
|||||||
else
|
else
|
||||||
return run("ls -1 '" .. path:gsub("'", "'\\''") .. "'", 10)
|
return run("ls -1 '" .. path:gsub("'", "'\\''") .. "'", 10)
|
||||||
end
|
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.", {
|
server:tool("search_files", "Search for files by pattern.", {
|
||||||
type = "object",
|
type = "object",
|
||||||
@@ -320,14 +665,415 @@ server:tool("search_files", "Search for files by pattern.", {
|
|||||||
-- (common on Homebrew, e.g. /usr/local/share/lua -> Cellar/…/share/lua).
|
-- (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)
|
return run("find -L '" .. path:gsub("'", "'\\''") .. "' -name '" .. a.pattern:gsub("'", "'\\''") .. "' 2>/dev/null", 30)
|
||||||
end
|
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
|
if WINDOWS then
|
||||||
server:tool("systeminfo", "Get Windows system information.", {
|
server:tool("systeminfo", "Get Windows system information.", {
|
||||||
type = "object", properties = {},
|
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
|
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(string.format("lmcp %s starting on port %d (%s)\n",
|
io.stderr:write(string.format("lmcp %s starting on port %d (%s)\n",
|
||||||
server_name, server.port, WINDOWS and "Windows" or "POSIX"))
|
server_name, server.port, WINDOWS and "Windows" or "POSIX"))
|
||||||
server:run()
|
server:run()
|
||||||
|
end
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# lmcp Windows MSI build
|
||||||
|
|
||||||
|
This directory contains the WiX manifest and packaging files for the
|
||||||
|
Windows MSI build of lmcp.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Pull the Lua + LuaSocket runtime into pkg/lua/ (one-time, see below).
|
||||||
|
# 2. Sync the lmcp .lua sources from the root of the repo:
|
||||||
|
./sync.sh
|
||||||
|
# 3. Bump windows/lmcp.wxs `Version="…"` to match the release tag.
|
||||||
|
# 4. Invoke WiX:
|
||||||
|
candle.exe lmcp.wxs
|
||||||
|
light.exe lmcp.wixobj -o lmcp-1.x.y.msi
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's tracked vs. generated
|
||||||
|
|
||||||
|
- **Tracked** (edit in git):
|
||||||
|
- `lmcp.wxs` — WiX MSI manifest
|
||||||
|
- `sync.sh` — copies root .lua sources → `pkg/`
|
||||||
|
- `README.md` — this file
|
||||||
|
- `pkg/install_service.bat` — Windows service installer
|
||||||
|
- `pkg/start.bat` — manual launcher
|
||||||
|
|
||||||
|
- **Generated / external** (gitignored):
|
||||||
|
- `pkg/lmcp.lua`, `pkg/server.lua`, `pkg/json.lua` — produced by
|
||||||
|
`sync.sh`. Never edit directly; edit the root files and re-sync.
|
||||||
|
- `pkg/lua/` — the Lua + LuaSocket runtime drop-in. Download
|
||||||
|
separately and place here. Suggested source: the lua-binaries
|
||||||
|
project (https://github.com/rjpcomputing/luaforwindows) or a
|
||||||
|
similar pre-built bundle. The MSI expects `pkg/lua/lua.exe`,
|
||||||
|
`pkg/lua/lua54.dll`, and the `pkg/lua/socket/` + `pkg/lua/mime/`
|
||||||
|
subdirectories per the manifest.
|
||||||
|
|
||||||
|
## Issue history
|
||||||
|
|
||||||
|
Issue #18 (closed in v1.1.0) introduced this workflow after the
|
||||||
|
`pkg/` lua sources had silently drifted ~6 months out of date,
|
||||||
|
missing every feature added since April 2026.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||||
|
<!-- Bump Version on every release. See windows/README.md. -->
|
||||||
|
<Product Id="*"
|
||||||
|
Name="lmcp — Lua MCP Server"
|
||||||
|
Language="1033"
|
||||||
|
Version="1.1.0"
|
||||||
|
Manufacturer="QAP'LA Project"
|
||||||
|
UpgradeCode="A7F3E2D1-4B5C-6D7E-8F9A-0B1C2D3E4F5A">
|
||||||
|
|
||||||
|
<Package InstallerVersion="200"
|
||||||
|
Compressed="yes"
|
||||||
|
InstallScope="perMachine"
|
||||||
|
Description="Lightweight MCP server in Lua. 2MB RSS."
|
||||||
|
Comments="Zero-dependency MCP server." />
|
||||||
|
|
||||||
|
<MediaTemplate EmbedCab="yes" />
|
||||||
|
|
||||||
|
<MajorUpgrade DowngradeErrorMessage="A newer version is already installed." />
|
||||||
|
|
||||||
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
|
<Directory Id="ProgramFiles64Folder">
|
||||||
|
<Directory Id="INSTALLFOLDER" Name="lmcp">
|
||||||
|
<Directory Id="LUA_DIR" Name="lua">
|
||||||
|
<Directory Id="SOCKET_DIR" Name="socket" />
|
||||||
|
<Directory Id="MIME_DIR" Name="mime" />
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
<!-- lmcp application files -->
|
||||||
|
<DirectoryRef Id="INSTALLFOLDER">
|
||||||
|
<Component Id="JsonLua" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567890">
|
||||||
|
<File Id="json.lua" Source="pkg\json.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="LmcpLua" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567891">
|
||||||
|
<File Id="lmcp.lua" Source="pkg\lmcp.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="ServerLua" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567892">
|
||||||
|
<File Id="server.lua" Source="pkg\server.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="StartBat" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567893">
|
||||||
|
<File Id="start.bat" Source="pkg\start.bat" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="InstallService" Guid="B1A2C3D4-E5F6-7890-ABCD-EF1234567894">
|
||||||
|
<File Id="install_service.bat" Source="pkg\install_service.bat" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<!-- Lua runtime -->
|
||||||
|
<DirectoryRef Id="LUA_DIR">
|
||||||
|
<Component Id="LuaExe" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678900">
|
||||||
|
<File Id="lua.exe" Source="pkg\lua\lua.exe" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="LuaDll" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678901">
|
||||||
|
<File Id="lua54.dll" Source="pkg\lua\lua54.dll" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="SocketLua" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678902">
|
||||||
|
<File Id="socket.lua" Source="pkg\lua\socket.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="MimeLua" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678903">
|
||||||
|
<File Id="mime.lua" Source="pkg\lua\mime.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="Ltn12Lua" Guid="C2B3D4E5-F6A7-8901-BCDE-F12345678904">
|
||||||
|
<File Id="ltn12.lua" Source="pkg\lua\ltn12.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<!-- LuaSocket native DLLs -->
|
||||||
|
<DirectoryRef Id="SOCKET_DIR">
|
||||||
|
<Component Id="SocketCoreDll" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789010">
|
||||||
|
<File Id="socket_core.dll" Name="core.dll" Source="pkg\lua\socket\core.dll" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="SocketFtp" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789011">
|
||||||
|
<File Id="ftp.lua" Source="pkg\lua\socket\ftp.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="SocketHeaders" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789012">
|
||||||
|
<File Id="headers.lua" Source="pkg\lua\socket\headers.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="SocketHttp" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789013">
|
||||||
|
<File Id="http.lua" Source="pkg\lua\socket\http.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="SocketTp" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789014">
|
||||||
|
<File Id="tp.lua" Source="pkg\lua\socket\tp.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
<Component Id="SocketUrl" Guid="D3C4E5F6-A7B8-9012-CDEF-123456789015">
|
||||||
|
<File Id="url.lua" Source="pkg\lua\socket\url.lua" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<DirectoryRef Id="MIME_DIR">
|
||||||
|
<Component Id="MimeCoreDll" Guid="E4D5F6A7-B8C9-0123-DEFA-234567890120">
|
||||||
|
<File Id="mime_core.dll" Name="core.dll" Source="pkg\lua\mime\core.dll" KeyPath="yes" />
|
||||||
|
</Component>
|
||||||
|
</DirectoryRef>
|
||||||
|
|
||||||
|
<Feature Id="MainFeature" Title="lmcp Server" Level="1">
|
||||||
|
<ComponentRef Id="JsonLua" />
|
||||||
|
<ComponentRef Id="LmcpLua" />
|
||||||
|
<ComponentRef Id="ServerLua" />
|
||||||
|
<ComponentRef Id="StartBat" />
|
||||||
|
<ComponentRef Id="InstallService" />
|
||||||
|
<ComponentRef Id="LuaExe" />
|
||||||
|
<ComponentRef Id="LuaDll" />
|
||||||
|
<ComponentRef Id="SocketLua" />
|
||||||
|
<ComponentRef Id="MimeLua" />
|
||||||
|
<ComponentRef Id="Ltn12Lua" />
|
||||||
|
<ComponentRef Id="SocketCoreDll" />
|
||||||
|
<ComponentRef Id="SocketFtp" />
|
||||||
|
<ComponentRef Id="SocketHeaders" />
|
||||||
|
<ComponentRef Id="SocketHttp" />
|
||||||
|
<ComponentRef Id="SocketTp" />
|
||||||
|
<ComponentRef Id="SocketUrl" />
|
||||||
|
<ComponentRef Id="MimeCoreDll" />
|
||||||
|
</Feature>
|
||||||
|
|
||||||
|
</Product>
|
||||||
|
</Wix>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
REM Install lmcp as a Windows service using NSSM (Non-Sucking Service Manager)
|
||||||
|
REM Download nssm from https://nssm.cc if not present
|
||||||
|
|
||||||
|
if not exist "%~dp0nssm.exe" (
|
||||||
|
echo ERROR: nssm.exe not found in %~dp0
|
||||||
|
echo Download from https://nssm.cc and place nssm.exe here.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
set INSTALL_DIR=%~dp0
|
||||||
|
set SERVICE_NAME=lmcp
|
||||||
|
|
||||||
|
echo Installing lmcp as Windows service...
|
||||||
|
%INSTALL_DIR%nssm.exe install %SERVICE_NAME% "%INSTALL_DIR%lua\lua.exe" "%INSTALL_DIR%server.lua"
|
||||||
|
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% AppDirectory "%INSTALL_DIR%"
|
||||||
|
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% AppEnvironmentExtra "LMCP_PORT=8080"
|
||||||
|
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% DisplayName "lmcp MCP Server"
|
||||||
|
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% Description "Lightweight MCP server in Lua"
|
||||||
|
%INSTALL_DIR%nssm.exe set %SERVICE_NAME% Start SERVICE_AUTO_START
|
||||||
|
%INSTALL_DIR%nssm.exe start %SERVICE_NAME%
|
||||||
|
|
||||||
|
echo Done. Service '%SERVICE_NAME%' installed and started.
|
||||||
|
echo Check: sc query %SERVICE_NAME%
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
REM lmcp — Lua MCP Server
|
||||||
|
REM Start the server on port 8080 (or LMCP_PORT if set)
|
||||||
|
cd /d "%~dp0"
|
||||||
|
if not defined LMCP_PORT set LMCP_PORT=8080
|
||||||
|
echo Starting lmcp on port %LMCP_PORT%...
|
||||||
|
lua\lua.exe server.lua
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# windows/sync.sh — refresh windows/pkg/ from root .lua sources (issue #18).
|
||||||
|
#
|
||||||
|
# Run BEFORE invoking the WiX build so the MSI bundles whatever is in
|
||||||
|
# master. The .lua files in windows/pkg/ are regenerated on every run
|
||||||
|
# and are gitignored — never edit them directly.
|
||||||
|
#
|
||||||
|
# Idempotent: re-running just re-copies. Safe to call from a Makefile,
|
||||||
|
# a CI step, or by hand before `candle.exe + light.exe`.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
here=$(dirname "$(readlink -f "$0")")
|
||||||
|
root=$(cd "$here/.." && pwd)
|
||||||
|
|
||||||
|
for f in lmcp.lua server.lua json.lua; do
|
||||||
|
if [ ! -f "$root/$f" ]; then
|
||||||
|
echo "windows/sync.sh: missing source $root/$f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cp "$root/$f" "$here/pkg/$f"
|
||||||
|
echo " synced $f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "windows/sync.sh: done — pkg/ matches root .lua at $(date +%Y-%m-%dT%H:%M:%S)"
|
||||||
Reference in New Issue
Block a user