-- pmacs-mcp-tools/init.lua --- T M9.6 tools-as-commands. -- -- Public API: -- -- local mcp_tools = require("pmacs-mcp-tools") -- mcp_tools.register(server) -- fetch tools/list, define each as a command -- mcp_tools.unregister(server) -- drop the server's commands -- mcp_tools.commands_for(server) -- list of registered command names -- mcp_tools.command_name(label, tool)-- compute the normalized command name -- -- Spec interpretation (T M9.6): -- -- The spec phrases registration as "for each connected MCP server, -- register its tools as Lua commands". The trigger is not specified. -- This package follows the same explicit-call pattern as -- pmacs-mcp-resources (T M9.5): the user spawns the server via -- `pmacs.mcp.spawn`, then calls `mcp_tools.register(server)` once -- the server is initialized. From there, the package's -- notifications/tools/list_changed handler keeps the registered -- commands in sync without further user intervention. The shipped -- user-facing example that wires this up automatically is M9.8's -- AI-assistance package; M9.6's contract is the per-server register -- primitive plus its lifecycle. -- -- The package currently lives in tests/fixtures/ rather than -- builtin/packages/ because M9.5/M9.6/M9.7 are deliberately -- primitives — M9.8 is the milestone that ships a user-installable -- MCP example. The test fixture exercises the full primitive surface -- so M9.8 can compose it cleanly. -- -- The package consumes M9.5's notifications/tools/list_changed -- dispatcher with zero new pmacs.mcp.* APIs — that's the structural -- property the M9.5 framing predicted: the second consumer of -- on_notification lands without forcing further core surface. -- -- Implementation notes: -- -- * Command names follow `-`, with both -- halves passed through the same character normalizer. The spec -- example `filesystem-read-file` reads as "-"; -- in pmacs's vocabulary the spawn-time `label` is the server name -- (see `pmacs.mcp.spawn { label = ... }`), so the two framings line -- up. Any character outside `[a-zA-Z0-9_.-]` becomes `-`. Both -- halves are normalized so a label like "my server!" still -- produces a registry-clean command name; the registry's `define` -- only validates non-empty, so an unnormalized label would be -- accepted but produce surprising command palette entries. -- * Two tools normalizing to the same command name → second -- registration is skipped with a warning. Silent overwrite would -- hide a real configuration bug. The same warn-and-skip path -- handles cross-source collisions (a tool whose normalized name -- is already owned by a builtin, a user definition, or a -- different MCP server's tools-as-commands registration). A -- second MCP server with the same `label` whose tools normalize -- to names already taken by the first server will see its tools -- skipped — that's intentional. Disambiguation is the operator's -- job at spawn time (use distinct labels), not the package's. -- * describe-command is satisfied by stuffing the tool's schema -- into the registered command's `description` field; pmacs -- describe.command surfaces description verbatim. -- * Required-arg flow: pmacs.minibuffer.read with chained on_accept -- callbacks. Each accept either kicks off the next prompt or -- dispatches the tool. Optional args are not prompted in v0.1 -- (spec mandates required only); callers wanting full arg surface -- use pmacs.mcp.invoke_tool directly. Typed-arg coercion (integer, -- number, boolean) happens at accept time so MCP servers receive -- the JSON shape their schema advertises rather than a string. -- Empty input for a required arg is sent as the empty string and -- left to the server to validate; v0.1 doesn't second-guess the -- server's interpretation of "missing" arguments. -- * Result delivery: pmacs.editor.set_status with first-line -- truncation; the frontend handles terminal-width clipping. -- Multi-line / large results are M9.8's result-buffer job; v0.1 -- delivers a "did the call work?" signal. -- * Reconciliation on list_changed: refetch tools/list, hash each -- advertised tool, diff against the registered set, register -- additions, unregister removals, re-register schema changes. -- The hash is order-sensitive on `inputSchema.required` because -- prompt order is determined by the captured closure, so a -- reorder of required-args is a meaningful change that must -- trigger re-registration. local M = {} -- Per-server state, keyed by server.id (the integer from the -- McpServerIdLua handle). Value is a record: -- { label = "...", tools = { [tool_name] = { command_name, hash } }, -- in_flight = bool, rerun = bool, cancelled = bool } -- `tools` keys are the *original* tool names (preserve `/`); the -- `command_name` is the normalized form actually registered. -- `in_flight` / `rerun` serialize concurrent reconciles (initial fetch -- vs. notifications/tools/list_changed firing during it). `cancelled` -- lets a mid-flight reconcile bail when M.unregister has run. local _by_server = {} -- Number of currently-registered servers. Used to off_notification -- once the count drops to zero, so the package's -- notifications/tools/list_changed subscription is balanced rather -- than leaked across register/unregister cycles. local _registered_count = 0 -- --------------------------------------------------------------------------- -- Helpers -- --------------------------------------------------------------------------- local function server_id(server) -- McpServerIdLua exposes :raw() which returns the underlying integer -- directly. Earlier drafts parsed digits out of tostring(server), -- which silently broke if the Display impl ever changed shape. -- Wrapped in pcall so a future runtime that renames/removes :raw() -- surfaces as a clean register-time error rather than an opaque -- "attempt to call a nil value" mid-coroutine. if type(server) ~= "userdata" and type(server) ~= "table" then error("pmacs-mcp-tools: server handle must be McpServerIdLua, got " .. type(server)) end local ok, raw = pcall(function() return server:raw() end) if not ok then error("pmacs-mcp-tools: server:raw() failed; runtime contract " .. "broken (was the McpServerIdLua API renamed?): " .. tostring(raw)) end return raw end local function server_label(server) -- The label was set at spawn time and surfaces through -- pmacs.mcp.list(). One walk per register() call is fine — server -- counts are typically single-digit, and the label is then cached -- on `_by_server[sid].label` so reconcile() doesn't rewalk. for _, row in ipairs(pmacs.mcp.list()) do if row.id == server then return row.label or "unnamed" end end return "unnamed" end -- Heuristic: does this Lua-error string look like the server has gone -- away? `pmacs.mcp.invoke_tool` raises `unknown server: ` when -- the server has been stopped/dropped from the manager, and `server -- is not ready for requests` when the state is non-Initialized -- (Crashed/ShuttingDown/Stopped). Either way the registered commands -- are stale; trigger a teardown. local function looks_like_server_gone(err) local s = type(err) == "table" and tostring(err.message or "") or tostring(err) return s:find("unknown server", 1, true) ~= nil or s:find("not ready for requests", 1, true) ~= nil end -- Notify the user about a non-fatal anomaly. set_status gives an -- immediate one-line echo; pmacs.error (when the host has installed -- it) is the project's persistent log surface — same convention as -- builtin/runtime/{async,mcp,syntax}.lua. Without the pmacs.error -- branch, a collision warning could be overwritten by the very next -- set_status from any source, leaving no trace of the misconfiguration. local function notify(msg) pmacs.editor.set_status(msg) if pmacs.error then pmacs.error("pmacs-mcp-tools: " .. msg) end end -- Normalize one character. The allow-list is `[a-zA-Z0-9_.-]`; anything -- else collapses to `-`. Defensive against unusual MCP tool-name -- characters (whitespace, unicode punctuation, etc). local function normalize_char(c) if c:match("[%a%d_%.%-]") then return c end return "-" end function M.command_name(label, tool_name) -- Both halves run through normalize_char so unusual server labels -- (e.g., "my server!" or "filesystem/v2") produce registry-clean -- command names. The Rust CommandRegistry only validates non-empty, -- so without label normalization a `pmacs.command.define` call -- would happily accept "my server!-echo" — passing M-x completion -- but breaking the look-it-up-and-rebind workflow because the name -- contains characters the keymap parser doesn't expect. local out = "" for i = 1, #label do out = out .. normalize_char(label:sub(i, i)) end out = out .. "-" for i = 1, #tool_name do out = out .. normalize_char(tool_name:sub(i, i)) end return out end -- Hash a tool definition. Stable identity = name + description + -- inputSchema shape. Two tools advertised back-to-back with the same -- shape produce the same hash; mutating any field changes it. Not -- cryptographic; collision resistance is per-server-per-name only. -- -- `required` is hashed in document order — make_command_body's prompt -- closure prompts in that order, so a reorder of `required` (same -- membership, different sequence) is a meaningful change that must -- trigger re-registration. An earlier draft sorted a copy of `required` -- before hashing, which made reorder-only mutations invisible to -- the diff and left the closure prompting in stale order. -- `properties` keys are still sorted because the iteration order of a -- Lua table from a JSON object is implementation-defined; sorting -- gives a deterministic hash without losing meaningful information. local function tool_hash(tool) local parts = { tool.name or "", tool.description or "" } local schema = tool.inputSchema if type(schema) == "table" then local req = schema.required if type(req) == "table" then parts[#parts + 1] = "required:" .. table.concat(req, ",") end local props = schema.properties if type(props) == "table" then local keys = {} for k, _ in pairs(props) do keys[#keys + 1] = k end table.sort(keys) for _, k in ipairs(keys) do local p = props[k] local t = (type(p) == "table" and p.type) or "" parts[#parts + 1] = k .. ":" .. tostring(t) end end end return table.concat(parts, "|") end -- --------------------------------------------------------------------------- -- Schema rendering for describe-command -- --------------------------------------------------------------------------- local function render_schema_doc(tool) local lines = {} local desc = tool.description if type(desc) ~= "string" or desc == "" then desc = "(no description)" end lines[#lines + 1] = desc local schema = tool.inputSchema local props = (type(schema) == "table") and schema.properties or nil local required = (type(schema) == "table") and schema.required or nil -- Build a set of required names for O(1) lookup. local req_set = {} if type(required) == "table" then for _, name in ipairs(required) do req_set[name] = true end end if type(props) == "table" and next(props) ~= nil then lines[#lines + 1] = "" lines[#lines + 1] = "Arguments:" -- Stable iteration: required first (in the spec's required order), -- then any remaining properties alphabetically. local ordered = {} if type(required) == "table" then for _, name in ipairs(required) do if props[name] ~= nil then ordered[#ordered + 1] = name end end end local optional_keys = {} for k, _ in pairs(props) do if not req_set[k] then optional_keys[#optional_keys + 1] = k end end table.sort(optional_keys) for _, k in ipairs(optional_keys) do ordered[#ordered + 1] = k end for _, name in ipairs(ordered) do local p = props[name] or {} local ty = p.type or "any" local d = p.description or "" local req_tag = req_set[name] and ", required" or "" local suffix = (d ~= "" and (": " .. d)) or "" lines[#lines + 1] = " " .. name .. " (" .. ty .. req_tag .. ")" .. suffix end end return table.concat(lines, "\n") end -- --------------------------------------------------------------------------- -- Result delivery -- --------------------------------------------------------------------------- -- Extract the response's first text content and keep just the first -- line. The first-line clip matters: a multi-line set_status would -- corrupt the row layout. The *width* clipping is the frontend's job -- — `emit_status_overlay` already truncates to terminal width -- (frontend.rs:`status_overlay_truncates_text_to_terminal_width`). -- Earlier drafts also imposed a hardcoded 80-char "..." cap here, -- which clipped useful detail on wide terminals (and the package has -- no width info to do better). The honest fix: emit the full first -- line, let the frontend truncate at the actual edge. local function format_status(prefix, text) local first_line = text:match("([^\n]*)") or text return prefix .. ": " .. first_line end local function content_text(response) if type(response) ~= "table" then return tostring(response) end local content = response.content if type(content) ~= "table" then return "" end local out = {} for _, entry in ipairs(content) do if type(entry) == "table" and entry.type == "text" and type(entry.text) == "string" then out[#out + 1] = entry.text end end return table.concat(out, "") end local function deliver_result(tool_name, response) local text = content_text(response) if text == "" then text = "(no text content)" end pmacs.editor.set_status(format_status("MCP " .. tool_name, text)) end local function deliver_error(tool_name, err) -- The async runtime raises errors as either a string or a table -- `{ tag = "...", message = "..." }`. Normalize. local msg if type(err) == "table" and type(err.message) == "string" then msg = err.message else msg = tostring(err) end pmacs.editor.set_status(format_status("MCP " .. tool_name .. " error", msg)) end -- --------------------------------------------------------------------------- -- Argument prompting -- --------------------------------------------------------------------------- -- -- Sequential minibuffer prompts. `required` is the ordered list of -- arg names; the prompt for arg N's `on_accept` either kicks off -- prompt N+1 or dispatches the tool with the assembled args. -- Forward declaration so dispatch can reach the public unregister -- when it detects the server has gone away mid-flight (issue 5). local _unregister_for_teardown local function dispatch(server, tool_name, args) pmacs.async(function() local ok, response_or_err = pcall(function() return pmacs.mcp.invoke_tool(server, tool_name, args):await() end) if ok then deliver_result(tool_name, response_or_err) else deliver_error(tool_name, response_or_err) -- If the failure shape says the server is gone, drop the now- -- stale registrations. Without this the registered commands -- linger past the server's lifetime, and every subsequent -- invocation hits the same dead-server error. The status line -- already reflects the underlying failure (deliver_error above); -- the teardown is a silent side-effect. if looks_like_server_gone(response_or_err) then local sid = server_id(server) if sid ~= nil and _by_server[sid] ~= nil then _unregister_for_teardown(server) end end end end) end -- Coerce a minibuffer-typed string into the JSON shape the tool's -- schema declares. v0.1 covers the scalar types (string, integer, -- number, boolean); compound types (array, object, null) and unknown -- types fall through as strings so the server can decide. Returns -- `(coerced_value, nil)` on success or `(nil, err_msg)` on a parse -- failure so the caller can route the error to the status line and -- abort the dispatch instead of sending malformed JSON to the server. local function coerce_arg(value, p) local ty = (type(p) == "table") and p.type or nil if ty == nil or ty == "string" or ty == "any" then return value, nil end if ty == "integer" then local n = tonumber(value) if n == nil then return nil, string.format("expected integer, got %q", value) end if n ~= math.floor(n) then return nil, string.format("expected integer, got %q", value) end return math.floor(n), nil end if ty == "number" then local n = tonumber(value) if n == nil then return nil, string.format("expected number, got %q", value) end return n, nil end if ty == "boolean" then if value == "true" then return true, nil end if value == "false" then return false, nil end return nil, string.format("expected true/false, got %q", value) end -- Unknown type — pass the literal string through. Servers that need -- structured input via M-x can prompt callers to use -- `pmacs.mcp.invoke_tool` directly instead. return value, nil end local function prompt_chain(server, tool_name, required, props, args, idx) if idx > #required then dispatch(server, tool_name, args) return end local arg_name = required[idx] local p = (type(props) == "table") and props[arg_name] or nil local ty = (type(p) == "table") and (p.type or "any") or "any" local prompt = string.format("%s (%s) %s: ", tool_name, ty, arg_name) pmacs.minibuffer.read { prompt = prompt, on_accept = function(value) local coerced, err = coerce_arg(value or "", p) if err ~= nil then pmacs.editor.set_status(string.format( "MCP %s arg %s: %s", tool_name, arg_name, err)) return end args[arg_name] = coerced prompt_chain(server, tool_name, required, props, args, idx + 1) end, on_cancel = function() pmacs.editor.set_status("MCP " .. tool_name .. ": cancelled") end, } end -- --------------------------------------------------------------------------- -- Command body -- --------------------------------------------------------------------------- local function make_command_body(server, tool_name, schema) return function() local props = (type(schema) == "table") and schema.properties or {} local required = (type(schema) == "table") and schema.required or {} if type(required) ~= "table" or #required == 0 then dispatch(server, tool_name, {}) return end prompt_chain(server, tool_name, required, props, {}, 1) end end -- --------------------------------------------------------------------------- -- Register / unregister -- --------------------------------------------------------------------------- local function fetch_tools(server) -- send_request returns a Handle; await inside an async coroutine. local response = pmacs.mcp.send_request(server, "tools/list", {}):await() local list = (type(response) == "table") and response.tools or nil if type(list) ~= "table" then return {} end local out = {} for _, tool in ipairs(list) do if type(tool) == "table" and type(tool.name) == "string" then out[#out + 1] = tool end end return out end local function register_one(state, server, label, tool) local cmd_name = M.command_name(label, tool.name) -- Collision check is live against this server's currently-registered -- commands. An earlier draft used a precomputed `seen` set, which -- broke schema-change reconciliation: unregister_one + register_one -- ran inside the same loop iteration, but `seen` was built before -- the unregister, so the re-register saw the pending name as a -- collision and silently skipped. The live read is the simple -- fix — `state.tools` always reflects what's registered now. for tname, entry in pairs(state.tools) do if entry.command_name == cmd_name and tname ~= tool.name then notify(string.format( "collision on %q (skipping %q)", cmd_name, tool.name)) return end end -- Cross-source collision: the normalized name is already taken by -- something outside this package — a builtin command, a user -- definition, or another MCP server's mcp-tools registration. The -- in-package collision branch above wouldn't see it (state.tools is -- per-server), and pmacs.command.define would raise DuplicateName, -- which propagates out of the async coroutine and aborts further -- registrations. Skip + warn instead so the rest of the server's -- tools still register cleanly. if pmacs.command.exists(cmd_name) then notify(string.format( "command %q already defined (skipping %q)", cmd_name, tool.name)) return end pmacs.command.define { name = cmd_name, description = render_schema_doc(tool), fn = make_command_body(server, tool.name, tool.inputSchema), } state.tools[tool.name] = { command_name = cmd_name, hash = tool_hash(tool), } end local function unregister_one(state, tool_name) local entry = state.tools[tool_name] if entry == nil then return end pmacs.command.unregister(entry.command_name) state.tools[tool_name] = nil end -- Apply the fresh tools/list against the per-server state. Same diff -- shape used by both initial register and notification-driven -- reconcile — keeping the mutation in one function lets the in-flight -- guard serialize them. local function apply_fresh(state, server, fresh) local fresh_by_name = {} for _, t in ipairs(fresh) do fresh_by_name[t.name] = t end local to_drop = {} for name, _ in pairs(state.tools) do if fresh_by_name[name] == nil then to_drop[#to_drop + 1] = name end end for _, name in ipairs(to_drop) do unregister_one(state, name) end for _, t in ipairs(fresh) do local existing = state.tools[t.name] if existing == nil then register_one(state, server, state.label, t) else local fresh_hash = tool_hash(t) if fresh_hash ~= existing.hash then -- Schema changed. Unregister and re-register so the -- prompt-flow closure picks up the new required-args list. -- -- Important: the unregister and register MUST stay in the -- same synchronous Lua block — register_one's cross-source -- guard (`pmacs.command.exists(cmd_name)`) returns true if -- the slot is still occupied. If a future refactor inserts -- an await between these two lines, the cross-source check -- will fire on what is logically the same package's slot -- and silently skip the re-registration, leaving the closure -- with the stale schema. Keep them adjacent. unregister_one(state, t.name) register_one(state, server, state.label, t) end end end end -- reconcile() is the single mutation entry point: initial register -- routes through it, and so does every notifications/tools/list_changed -- arrival. The in-flight guard collapses overlapping calls into one -- in-flight + at-most-one queued, so a notification arriving during -- the initial fetch doesn't run two interleaving tools/list coroutines -- against the same `state.tools`. local function reconcile(server) local sid = server_id(server) local state = _by_server[sid] if state == nil then return end if state.in_flight then state.rerun = true return end state.in_flight = true state.rerun = false pmacs.async(function() local ok, fresh_or_err = pcall(fetch_tools, server) -- M.unregister may have run while we were awaiting tools/list. If -- so, `state` is detached from `_by_server` and any registrations -- it tries would resurrect commands that were just torn down. if state.cancelled or _by_server[sid] ~= state then state.in_flight = false return end if ok then apply_fresh(state, server, fresh_or_err) elseif looks_like_server_gone(fresh_or_err) then -- The server vanished mid-fetch (e.g. crashed between the -- initial register and tools/list returning). Drop registrations -- so M-x doesn't keep advertising dead commands. Clear the -- in_flight flag *before* unregister so the orphaned `state` -- doesn't carry a stale "true" — even though `state` is detached -- from `_by_server` immediately after, future code that holds a -- pre-teardown reference (or future test seams) sees a clean -- terminal value. state.in_flight = false _unregister_for_teardown(server) return end -- else: transient JSON-RPC failure on a still-alive server; leave -- the existing registrations in place. state.in_flight = false if state.rerun and _by_server[sid] == state and not state.cancelled then reconcile(server) end end) end -- --------------------------------------------------------------------------- -- Notification dispatcher (M9.5 second consumer) -- --------------------------------------------------------------------------- local _notification_method = "notifications/tools/list_changed" local _notification_token = nil local function ensure_notification_handler() if _notification_token ~= nil then return end _notification_token = pmacs.mcp.on_notification( _notification_method, function(server, _params) reconcile(server) end) end -- Drop the package's notification subscription once no servers remain -- registered. Without this, ensure_notification_handler set the token -- on the first M.register and the package kept consuming list_changed -- events forever — harmless (reconcile bails when state is nil) but -- an unbalanced subscribe. local function release_notification_handler() if _notification_token == nil then return end if _registered_count > 0 then return end pmacs.mcp.off_notification(_notification_method, _notification_token) _notification_token = nil end -- Test seam (unstable, do not rely from external code). -- Reports whether the package currently holds a -- notifications/tools/list_changed subscription. The bool surfaces -- the lifecycle invariant ("subscribed iff at least one server is -- registered") to acceptance tests without exposing the token. The -- underscore prefix marks this as internal — package authors building -- on top of pmacs-mcp-tools must not depend on this symbol; it can -- change shape between any two milestones without notice. function M._has_notification_subscription() return _notification_token ~= nil end -- --------------------------------------------------------------------------- -- Public API -- --------------------------------------------------------------------------- function M.register(server) local sid = server_id(server) if sid == nil then error("pmacs-mcp-tools.register: server handle has no resolvable id") end if _by_server[sid] ~= nil then -- Idempotent re-register: reconcile against the live tool list. reconcile(server) return end ensure_notification_handler() local label = server_label(server) _by_server[sid] = { label = label, tools = {}, in_flight = false, rerun = false, cancelled = false, } _registered_count = _registered_count + 1 -- Initial population goes through reconcile so it shares the -- in-flight guard with notification-driven re-fetches. From an -- empty state.tools, apply_fresh's diff degenerates to "register -- every tool" — same effect as the old explicit loop. reconcile(server) end function M.unregister(server) local sid = server_id(server) if sid == nil then return end local state = _by_server[sid] if state == nil then return end -- Mark the state cancelled before any unregister_one runs; an -- in-flight reconcile coroutine checks `state.cancelled` after it -- awakens from its tools/list await and bails without re-defining -- commands we just dropped. state.cancelled = true for name, _ in pairs(state.tools) do pmacs.command.unregister(state.tools[name].command_name) end _by_server[sid] = nil _registered_count = _registered_count - 1 if _registered_count < 0 then _registered_count = 0 end release_notification_handler() end -- Non-public alias so dispatch / reconcile can teardown without a -- forward-declaration dance against M.unregister's later definition. _unregister_for_teardown = M.unregister function M.commands_for(server) local sid = server_id(server) local state = _by_server[sid] if state == nil then return {} end local out = {} for _, entry in pairs(state.tools) do out[#out + 1] = entry.command_name end table.sort(out) return out end -- Test seam (unstable, do not rely from external code). -- Renders the documentation string the package would attach to a -- registered command for `tool` (a tools/list entry shape). -- Underscore prefix marks it as not a stable user-facing API; M9.6 -- acceptance tests use it to pin the "(no description)" fallback -- shape without spinning up a server. The format may change between -- milestones — package authors who need to render schema docs should -- duplicate the rendering rather than depend on this seam. function M._render_schema_doc(tool) return render_schema_doc(tool) end -- Test seam (unstable, do not rely from external code). -- Computes the same identity hash the reconcile loop uses to detect -- meaningful schema changes. Acceptance tests use this to pin the -- "required-arg order is part of the identity" property without -- driving a live server through change_tool_schema. function M._tool_hash(tool) return tool_hash(tool) end return M