Compare commits
3 Commits
v1.0.0-rc1
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e62f71931 | |||
| 55ead8041f | |||
| 2ac502e50f |
+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
|
||||||
@@ -7,6 +7,22 @@ local json = require('json')
|
|||||||
local lmcp = {}
|
local lmcp = {}
|
||||||
lmcp.__index = lmcp
|
lmcp.__index = lmcp
|
||||||
|
|
||||||
|
-- Module-level coroutine→ctx registry (issue #11). Weak keys so
|
||||||
|
-- coroutines that die without explicit cleanup get GC'd out.
|
||||||
|
-- Each ctx table carries a `server` back-reference, so any code with
|
||||||
|
-- a coroutine handle can find both ctx and its owning lmcp instance.
|
||||||
|
local _ctx_by_co = setmetatable({}, { __mode = "k" })
|
||||||
|
|
||||||
|
-- server.lua and any other library code can call lmcp.current_ctx() to
|
||||||
|
-- access the ctx of the currently-running dispatch coroutine. Returns
|
||||||
|
-- nil outside coroutine context. Used by server.lua:run() to do
|
||||||
|
-- transparent auto-cancellation of long-running shell-out polls.
|
||||||
|
function lmcp.current_ctx()
|
||||||
|
local co = coroutine.running()
|
||||||
|
if co == nil then return nil end
|
||||||
|
return _ctx_by_co[co]
|
||||||
|
end
|
||||||
|
|
||||||
-- Read auth token from config file if present
|
-- Read auth token from config file if present
|
||||||
local function read_conf(path)
|
local function read_conf(path)
|
||||||
local conf = {}
|
local conf = {}
|
||||||
@@ -69,6 +85,16 @@ function lmcp.new(name, opts)
|
|||||||
-- server calls `:roots(session_id, ...)`; invalidated when the client
|
-- server calls `:roots(session_id, ...)`; invalidated when the client
|
||||||
-- sends notifications/roots/list_changed.
|
-- sends notifications/roots/list_changed.
|
||||||
self._roots_cache = {}
|
self._roots_cache = {}
|
||||||
|
-- Pending handler coroutines (issue #20 — concurrent dispatch).
|
||||||
|
-- Each entry: { co, conn, wake_at, finalise }. The scheduler tick
|
||||||
|
-- resumes any whose wake_at has passed and runs `finalise` on the
|
||||||
|
-- coroutine's return value to build the deferred response.
|
||||||
|
self._pending_handlers = {}
|
||||||
|
-- Cancellation flags (issue #11). Keyed by stringified JSON-RPC
|
||||||
|
-- request id. Only ever holds in-flight ids — see the
|
||||||
|
-- notifications/cancelled handler in handle_request which checks
|
||||||
|
-- for in-flight before inserting. Cleared by _finalise_dispatch.
|
||||||
|
self._cancelled_ids = {}
|
||||||
-- Notification queue: drained by Streamable HTTP transport (issue #16).
|
-- Notification queue: drained by Streamable HTTP transport (issue #16).
|
||||||
-- Today delivery is a no-op; we still enqueue so the emission code
|
-- Today delivery is a no-op; we still enqueue so the emission code
|
||||||
-- path is exercised. Capped + deduped to keep the queue useful.
|
-- path is exercised. Capped + deduped to keep the queue useful.
|
||||||
@@ -413,9 +439,28 @@ function lmcp:handle_request(req)
|
|||||||
if method == "notifications/roots/list_changed" then
|
if method == "notifications/roots/list_changed" then
|
||||||
-- Invalidate cached roots for the session that sent this.
|
-- Invalidate cached roots for the session that sent this.
|
||||||
if req._session_id then self._roots_cache[req._session_id] = nil end
|
if req._session_id then self._roots_cache[req._session_id] = nil end
|
||||||
|
elseif method == "notifications/cancelled" then
|
||||||
|
-- Issue #11 — flip cancel flag for the named request id,
|
||||||
|
-- but ONLY if the request is actually in-flight. Cancels
|
||||||
|
-- for unknown/already-completed ids drop silently (per Phase
|
||||||
|
-- 5 review fix #2 — prevents unbounded map growth).
|
||||||
|
local rid = (req.params or {}).requestId
|
||||||
|
if rid ~= nil then
|
||||||
|
local rid_str = tostring(rid)
|
||||||
|
local in_flight = false
|
||||||
|
-- Scan _ctx_by_co for a matching live request.
|
||||||
|
for _, c in pairs(_ctx_by_co) do
|
||||||
|
if c.request_id ~= nil
|
||||||
|
and tostring(c.request_id) == rid_str then
|
||||||
|
in_flight = true; break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if in_flight then
|
||||||
|
self._cancelled_ids[rid_str] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
-- (Other client→server notifications: cancelled, message — no
|
-- (Other client→server notifications drop silently.)
|
||||||
-- action today; add side-effects here as needed.)
|
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -484,15 +529,59 @@ function lmcp:handle_request(req)
|
|||||||
if not tool then
|
if not tool then
|
||||||
return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name))
|
return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name))
|
||||||
end
|
end
|
||||||
-- ctx exposes the request's _meta (issue #13) and the session_id
|
-- ctx exposes the request's _meta (issue #13), the session_id
|
||||||
-- (issue #9 — so handlers can call self:sample(ctx.session_id, …)).
|
-- (issue #9 — handlers can call self:sample(ctx.session_id, …)),
|
||||||
-- Handlers that don't declare a second parameter ignore it (Lua
|
-- progress() and cancelled() (issue #11), and a `server` back-ref
|
||||||
-- call discards extras).
|
-- (so lmcp.current_ctx() can find the right server instance
|
||||||
local ctx = {
|
-- without a singleton). Handlers that don't declare a second
|
||||||
|
-- parameter ignore it (Lua call discards extras).
|
||||||
|
local rid_str = tostring(id)
|
||||||
|
local ptoken = (params._meta or {}).progressToken -- nil if absent
|
||||||
|
local ctx
|
||||||
|
ctx = {
|
||||||
_meta = params._meta,
|
_meta = params._meta,
|
||||||
request_id = id,
|
request_id = id,
|
||||||
session_id = req._session_id,
|
session_id = req._session_id,
|
||||||
|
server = self,
|
||||||
|
-- progress(p, total?, message?): emits notifications/progress
|
||||||
|
-- on session's notify_q. No-op if client didn't supply a
|
||||||
|
-- progressToken. Type-checks; rejects non-numeric progress.
|
||||||
|
progress = function(p, total, message)
|
||||||
|
if ptoken == nil then return false end
|
||||||
|
if type(p) ~= "number" then return false end
|
||||||
|
if total ~= nil and type(total) ~= "number" then return false end
|
||||||
|
local sess = self._sessions[req._session_id]
|
||||||
|
if not sess then return false end
|
||||||
|
local np = { progressToken = ptoken, progress = p }
|
||||||
|
if total ~= nil then np.total = total end
|
||||||
|
if message ~= nil then np.message = tostring(message) end
|
||||||
|
sess.notify_q[#sess.notify_q + 1] = {
|
||||||
|
jsonrpc = JSONRPC, method = "notifications/progress",
|
||||||
|
params = np,
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
-- cancelled(): true if a notifications/cancelled for this
|
||||||
|
-- request id has been received.
|
||||||
|
cancelled = function()
|
||||||
|
return self._cancelled_ids[rid_str] == true
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- Register on the currently-running coroutine so lmcp.current_ctx()
|
||||||
|
-- (and thus server.lua:run()'s auto-cancel) can find this ctx.
|
||||||
|
-- Pure-Lua handlers also get this registration; harmless.
|
||||||
|
local co = coroutine.running()
|
||||||
|
if co ~= nil then _ctx_by_co[co] = ctx end
|
||||||
|
|
||||||
|
-- Pre-handler cancellation short-circuit (Phase 5 review fix #9).
|
||||||
|
-- If a notifications/cancelled landed for this id before dispatch
|
||||||
|
-- reached here, skip the handler entirely. _finalise_dispatch
|
||||||
|
-- will see `not result` and suppress the response.
|
||||||
|
if self._cancelled_ids[rid_str] then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
local ok, result = pcall(tool.handler, arguments, ctx)
|
local ok, result = pcall(tool.handler, arguments, ctx)
|
||||||
if ok then
|
if ok then
|
||||||
local resp = { isError = false }
|
local resp = { isError = false }
|
||||||
@@ -887,6 +976,10 @@ end
|
|||||||
|
|
||||||
-- ---- Dispatch a fully-parsed POST body ----
|
-- ---- Dispatch a fully-parsed POST body ----
|
||||||
|
|
||||||
|
-- Forward declarations: used by _dispatch_post, defined below.
|
||||||
|
local _drive_handler_co
|
||||||
|
local _finalise_dispatch
|
||||||
|
|
||||||
local function _dispatch_post(self, conn)
|
local function _dispatch_post(self, conn)
|
||||||
local body = conn.body
|
local body = conn.body
|
||||||
if body == "" then
|
if body == "" then
|
||||||
@@ -942,28 +1035,112 @@ local function _dispatch_post(self, conn)
|
|||||||
-- expose it to handler ctx (issue #9 — sampling needs to know which
|
-- expose it to handler ctx (issue #9 — sampling needs to know which
|
||||||
-- session to push the request onto).
|
-- session to push the request onto).
|
||||||
rpc_req._session_id = sess.id
|
rpc_req._session_id = sess.id
|
||||||
|
-- Stash the JSON-RPC id on the conn so _finalise_dispatch can clear
|
||||||
|
-- the cancellation flag for this request after building the response
|
||||||
|
-- (issue #11). Notifications have nil id; that's fine — the
|
||||||
|
-- nil-guard in _finalise_dispatch keeps tostring(nil) out of the
|
||||||
|
-- cancel map.
|
||||||
|
conn.dispatch_id = rpc_req.id
|
||||||
|
|
||||||
-- Normal client request / notification. Dispatch via handle_request.
|
-- Concurrent handler dispatch (issue #20). Wrap the dispatch call in
|
||||||
local response = self:handle_request(rpc_req)
|
-- a coroutine so any tool handler that goes through server.lua:run()
|
||||||
if not response then
|
-- (which yields when polling its sentinel file) can return control to
|
||||||
|
-- the event loop while it waits. Other connections continue making
|
||||||
|
-- progress.
|
||||||
|
--
|
||||||
|
-- The coroutine resumes itself synchronously the first time. If it
|
||||||
|
-- completes without yielding (pure-Lua handlers, ping, etc.) the
|
||||||
|
-- response is built inline as before. If it yields, we park it in
|
||||||
|
-- self._pending_handlers and return nil — the conn enters
|
||||||
|
-- dispatching_async, the scheduler tick resumes when wake_at passes.
|
||||||
|
local co = coroutine.create(function()
|
||||||
|
return self:handle_request(rpc_req)
|
||||||
|
end)
|
||||||
|
return _drive_handler_co(self, conn, co)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resume a handler coroutine until it completes or yields. On completion,
|
||||||
|
-- build the deferred HTTP response (preserving the Accept-aware shape).
|
||||||
|
-- On yield, register in self._pending_handlers and return nil — the conn
|
||||||
|
-- is parked in dispatching_async until the scheduler resumes it.
|
||||||
|
_drive_handler_co = function(self, conn, co)
|
||||||
|
local rok, ryield = coroutine.resume(co)
|
||||||
|
if coroutine.status(co) == "dead" then
|
||||||
|
return _finalise_dispatch(self, conn, rok, ryield, co)
|
||||||
|
end
|
||||||
|
-- Suspended. Parse the yield payload.
|
||||||
|
local wake_at = (type(ryield) == "table" and ryield.wake_at) or 0
|
||||||
|
self._pending_handlers[#self._pending_handlers + 1] = {
|
||||||
|
co = co, conn = conn, wake_at = wake_at,
|
||||||
|
}
|
||||||
|
conn.state = "dispatching_async"
|
||||||
|
return nil -- no write_buf change; conn parks
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build the HTTP response for a completed dispatch. `rok` is the coroutine.resume
|
||||||
|
-- success flag; `result` is the handler/dispatch return (a JSON-RPC string when
|
||||||
|
-- rok=true; an error message when rok=false). Used by both the sync path
|
||||||
|
-- (_dispatch_post tail) and the async resume path (_scheduler_tick).
|
||||||
|
-- Also: clears cancellation flag and ctx-by-co registry entry for this
|
||||||
|
-- request (issue #11 — single cleanup site per Phase 5 review fix #7).
|
||||||
|
_finalise_dispatch = function(self, conn, rok, result, co)
|
||||||
|
local session_id = conn.session_id
|
||||||
|
|
||||||
|
-- Cleanup (always): drop the coroutine's ctx entry and any
|
||||||
|
-- cancellation flag for this request id.
|
||||||
|
if co ~= nil then _ctx_by_co[co] = nil end
|
||||||
|
local rid = conn.dispatch_id
|
||||||
|
local was_cancelled = false
|
||||||
|
if rid ~= nil then
|
||||||
|
local rid_str = tostring(rid)
|
||||||
|
if self._cancelled_ids[rid_str] then
|
||||||
|
was_cancelled = true
|
||||||
|
self._cancelled_ids[rid_str] = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- Issue #11: cancelled requests get a -32800 JSON-RPC error response.
|
||||||
|
-- The MCP spec wording is "SHOULD NOT respond" (not MUST NOT). A silent
|
||||||
|
-- TCP-close would be cleaner but the spawned shell subprocess in
|
||||||
|
-- server.lua:run() inherits the socket FD via fork(), so the kernel
|
||||||
|
-- keeps the connection alive until that shell exits (i.e. the
|
||||||
|
-- underlying long-running command completes anyway). The error
|
||||||
|
-- response gives the client a structured signal and exits curl
|
||||||
|
-- immediately, which is the practical UX they want. JSON-RPC 2.0
|
||||||
|
-- code -32800 is the convention for "Request cancelled."
|
||||||
|
if was_cancelled then
|
||||||
|
return _build_http_response("200 OK",
|
||||||
|
{ ["Content-Type"] = "application/json",
|
||||||
|
["Access-Control-Allow-Origin"] = "*" },
|
||||||
|
jsonrpc_error(rid, -32800, "Request cancelled"),
|
||||||
|
session_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
if not rok then
|
||||||
|
-- Internal dispatch error — surface as a JSON-RPC error response.
|
||||||
|
return _build_http_response("500 Internal Server Error",
|
||||||
|
{ ["Content-Type"] = "application/json",
|
||||||
|
["Access-Control-Allow-Origin"] = "*" },
|
||||||
|
jsonrpc_error(nil, -32603, "Internal error: " .. tostring(result)),
|
||||||
|
session_id)
|
||||||
|
end
|
||||||
|
if not result then
|
||||||
-- Notification → 202 Accepted, no body.
|
-- Notification → 202 Accepted, no body.
|
||||||
return _build_http_response("202 Accepted",
|
return _build_http_response("202 Accepted",
|
||||||
{ ["Content-Type"] = "application/json",
|
{ ["Content-Type"] = "application/json",
|
||||||
["Access-Control-Allow-Origin"] = "*" },
|
["Access-Control-Allow-Origin"] = "*" },
|
||||||
"", conn.session_id)
|
"", session_id)
|
||||||
end
|
end
|
||||||
|
-- Accept-aware response shape (re-checked at finalise time; survives
|
||||||
-- If client accepts SSE, respond as a single-event SSE stream.
|
-- parking because conn.headers is captured by the closure scope).
|
||||||
-- Otherwise plain JSON body.
|
|
||||||
local accept = conn.headers["accept"] or ""
|
local accept = conn.headers["accept"] or ""
|
||||||
if accept:find("text/event%-stream") then
|
if accept:find("text/event%-stream") then
|
||||||
local hdrs = _build_sse_headers(conn.session_id)
|
local hdrs = _build_sse_headers(session_id)
|
||||||
return hdrs .. _sse_event(response)
|
return hdrs .. _sse_event(result)
|
||||||
end
|
end
|
||||||
return _build_http_response("200 OK",
|
return _build_http_response("200 OK",
|
||||||
{ ["Content-Type"] = "application/json",
|
{ ["Content-Type"] = "application/json",
|
||||||
["Access-Control-Allow-Origin"] = "*" },
|
["Access-Control-Allow-Origin"] = "*" },
|
||||||
response, conn.session_id)
|
result, session_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function _dispatch_options(conn)
|
local function _dispatch_options(conn)
|
||||||
@@ -1119,8 +1296,19 @@ local function _conn_read(self, conn)
|
|||||||
conn.state = "sse_open"
|
conn.state = "sse_open"
|
||||||
conn.last_heart = os.time()
|
conn.last_heart = os.time()
|
||||||
elseif conn.method == "POST" then
|
elseif conn.method == "POST" then
|
||||||
conn.write_buf = _dispatch_post(self, conn)
|
-- _dispatch_post may return nil (issue #20) if the handler
|
||||||
conn.state = "writing"
|
-- coroutine yielded. In that case it set conn.state =
|
||||||
|
-- "dispatching_async" itself and parked the coroutine.
|
||||||
|
local resp = _dispatch_post(self, conn)
|
||||||
|
if resp then
|
||||||
|
conn.write_buf = resp
|
||||||
|
-- _finalise_dispatch sets conn.state = "closing" for
|
||||||
|
-- cancelled requests (issue #11); only override if not.
|
||||||
|
if conn.state ~= "closing" then
|
||||||
|
conn.state = "writing"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
-- else: conn already parked; scheduler tick will finalise.
|
||||||
else
|
else
|
||||||
conn.write_buf = _build_http_response("405 Method Not Allowed",
|
conn.write_buf = _build_http_response("405 Method Not Allowed",
|
||||||
{ ["Content-Type"] = "text/plain",
|
{ ["Content-Type"] = "text/plain",
|
||||||
@@ -1195,6 +1383,73 @@ local function _heartbeat_tick(self)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Issue #20 — scheduler tick. Resume any parked dispatch coroutine whose
|
||||||
|
-- wake_at has passed. On completion, build the deferred response and
|
||||||
|
-- queue it for write. If the connection died while the handler was
|
||||||
|
-- parked, drop the coroutine.
|
||||||
|
--
|
||||||
|
-- gettime() is wall-clock (luasocket uses gettimeofday) — NOT monotonic.
|
||||||
|
-- A large NTP step backwards could delay resumes; forwards could bunch
|
||||||
|
-- them. Acceptable for the deployment fleet (chrony slews); revisit if
|
||||||
|
-- a use case appears that needs CLOCK_MONOTONIC.
|
||||||
|
local function _scheduler_tick(self)
|
||||||
|
if not self._pending_handlers[1] then return end
|
||||||
|
local socket = require("socket")
|
||||||
|
local now = socket.gettime()
|
||||||
|
local i = 1
|
||||||
|
while i <= #self._pending_handlers do
|
||||||
|
local p = self._pending_handlers[i]
|
||||||
|
if p.conn.state == "closing" then
|
||||||
|
-- Connection died mid-handler; drop the coroutine entirely
|
||||||
|
-- and free its ctx entry (issue #11 cleanup discipline).
|
||||||
|
_ctx_by_co[p.co] = nil
|
||||||
|
if p.conn.dispatch_id ~= nil then
|
||||||
|
self._cancelled_ids[tostring(p.conn.dispatch_id)] = nil
|
||||||
|
end
|
||||||
|
table.remove(self._pending_handlers, i)
|
||||||
|
elseif now >= p.wake_at then
|
||||||
|
-- Time to resume. Remove from pending BEFORE resume so a
|
||||||
|
-- re-yielding handler re-adds itself cleanly via _drive_handler_co.
|
||||||
|
table.remove(self._pending_handlers, i)
|
||||||
|
local rok, ryield = coroutine.resume(p.co)
|
||||||
|
if coroutine.status(p.co) == "dead" then
|
||||||
|
local resp = _finalise_dispatch(self, p.conn, rok, ryield, p.co)
|
||||||
|
p.conn.write_buf = (p.conn.write_buf or "") .. resp
|
||||||
|
-- _finalise_dispatch may set conn.state = "closing" for
|
||||||
|
-- cancelled requests; only transition to writing if it
|
||||||
|
-- didn't already pick the closing path.
|
||||||
|
if p.conn.state ~= "closing" then
|
||||||
|
p.conn.state = "writing"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Yielded again — re-park.
|
||||||
|
local wake_at = (type(ryield) == "table" and ryield.wake_at) or 0
|
||||||
|
self._pending_handlers[#self._pending_handlers + 1] = {
|
||||||
|
co = p.co, conn = p.conn, wake_at = wake_at,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns the earliest pending wake_at as an offset from now, or nil if
|
||||||
|
-- no handlers are parked. Used to tighten the select() timeout so the
|
||||||
|
-- scheduler wakes on the right beat.
|
||||||
|
local function _next_pending_delay(self)
|
||||||
|
if not self._pending_handlers[1] then return nil end
|
||||||
|
local socket = require("socket")
|
||||||
|
local now = socket.gettime()
|
||||||
|
local earliest = math.huge
|
||||||
|
for _, p in ipairs(self._pending_handlers) do
|
||||||
|
if p.wake_at < earliest then earliest = p.wake_at end
|
||||||
|
end
|
||||||
|
local d = earliest - now
|
||||||
|
if d < 0 then return 0 end
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
|
||||||
-- ---- Public: server-initiated request (for sampling/roots/etc.) ----
|
-- ---- Public: server-initiated request (for sampling/roots/etc.) ----
|
||||||
-- Enqueues a JSON-RPC request on the session's SSE stream. The callback
|
-- Enqueues a JSON-RPC request on the session's SSE stream. The callback
|
||||||
-- fires when the client POSTs back the response (matched by id).
|
-- fires when the client POSTs back the response (matched by id).
|
||||||
@@ -1322,7 +1577,14 @@ function lmcp:run()
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local ready_r, ready_w = socket.select(reads, writes, SELECT_TIMEOUT)
|
-- Tighten select timeout if a parked handler is due sooner.
|
||||||
|
-- Otherwise a 100ms tick adds 100ms latency to short shell-tool runs.
|
||||||
|
local select_timeout = SELECT_TIMEOUT
|
||||||
|
local next_pend = _next_pending_delay(self)
|
||||||
|
if next_pend and next_pend < select_timeout then
|
||||||
|
select_timeout = next_pend
|
||||||
|
end
|
||||||
|
local ready_r, ready_w = socket.select(reads, writes, select_timeout)
|
||||||
|
|
||||||
for _, sock in ipairs(ready_r or {}) do
|
for _, sock in ipairs(ready_r or {}) do
|
||||||
if sock == server_sock then
|
if sock == server_sock then
|
||||||
@@ -1365,9 +1627,11 @@ function lmcp:run()
|
|||||||
-- Per-tick maintenance.
|
-- Per-tick maintenance.
|
||||||
_drain_notifications(self)
|
_drain_notifications(self)
|
||||||
_heartbeat_tick(self)
|
_heartbeat_tick(self)
|
||||||
|
_scheduler_tick(self) -- issue #20: resume due dispatch coroutines
|
||||||
|
|
||||||
-- After draining, attempt immediate writes on conns whose write_buf
|
-- After draining, attempt immediate writes on conns whose write_buf
|
||||||
-- just got bytes (so list_changed / heartbeat appears within one tick).
|
-- just got bytes (so list_changed / heartbeat / async-completed
|
||||||
|
-- responses appear within one tick).
|
||||||
for sock, conn in pairs(self._conns) do
|
for sock, conn in pairs(self._conns) do
|
||||||
if conn.write_buf ~= "" and conn.state ~= "closing" then
|
if conn.write_buf ~= "" and conn.state ~= "closing" then
|
||||||
pcall(_conn_write, conn)
|
pcall(_conn_write, conn)
|
||||||
|
|||||||
+79
-21
@@ -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)"
|
||||||
|
|||||||
@@ -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