pmacs/tests/fixtures/pmacs-mcp-tools/init.lua

726 lines
29 KiB
Lua

-- 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 `<server.label>-<tool.name>`, with both
-- halves passed through the same character normalizer. The spec
-- example `filesystem-read-file` reads as "<server name>-<tool>";
-- 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: <sid>` when
-- the server has been stopped/dropped from the manager, and `server
-- <sid> 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