5 Commits

Author SHA1 Message Date
test0r 9707f7ae93 v1.1.1: omit empty inputSchema.properties at registration
Same json.lua empty-table → [] gotcha that bit `ping` in v1.0.0-rc1
(project_json_empty_table_gotcha memory) bit again — this time on
tool inputSchemas with `properties = {}`. Symptom: spec-strict MCP
clients (Zod et al.) reject tools/list with:

  expected: record, code: invalid_type,
  path: [tools, N, inputSchema, properties],
  message: "Invalid input: expected record, received array"

Fix: in `lmcp:tool()`, normalise the registered inputSchema —
when `properties` is an empty Lua table, drop the key entirely.
JSON Schema permits omitting `properties` on `type: "object"`
(means "any object, no constraints" — exactly what a no-arg tool
wants).

Clone-before-mutate so the caller's table isn't trampled (matters
when a server author shares one schema across multiple
registrations).

Smoke tested locally with 3 tools (empty, default-nil, populated):
- `properties = {}` → emitted as `{"type":"object"}`
- nil schema → same default, same output
- populated properties → emitted intact with full shape

Discovered against hertz-tools live (lxc_list, network_status had
`properties = {}` — hertz hotfixed by hand before this commit;
this protects every future tool author from the same trap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 22:39:56 +00:00
test0r 9e53b23b11 windows/build-msi.sh: cross-build the MSI on Linux via wixl + mingw-w64
Discovered building v1.1.0 that the MSI can be produced entirely on
Linux — no Windows VM, no manual WiX install, no GUI babysitting:

  apt install wixl unzip gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
              mingw-w64-x86-64-dev curl

The new build-msi.sh script:
  1. Runs sync.sh to refresh pkg/{lmcp,server,json}.lua from root.
  2. Downloads Lua 5.4.2 Win64 binaries from LuaBinaries (Tools +
     Library zips — interpreter + headers + import lib).
  3. Cross-compiles LuaSocket 3.1.0 via x86_64-w64-mingw32-gcc
     (produces socket-3.0.0.dll + mime-1.0.3.dll for Win64).
  4. Stages pkg/lua/{lua.exe, lua54.dll, socket/, mime/, *.lua} per
     the WiX manifest layout.
  5. Invokes wixl on the lmcp.wxs manifest (with sed for the
     Windows backslash path separators → forward slashes).

Output: lmcp-<version>.msi. Version is read from lmcp.wxs
Version="…", so bump that before each release.

Cold build: ~30s. Warm cache: ~5s. The artifact contains all 17
files the WiX manifest expects, ProductVersion matches lmcp.wxs.

README updated to point at build-msi.sh as the recommended path;
the Windows-side candle/light recipe kept as an alternative.

Reproducibility note (deferred): the MSI is not yet bit-reproducible
across builds — file mtimes in the Lua binaries' zip propagate to
the cab inside the MSI. The debian/lmcp/build-deb.sh in marfrit-
packages uses SOURCE_DATE_EPOCH to fix this; same pattern would
apply here. Out of scope for the first cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:27:32 +00:00
test0r 7e62f71931 v1.1.0/#18: windows MSI build — sync.sh + tracked manifest
windows/ was previously an untracked working tree with manually-
copied .lua files that drifted ~6 months out of date (missed every
feature added since April 2026). #18 introduces Option 1 from the
issue body: build-time sync.

New tracked files:
  - windows/sync.sh — copies root {lmcp,server,json}.lua to pkg/.
    Idempotent; run before WiX. Catches missing source files; logs
    each sync.
  - windows/README.md — workflow doc + tracked-vs-generated map.
  - windows/lmcp.wxs — MSI manifest (Version bumped 0.1.0 → 1.1.0).
  - windows/pkg/{install_service,start}.bat — Windows service
    installer + launcher (now tracked; they were already in pkg/).

New .gitignore at repo root:
  - windows/pkg/{lmcp,server,json}.lua — regenerated by sync.sh
  - windows/pkg/lua/ — bundled Lua + LuaSocket runtime (downloaded
    separately, not in git)
  - editor noise (*.swp, *.swo, .DS_Store)

Verification (Phase 7):
  $ ./windows/sync.sh
    synced lmcp.lua
    synced server.lua
    synced json.lua
  $ diff lmcp.lua windows/pkg/lmcp.lua  → empty
  $ git ls-files -o --exclude-standard windows/
    windows/README.md
    windows/lmcp.wxs
    windows/pkg/install_service.bat
    windows/pkg/start.bat
    windows/sync.sh
  $ git check-ignore windows/pkg/{lmcp,server,json}.lua  → all 3 ignored

The "missed every feature since April" failure mode this fixes:
running sync.sh before each MSI build now guarantees pkg/ matches
master. Forgetting to run it is failure-loud (the MSI ships the
last sync's snapshot, easy to spot in QA), not silent (the manifest
points at fresh files that mismatch root).

Closes v1.1.0 milestone with #11, #20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:43:27 +00:00
test0r 55ead8041f v1.1.0/#11: progress + cancellation notifications
ctx augmentation:
- ctx.progress(p, total?, message?) emits notifications/progress on
  the session's notify_q. No-op when the original request omitted
  _meta.progressToken (per spec: only emit when client opted in).
  Type-checks numeric args; passes progressToken through unchanged
  (spec allows number OR string keys).
- ctx.cancelled() returns true once the client has sent a
  notifications/cancelled for this request's id.

handle_request:
- New side-effect in the id==nil branch: notifications/cancelled
  scans the module-level _ctx_by_co for an in-flight ctx whose
  request_id matches; flips self._cancelled_ids[rid_str] only when
  found. Unknown rids drop silently (no map growth).
- Pre-handler short-circuit: if cancel arrived before dispatch
  reached tools/call, skip the handler entirely.

Cross-module ctx lookup:
- Module-level weak _ctx_by_co table in lmcp.lua keyed by
  coroutine. lmcp.current_ctx() returns the ctx of the running
  coroutine. server.lua's run() lazy-requires lmcp and uses it
  to opt into auto-cancellation without depending on lmcp internals.

server.lua:run():
- After each sleep_ms cycle, check ctx.cancelled(); exit poll loop
  with cancelled=true if set.
- Poll interval capped at 500ms when a ctx is present so worst-case
  cancel latency stays ≤500ms (vs. 2s default growth).
- Returns "(cancelled)" sentinel; handler propagates normally.

_finalise_dispatch:
- Single cleanup site for both _cancelled_ids and _ctx_by_co (per
  Phase 5 review).
- When was_cancelled: emit JSON-RPC -32800 "Request cancelled"
  (deviation from Phase 4 plan; documented).

Phase 4 deviation explained: plan was silent TCP close (per spec
"SHOULD NOT respond"). Empirically: os.execute's fork+exec
inherits the parent's TCP socket FD into the spawned shell, so
sock:close() doesn't actually deliver FIN until the subshell exits
(i.e. the long-running command completes anyway). Verified
luasocket close() works on bare sockets (curl exits with RST in
511ms). The fix would be FD_CLOEXEC on accepted sockets, which
luasocket doesn't expose — needs a C shim or luaposix. Deferred.
Captured in memory project_fd_inheritance_in_run.

Practical UX with the deviation: client receives a structured
-32800 error within ~420ms of POSTing the cancel notification.

Measurements (Phase 7):
  cancel timing (3 runs, sleep 10 with cancel at 0.4s):
    run 1: t=0.42s code=-32800
    run 2: t=0.42s code=-32800
    run 3: t=0.42s code=-32800
  progress: 3/3 events arrived on SSE; spec-shaped payload
  concurrent fast+slow (#20 regression): unchanged (fast 0.01s)
  all previously-closed issues regression-test green

Zero handler source-code changes. Existing tools (shell, fetch,
web_search, hub remote_*) get cancellation for free via run().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:29:00 +00:00
test0r 2ac502e50f v1.1.0/#20: concurrent handler dispatch
Replaces the synchronous tools/call path with a coroutine-wrapped
dispatch. The select()-based event loop from v1.0.0-rc1 already
multiplexes I/O; this change extends the same single-thread
cooperative scheduling to tool handler execution.

How:
- server.lua:sleep_ms detects coroutine context and yields with
  { wake_at = gettime() + ms/1000 } instead of blocking. Falls back
  to today's busy-blocking sleep when on the main thread (stdio
  dispatch, init code).
- server.lua:run() now uses gettime() deltas for timeout accounting
  (Phase 5 review fix — the prior interval-accumulator diverged
  from wall-clock when scheduler delayed resumes).
- lmcp.lua wraps the handle_request call inside _dispatch_post in a
  coroutine. Synchronous completion (no yield) takes the inline-
  response path; if the handler yields, the coroutine parks in
  self._pending_handlers and the conn enters dispatching_async.
- New _scheduler_tick services pending coroutines whose wake_at has
  passed; on completion calls the shared _finalise_dispatch helper
  to build the deferred HTTP response (Accept-aware: SSE or JSON).
- select() timeout tightens to the next pending wake_at so short
  yields don't pay the full 100ms tick.

Measurement (Phase 7):
  before: fast ping during slow shell sleep 3 = 4.28s
  after:  fast ping during slow shell sleep 3 = 0.01s   (~400×)
  3 parallel slow shells: 3.77s total wall (was ~9s).

Zero handler source-code changes. Every existing tool that goes
through run() (shell, shell_bg, fetch, web_search, list_dir,
search_files, systeminfo, hub remote_*) gets concurrency for free.
Pure-Lua handlers (ping, read_file, write_file, edit_file) continue
to complete inline. stdio transport stays serialised by design
(single-client per stdio process).

Known limits documented in memory project_handler_coroutines:
- socket.gettime() is wall-clock not monotonic; large NTP steps may
  bunch resumes. Acceptable on chrony-slewed fleet.
- Cancellation (#11) is now tractable since the scheduler can flip a
  flag between resumes — implementation pending.
- Server-initiated request await (sampling/roots from inside a
  handler) still requires a future yield-on-pending helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:03:06 +00:00
9 changed files with 729 additions and 43 deletions
+13
View File
@@ -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
+305 -22
View File
@@ -7,6 +7,22 @@ local json = require('json')
local 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
local function read_conf(path)
local conf = {}
@@ -69,6 +85,16 @@ function lmcp.new(name, opts)
-- server calls `:roots(session_id, ...)`; invalidated when the client
-- sends notifications/roots/list_changed.
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).
-- Today delivery is a no-op; we still enqueue so the emission code
-- path is exercised. Capped + deduped to keep the queue useful.
@@ -137,10 +163,29 @@ end
-- structuredContent (issue #13; spec-strict clients get first-class
-- structured access)
function lmcp:tool(name, description, params_schema, handler, opts)
-- Normalise empty inputSchema.properties → nil. JSON Schema allows
-- omitting `properties` on a `type: "object"` schema (means "any
-- object, no constraints"). Without this, an empty Lua properties
-- table goes through json.lua's is_array → emitted as `[]` →
-- spec-strict clients (Zod et al.) reject with
-- `expected: record, received: array`. The same gotcha already
-- bit `ping` in v1.0.0-rc1 (project_json_empty_table_gotcha
-- memory). v1.1.1 fix.
local schema = params_schema or { type = "object" }
if type(schema.properties) == "table" and next(schema.properties) == nil then
-- Clone the schema and drop the empty `properties` key. Avoids
-- mutating the caller's table (in case they re-use it across
-- registrations).
local clean = {}
for k, v in pairs(schema) do
if k ~= "properties" then clean[k] = v end
end
schema = clean
end
self.tools[name] = {
name = name,
description = description,
inputSchema = params_schema or { type = "object", properties = {} },
inputSchema = schema,
handler = handler,
annotations = opts and opts.annotations or nil,
outputSchema = opts and opts.outputSchema or nil,
@@ -413,9 +458,28 @@ function lmcp:handle_request(req)
if method == "notifications/roots/list_changed" then
-- Invalidate cached roots for the session that sent this.
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
-- (Other client→server notifications: cancelled, message — no
-- action today; add side-effects here as needed.)
-- (Other client→server notifications drop silently.)
return nil
end
@@ -484,15 +548,59 @@ function lmcp:handle_request(req)
if not tool then
return jsonrpc_error(id, -32601, "Tool not found: " .. tostring(tool_name))
end
-- ctx exposes the request's _meta (issue #13) and the session_id
-- (issue #9 — so handlers can call self:sample(ctx.session_id, …)).
-- Handlers that don't declare a second parameter ignore it (Lua
-- call discards extras).
local ctx = {
-- ctx exposes the request's _meta (issue #13), the session_id
-- (issue #9 — handlers can call self:sample(ctx.session_id, …)),
-- progress() and cancelled() (issue #11), and a `server` back-ref
-- (so lmcp.current_ctx() can find the right server instance
-- 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,
request_id = 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)
if ok then
local resp = { isError = false }
@@ -887,6 +995,10 @@ end
-- ---- 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 body = conn.body
if body == "" then
@@ -942,28 +1054,112 @@ local function _dispatch_post(self, conn)
-- expose it to handler ctx (issue #9 — sampling needs to know which
-- session to push the request onto).
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.
local response = self:handle_request(rpc_req)
if not response then
-- Concurrent handler dispatch (issue #20). Wrap the dispatch call in
-- a coroutine so any tool handler that goes through server.lua:run()
-- (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.
return _build_http_response("202 Accepted",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
"", conn.session_id)
"", session_id)
end
-- If client accepts SSE, respond as a single-event SSE stream.
-- Otherwise plain JSON body.
-- Accept-aware response shape (re-checked at finalise time; survives
-- parking because conn.headers is captured by the closure scope).
local accept = conn.headers["accept"] or ""
if accept:find("text/event%-stream") then
local hdrs = _build_sse_headers(conn.session_id)
return hdrs .. _sse_event(response)
local hdrs = _build_sse_headers(session_id)
return hdrs .. _sse_event(result)
end
return _build_http_response("200 OK",
{ ["Content-Type"] = "application/json",
["Access-Control-Allow-Origin"] = "*" },
response, conn.session_id)
result, session_id)
end
local function _dispatch_options(conn)
@@ -1119,8 +1315,19 @@ local function _conn_read(self, conn)
conn.state = "sse_open"
conn.last_heart = os.time()
elseif conn.method == "POST" then
conn.write_buf = _dispatch_post(self, conn)
conn.state = "writing"
-- _dispatch_post may return nil (issue #20) if the handler
-- 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
conn.write_buf = _build_http_response("405 Method Not Allowed",
{ ["Content-Type"] = "text/plain",
@@ -1195,6 +1402,73 @@ local function _heartbeat_tick(self)
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.) ----
-- Enqueues a JSON-RPC request on the session's SSE stream. The callback
-- fires when the client POSTs back the response (matched by id).
@@ -1322,7 +1596,14 @@ function lmcp:run()
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
if sock == server_sock then
@@ -1365,9 +1646,11 @@ function lmcp:run()
-- Per-tick maintenance.
_drain_notifications(self)
_heartbeat_tick(self)
_scheduler_tick(self) -- issue #20: resume due dispatch coroutines
-- 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
if conn.write_buf ~= "" and conn.state ~= "closing" then
pcall(_conn_write, conn)
+79 -21
View File
@@ -35,7 +35,51 @@ local function tmpname()
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)
-- 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
-- ping loopback: ~1s per -n count. For sub-second, use busy-wait.
if ms < 500 then
@@ -78,6 +122,35 @@ local function run(cmd, timeout_sec)
local out_file = base .. ".out"
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
-- Write a batch wrapper that runs the command and signals completion
local bat_file = base .. ".bat"
@@ -89,22 +162,14 @@ local function run(cmd, timeout_sec)
bf:close()
os.execute('start /B cmd /C "' .. bat_file .. '"')
-- Poll for sentinel
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 completed = poll_loop()
local output = read_file(out_file)
remove_silent(bat_file)
remove_silent(out_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")
end
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("'", "'\\''") .. "' &")
local elapsed = 0
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 completed = poll_loop()
local output = read_file(out_file)
remove_silent(out_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")
end
return output and output ~= "" and output or "(no output)"
+57
View File
@@ -0,0 +1,57 @@
# lmcp Windows MSI build
This directory contains the WiX manifest and packaging files for the
Windows MSI build of lmcp.
## Recommended: cross-build on Linux (one command)
```sh
./build-msi.sh /path/to/output/dir
```
Downloads Lua 5.4 Win64 binaries from LuaBinaries, cross-compiles
LuaSocket via `mingw-w64`, stages `pkg/lua/`, and runs `wixl` to
produce `lmcp-<version>.msi`. No Windows VM required.
Prereqs on a Debian/Ubuntu builder:
```sh
sudo apt install wixl unzip gcc-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 mingw-w64-x86-64-dev curl
```
Version comes from `lmcp.wxs` `Version="…"`. Bump that before
building a release.
## Alternative: build on Windows via WiX toolset
```cmd
sync.sh REM see "tracked vs. generated"
REM ensure pkg/lua/ has the runtime — see below
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.
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
# windows/build-msi.sh — produce lmcp-<ver>.msi on Linux via wixl.
#
# This is the first-time-discovered cross-build path: download Lua 5.4
# Win64 binaries from LuaBinaries, cross-compile LuaSocket with mingw-w64,
# stage windows/pkg/lua/, then invoke wixl on the WiX manifest.
#
# Avoids the VM106-clone + WiX-on-Windows path entirely. ~1 minute on a
# warm cache; ~3-5 minutes cold (downloads ~700 KB + cross-compiles).
#
# Prereqs (apt install on Debian aarch64):
# apt-get install -y wixl unzip gcc-mingw-w64-x86-64 binutils-mingw-w64-x86-64 \
# mingw-w64-x86-64-dev
#
# Usage: ./build-msi.sh [output_dir]
# Output: $output_dir/lmcp-<ver>.msi (default: $PWD)
#
# Version comes from windows/lmcp.wxs Version="…" attribute.
set -eu
here=$(dirname "$(readlink -f "$0")")
root=$(cd "$here/.." && pwd)
out_dir=${1:-$PWD}
work=$(mktemp -d /tmp/lmcp-msi-XXXXXX)
trap "rm -rf $work" EXIT
# Versions — bump as upstream releases.
LUA_VER=5.4.2
LUASOCKET_VER=3.1.0
# Pull current lmcp version from the WiX manifest.
lmcp_ver=$(sed -n 's/.*Version="\([^"]*\)".*/\1/p' "$here/lmcp.wxs" | head -1)
[ -n "$lmcp_ver" ] || { echo "build-msi.sh: cannot parse Version from lmcp.wxs" >&2; exit 1; }
echo "build-msi.sh: lmcp $lmcp_ver, lua $LUA_VER, luasocket $LUASOCKET_VER"
echo "==> 1/5 sync lmcp .lua sources into pkg/"
"$here/sync.sh"
echo "==> 2/5 fetch lua $LUA_VER win64 binaries + dev library"
cd "$work"
curl -sSLf -o lua-bin.zip \
"https://downloads.sourceforge.net/project/luabinaries/${LUA_VER}/Tools%20Executables/lua-${LUA_VER}_Win64_bin.zip"
curl -sSLf -o lua-lib.zip \
"https://downloads.sourceforge.net/project/luabinaries/${LUA_VER}/Windows%20Libraries/Dynamic/lua-${LUA_VER}_Win64_dllw6_lib.zip"
mkdir -p luabin lualib include/lua/54 include/lua54 bin/lua/54 bin/lua54 lib/lua/54 lib/lua54
unzip -q -o lua-bin.zip -d luabin
unzip -q -o lua-lib.zip -d lualib
cp lualib/include/*.h include/lua/54/
cp lualib/include/*.h include/lua54/
cp lualib/liblua54.a lib/lua/54/
cp lualib/liblua54.a lib/lua54/
cp lualib/lua54.dll bin/lua/54/
cp lualib/lua54.dll bin/lua54/
echo "==> 3/5 cross-compile LuaSocket $LUASOCKET_VER for win64"
curl -sSLf -o luasocket.tar.gz \
"https://github.com/lunarmodules/luasocket/archive/refs/tags/v${LUASOCKET_VER}.tar.gz"
tar xzf luasocket.tar.gz
cd "luasocket-${LUASOCKET_VER}"
make -s PLAT=mingw \
CC=x86_64-w64-mingw32-gcc \
LD=x86_64-w64-mingw32-gcc \
LUAV=54 \
LUAINC_mingw_base="$work/include" \
LUALIB_mingw_base="$work/bin" \
> /dev/null
echo "==> 4/5 stage pkg/lua/"
pkg_lua="$here/pkg/lua"
rm -rf "$pkg_lua"
mkdir -p "$pkg_lua/socket" "$pkg_lua/mime"
# WiX manifest expects "lua.exe" (not "lua54.exe").
cp "$work/luabin/lua54.exe" "$pkg_lua/lua.exe"
cp "$work/luabin/lua54.dll" "$pkg_lua/lua54.dll"
cp src/socket.lua "$pkg_lua/"
cp src/mime.lua "$pkg_lua/"
cp src/ltn12.lua "$pkg_lua/"
cp src/socket-3.0.0.dll "$pkg_lua/socket/core.dll"
cp src/ftp.lua "$pkg_lua/socket/"
cp src/headers.lua "$pkg_lua/socket/"
cp src/http.lua "$pkg_lua/socket/"
cp src/smtp.lua "$pkg_lua/socket/"
cp src/tp.lua "$pkg_lua/socket/"
cp src/url.lua "$pkg_lua/socket/"
cp src/mime-1.0.3.dll "$pkg_lua/mime/core.dll"
echo "==> 5/5 wixl: produce MSI"
# wixl wants forward slashes; rewrite Windows-style backslashes in Source=.
wxs_tmp="$work/lmcp.wxs"
sed 's|Source="pkg\\|Source="pkg/|g; s|\\\([a-zA-Z]\)|/\1|g' "$here/lmcp.wxs" > "$wxs_tmp"
mkdir -p "$out_dir"
out_msi="$out_dir/lmcp-${lmcp_ver}.msi"
(cd "$here" && wixl -v "$wxs_tmp" -o "$out_msi")
echo ""
echo "==> done: $out_msi"
ls -la "$out_msi"
sha256sum "$out_msi"
+119
View File
@@ -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>
+24
View File
@@ -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%
+7
View File
@@ -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
+25
View File
@@ -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)"