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

808 lines
30 KiB
Lua

-- pmacs-mcp-prompts/init.lua --- T M9.7 prompts-as-result-buffers.
--
-- Public API:
--
-- local mcp_prompts = require("pmacs-mcp-prompts")
-- mcp_prompts.register(server) -- fetch prompts/list, define each as a command
-- mcp_prompts.unregister(server) -- drop the server's commands
-- mcp_prompts.commands_for(server) -- list of registered command names
-- mcp_prompts.command_name(label, prompt) -- compute the normalized command name
--
-- Spec interpretation (T M9.7):
--
-- Invoking a prompt with arguments produces a *result buffer*; the
-- buffer's view interprets the result format (text / code / markdown).
-- Format determination is *explicit* via `_meta.format` on the
-- `prompts/get` response — the package does not infer format from
-- content. Recognized values: `"text"`, `"code"`, `"markdown"`.
-- Anything else (or absent) falls back to text rendering with a
-- logged warning. Servers that want their prompts rendered as code
-- or markdown set the field; servers that don't get plain text.
--
-- `_meta.language` accompanies `format = "code"` and names the
-- tree-sitter language to attach. `markdown` always uses the
-- `markdown` grammar.
--
-- ===========================================================================
-- IMPORTANT — `_meta.format` is a pmacs convention, not standard MCP.
-- ===========================================================================
--
-- The MCP spec (2025-11-25) defines `_meta` as a free-form metadata
-- bag for prompts/resources/tools but does not standardize a
-- `format` field. Pmacs's choice to key off `_meta.format` is
-- deliberate (explicit beats inferred) and documented here so that
-- the M9.10 deliverable (TRANSITION-M9.md and the
-- MCP-for-package-authors guide) can spread the rule:
--
-- * Existing real-world MCP servers (Anthropic's filesystem,
-- github, slack, etc., as of 2026-Q1) do NOT set `_meta.format`.
-- Every prompt from those servers therefore renders as
-- plain text. This is correct behavior, not a bug — it's the
-- "servers that don't, get plain text" branch above.
-- * Authors of *new* MCP servers that want their prompts to
-- render with code or markdown highlighting MUST set
-- `_meta.format = "code"` (with a `_meta.language`) or
-- `_meta.format = "markdown"` on each `prompts/get` response.
-- * If/when the upstream MCP spec adopts a format-hint field,
-- this package will switch to whatever the spec defines and
-- keep `_meta.format` as a backwards-compatibility fallback
-- until v2.0.
--
-- M9.10 owners: surface this rule in TRANSITION-M9.md and the
-- MCP-for-package-authors guide so package authors know the
-- contract before they ship a server.
-- ===========================================================================
--
-- The package consumes M9.5's notifications dispatcher with zero new
-- pmacs.mcp.* APIs — that's the M9.5 framing's structural property.
-- M9.7 ships exactly like M9.6: fixture-package + fake-server
-- extensions, no Rust API growth. The one v0.1 surface expansion is
-- the markdown grammar registered in `BUILTIN_LANGUAGES` (M11
-- measurement counts public Lua/Rust API; grammars and Cargo deps
-- are different dimensions).
--
-- Implementation notes:
--
-- * Command names follow `<server.label>-<prompt.name>` with both
-- halves passed through the same `[a-zA-Z0-9_.-]` allow-list (M9.6
-- finding 11). A label like `"my server!"` and a prompt named
-- `code/review` together produce `my-server--code-review`.
-- * Cross-source collisions (a builtin or another package owning
-- the normalized name) → set_status warn, route through
-- pmacs.error, skip the prompt; rest of the server's prompts
-- register cleanly. M9.6 finding 6 + 10 carry-forward.
-- * Required-arg flow: pmacs.minibuffer.read with chained on_accept
-- callbacks. Optional args are not prompted in v0.1. Prompt-arg
-- coercion does NOT apply (M9.6 finding 12 doesn't carry forward
-- to M9.7) — MCP's `prompts/list` returns arguments as
-- `{name, description?, required?}` triples with NO per-arg
-- schema, so all wire values are strings; coercion would have
-- nothing to coerce against.
-- * Result buffer named `*mcp:<label>:<prompt>*` (parallel to M9.5's
-- `*mcp:<uri>*`). Reused on re-invocation (M9.5 pattern); cursor
-- resets to (0, 0) and overlays clear via `switch_active_buffer`.
-- Read-only via M8 `add_intercept`, painted with a per-buffer
-- `painting` flag bypass (M9.5 prior art).
-- * Format dispatch: text / code / markdown. Code uses
-- `_meta.language`; markdown always uses the `markdown` grammar.
-- Unknown formats fall back to text with a warning.
-- * Multi-message rendering: each message gets a `## <role>`
-- level-2 header followed by content; messages are separated by
-- blank lines.
-- * Single-message rendering: render the content directly with no
-- role header. Matches the spec's "text-format prompt result
-- renders as a plain buffer" reading literally — a one-shot
-- prompt result does not need a `## user` ceremonial line.
-- * Non-text content (`type = "image"` / `"resource"`) renders as
-- `[image: <mimeType>]` / `[resource: <uri>]` placeholders
-- rather than being silently dropped.
-- * Reconciliation on `notifications/prompts/list_changed`: refetch
-- `prompts/list`, hash each advertised prompt, diff against the
-- registered set, register additions, unregister removals,
-- re-register schema changes. Hash is order-sensitive on the
-- required-args list (M9.6 finding 4 carry-forward) so a reorder
-- re-registers and the closure picks up the new prompt order.
-- * Server-gone teardown: dispatch detects "unknown server" /
-- "not ready for requests" on get_prompt failure and unregisters
-- (M9.6 finding 5 carry-forward) so dead commands don't linger
-- in pmacs.command.list().
-- * Notification-subscription refcount: subscribe iff
-- `_registered_count > 0`. M9.6 finding 3 carry-forward.
local M = {}
-- Per-server state, keyed by raw server id.
-- { label, prompts = { [prompt_name] = { command_name, hash } },
-- in_flight, rerun, cancelled }
local _by_server = {}
-- Per-buffer state for the read-only intercept + paint flag.
--
-- Keyed by `tostring(buf)`, which is stable per underlying BufferId
-- (the metamethod formats the wrapped id) — see `builtin/runtime/
-- syntax.lua`'s `highlighted_buffers` for the same pattern. NOT
-- keyed by the userdata itself: `pmacs.buffer.list()` and
-- `pmacs.window.buffer()` return fresh BufferIdLua userdata each
-- call, so a userdata-keyed table only finds the *first* wrapping
-- and silently misses every subsequent lookup. Result: `paint`
-- would return early on every re-invocation, the buffer would
-- never repaint, and the bug would only surface in tests that
-- assert buffer body content (not just buffer count). M9.8's
-- composition test (`m9_8_composes_with_m9_7_render_into_same_buffer`)
-- forces this — string keys are the fix.
local _buffer_state = {}
local function buffer_key(buf)
return tostring(buf)
end
-- Refcount of currently-registered servers. Used to balance the
-- notification subscription so off_notification fires once the count
-- drops to zero (M9.6 finding 3).
local _registered_count = 0
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function server_id(server)
-- McpServerIdLua exposes :raw() — see M9.6 finding 2 (don't parse
-- digits from tostring).
if type(server) ~= "userdata" and type(server) ~= "table" then
error("pmacs-mcp-prompts: 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-prompts: server:raw() failed; runtime contract "
.. "broken (was the McpServerIdLua API renamed?): " .. tostring(raw))
end
return raw
end
local function server_label(server)
for _, row in ipairs(pmacs.mcp.list()) do
if row.id == server then
return row.label or "unnamed"
end
end
return "unnamed"
end
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
local function notify(msg)
pmacs.editor.set_status(msg)
if pmacs.error then
pmacs.error("pmacs-mcp-prompts: " .. msg)
end
end
local function normalize_char(c)
if c:match("[%a%d_%.%-]") then return c end
return "-"
end
-- Apply `normalize_char` byte-by-byte. Shared by `command_name` and
-- by the buffer-name builder below so the two surfaces can never
-- drift: the buffer that a command lands in has the same shape as
-- the command name.
local function normalize_half(s)
local out = ""
for i = 1, #s do
out = out .. normalize_char(s:sub(i, i))
end
return out
end
function M.command_name(label, prompt_name)
return normalize_half(label) .. "-" .. normalize_half(prompt_name)
end
-- Buffer name for a `<label>, <prompt>` pair. Both halves go through
-- `normalize_half` so a label like `"my server!"` and prompt
-- `"code/review"` produce `*mcp:my-server--code-review*` rather than
-- a name with raw spaces and slashes — and the buffer name stays
-- aligned with the command name `my-server--code-review`.
function M.buffer_name(label, prompt_name)
return string.format("*mcp:%s:%s*", normalize_half(label), normalize_half(prompt_name))
end
-- Hash a prompt-list entry. Identity = name + description +
-- arguments-in-document-order (each arg's name + required-flag). The
-- hash is order-sensitive on `arguments` because the prompt-flow
-- closure captures the required-args sequence at register time, so a
-- reorder is a meaningful change that must trigger re-registration.
-- See M9.6 finding 4 for the audit history.
local function prompt_hash(entry)
local parts = { entry.name or "", entry.description or "" }
local args = entry.arguments
if type(args) == "table" then
for _, a in ipairs(args) do
if type(a) == "table" then
local req_flag = a.required == true and "1" or "0"
parts[#parts + 1] = (a.name or "") .. ":" .. req_flag
end
end
end
return table.concat(parts, "|")
end
-- ---------------------------------------------------------------------------
-- Format-hint dispatch
-- ---------------------------------------------------------------------------
-- Recognized v0.1 formats. Anything else falls back to text with a
-- warning. The function is exposed as a test seam so the unknown-
-- format-falls-back test can pin the recognized list without going
-- through a server.
local _RECOGNIZED_FORMATS = { text = true, code = true, markdown = true }
function M._recognized_format(value)
return _RECOGNIZED_FORMATS[value] == true
end
-- Read `_meta.format` and `_meta.language` from a prompts/get response.
-- Returns `(format, language, was_recognized)`. An absent `_meta`,
-- absent `format`, or `format == nil` defaults to "text" silently.
-- An *explicit* unknown format value (`_meta.format = "rtf"`) returns
-- `("text", nil, false)` so the caller can route the warning.
local function resolve_format(response)
local meta = (type(response) == "table") and response._meta or nil
if type(meta) ~= "table" then return "text", nil, true end
local fmt = meta.format
if fmt == nil then return "text", nil, true end
if type(fmt) ~= "string" then return "text", nil, false end
if not M._recognized_format(fmt) then return "text", nil, false end
local lang = nil
if fmt == "code" then
if type(meta.language) == "string" and meta.language ~= "" then
lang = meta.language
end
elseif fmt == "markdown" then
lang = "markdown"
end
return fmt, lang, true
end
-- ---------------------------------------------------------------------------
-- Message rendering
-- ---------------------------------------------------------------------------
-- Render a single content entry to a string. Text content passes
-- through; image/resource content render as a placeholder line so the
-- shape is readable without binary noise. v0.1 only fully renders
-- text; image/resource fidelity is M9.8+ work.
local function render_content(content)
if type(content) ~= "table" then return "" end
local ct = content.type
if ct == "text" then
return tostring(content.text or "")
elseif ct == "image" then
local mt = content.mimeType or "?"
return string.format("[image: %s]", mt)
elseif ct == "resource" then
-- Per the MCP spec the resource shape is `{ resource = { uri, ... } }`;
-- be defensive about the inner field's location.
local uri = "?"
if type(content.resource) == "table" then
uri = tostring(content.resource.uri or "?")
elseif type(content.uri) == "string" then
uri = content.uri
end
return string.format("[resource: %s]", uri)
end
return string.format("[%s: unsupported content type]", tostring(ct))
end
-- Build the body text from a `messages` array.
--
-- Single-message prompts (the common case for text-format prompts):
-- render the content directly, no role header — matches the spec's
-- "renders as a plain buffer" reading literally. A user reading a
-- one-shot prompt result does not need a `## user` ceremonial line.
--
-- Multi-message prompts: each message gets a `## <role>` level-2
-- header followed by a blank line + the rendered content. Messages
-- are separated by blank lines. Level-2 (rather than level-1) keeps
-- level-1 free for any actual title content the messages contain —
-- small consistency that pays off when markdown highlighting is
-- active.
function M._format_messages(messages)
if type(messages) ~= "table" then return "" end
if #messages == 1 then
local msg = messages[1]
if type(msg) == "table" then
return render_content(msg.content)
end
return ""
end
local lines = {}
for i, msg in ipairs(messages) do
if type(msg) == "table" then
local role = tostring(msg.role or "user")
if i > 1 then lines[#lines + 1] = "" end
lines[#lines + 1] = "## " .. role
lines[#lines + 1] = ""
lines[#lines + 1] = render_content(msg.content)
end
end
return table.concat(lines, "\n")
end
-- ---------------------------------------------------------------------------
-- Result buffer
-- ---------------------------------------------------------------------------
local function make_readonly_intercept(buf)
-- Capture the *key* (string) at intercept-attach time, not the
-- userdata. The intercept fires on every buffer mutation, including
-- ones routed through fresh userdata wrappings — keying by string
-- means every fire resolves correctly.
local key = buffer_key(buf)
return function(_op)
local s = _buffer_state[key]
if s and s.painting then return nil end
error("pmacs-mcp-prompts: result buffers are read-only; "
.. "the buffer is repainted on prompt re-invocation.")
end
end
local function paint(buf, text)
local s = _buffer_state[buffer_key(buf)]
if s == nil then return end
s.painting = true
local ok, err = pcall(function()
buf:replace(0, buf:len(), text)
end)
s.painting = false
if not ok then error(err) end
end
-- Look up an existing result buffer for `<label>:<prompt>` or create
-- one. On first creation, attaches the read-only intercept. Returns
-- the buffer handle.
local function find_or_create_result_buffer(label, prompt_name)
local buf_name = M.buffer_name(label, prompt_name)
for _, id in ipairs(pmacs.buffer.list()) do
local d = pmacs.describe.buffer(id)
if d ~= nil and d.name == buf_name then
-- Existing buffer. State entry was inserted at original
-- create time and is keyed by `tostring(buf)`, which is
-- stable per underlying BufferId — so the fresh userdata
-- returned here finds the same entry.
return id
end
end
local buf = pmacs.buffer.create(buf_name)
_buffer_state[buffer_key(buf)] = { painting = false }
pmacs.buffer.add_intercept(buf, make_readonly_intercept(buf))
return buf
end
-- Render an MCP `prompts/get` response into a result buffer.
--
-- Public from M9.8 onward (was `render_result` internal in M9.7's
-- ship cut). Promoted to public on M9.8's request as the second
-- consumer — `pmacs-mcp-ai` composes with this rather than
-- duplicating ~80 LoC of rendering logic. This is the
-- "promote-on-second-consumer" discipline working as designed,
-- not a M9.7 oversight.
--
-- buf = mcp_prompts.render(label, prompt_name, response)
--
-- Arguments:
--
-- * `label` — string used as the first half of the buffer
-- name (`*mcp:<label>:<prompt>*`). For M9.8
-- callers, pass the configured server's label
-- so the buffer reuses the same slot M9.7's
-- auto-registered command would use.
-- * `prompt_name` — second half of the buffer name; the MCP prompt
-- name as advertised by `prompts/list`.
-- * `response` — the *result* object from `pmacs.mcp.get_prompt`'s
-- awaited handle (`{description?, _meta?, messages}`).
-- Format dispatch reads `_meta.format` /
-- `_meta.language`; missing or unrecognized
-- formats fall back to text with a warning.
--
-- Returns the buffer handle. Side effects:
--
-- * Buffer `*mcp:<label>:<prompt>*` is created if absent or
-- repainted in place if present (cursor / region / scroll
-- reset via `switch_active_buffer`).
-- * Active window switches to the buffer.
-- * For `_meta.format = "code"` / `"markdown"`, tree-sitter
-- dispatch + highlight attach are pcall'd; an unknown language
-- falls through to text rendering with a notify.
--
-- Stability: this is the public surface M9.8 depends on. The shape
-- (label, prompt_name, response) → buffer is locked. Internal
-- behavior (which buffer name format, how unknown content types
-- render) may evolve in v0.2; the function-shape contract holds.
function M.render(label, prompt_name, response)
local buf = find_or_create_result_buffer(label, prompt_name)
local fmt, lang, recognized = resolve_format(response)
if not recognized then
local meta = (type(response) == "table") and response._meta or nil
local raw = (type(meta) == "table") and tostring(meta.format) or "?"
notify(string.format(
"unknown format hint %q for %s; falling back to text",
raw, prompt_name))
end
local body = M._format_messages(response and response.messages)
if body == "" then body = "(empty result)" end
paint(buf, body)
-- Switch the active window to the result buffer. This resets cursor
-- to (0,0), clears overlays, resets view_top — covering the Q4
-- commitment (cursor reset, region cleared, scroll reset). Even if
-- the user is already viewing this buffer, the switch re-resets.
pmacs.window.switch_buffer(buf)
-- Format-specific highlight attach. The `_attach_highlight` call
-- requires the active window to be on this buffer, which is now
-- guaranteed by the switch above.
--
-- pcall around `_dispatch` + `_attach_highlight`: a server can
-- name any language (`_meta.language = "klingon"`); pmacs only
-- ships grammars for languages registered in `BUILTIN_LANGUAGES`.
-- An unknown language makes `_dispatch` throw "unknown language:
-- <lang>". Without the pcall the throw escapes the surrounding
-- async coroutine and the user sees a cryptic error for what is
-- a routine "we don't have that grammar" condition. Fall back to
-- text rendering and route a notify so the user knows why.
if fmt == "code" or fmt == "markdown" then
if lang ~= nil then
local ok, err = pcall(function()
pmacs.parse._dispatch(buf, lang)
pmacs.parse._attach_highlight(buf, lang)
end)
if not ok then
notify(string.format(
"no grammar for %q (%s); rendered as text",
lang, tostring(err)))
end
end
end
return buf
end
-- Internal alias retained so the dispatch path inside this package
-- doesn't pay the table-lookup cost on every prompt invocation.
-- Same function, just bound to a local for the hot path.
local render_result = M.render
-- ---------------------------------------------------------------------------
-- Argument prompting
-- ---------------------------------------------------------------------------
-- Forward declaration so dispatch can reach the public unregister
-- when it detects the server has gone away mid-flight (M9.6 finding 5).
local _unregister_for_teardown
local function dispatch(server, label, prompt_name, args)
pmacs.async(function()
local ok, response_or_err = pcall(function()
return pmacs.mcp.get_prompt(server, prompt_name, args):await()
end)
if ok then
render_result(label, prompt_name, response_or_err)
else
local msg
if type(response_or_err) == "table" and type(response_or_err.message) == "string" then
msg = response_or_err.message
else
msg = tostring(response_or_err)
end
pmacs.editor.set_status("MCP " .. prompt_name .. " error: " .. msg)
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
local function prompt_chain(server, label, prompt_name, required, args, idx)
if idx > #required then
dispatch(server, label, prompt_name, args)
return
end
local arg_name = required[idx]
local prompt_text = string.format("%s %s: ", prompt_name, arg_name)
pmacs.minibuffer.read {
prompt = prompt_text,
on_accept = function(value)
args[arg_name] = value or ""
prompt_chain(server, label, prompt_name, required, args, idx + 1)
end,
on_cancel = function()
pmacs.editor.set_status("MCP " .. prompt_name .. ": cancelled")
end,
}
end
-- ---------------------------------------------------------------------------
-- Command body
-- ---------------------------------------------------------------------------
-- Pull required-arg names (in document order) out of a prompts/list
-- entry's `arguments` array. Each arg is `{name, description?, required?}`;
-- include only those with `required = true`. Returns an ordered Lua
-- array.
local function required_args(entry)
local out = {}
local args = entry.arguments
if type(args) ~= "table" then return out end
for _, a in ipairs(args) do
if type(a) == "table" and a.required == true and type(a.name) == "string" then
out[#out + 1] = a.name
end
end
return out
end
local function make_command_body(server, label, prompt_name, entry_required)
return function()
if #entry_required == 0 then
dispatch(server, label, prompt_name, {})
return
end
prompt_chain(server, label, prompt_name, entry_required, {}, 1)
end
end
-- ---------------------------------------------------------------------------
-- Schema rendering for describe-command
-- ---------------------------------------------------------------------------
local function render_prompt_doc(entry)
local lines = {}
local desc = entry.description
if type(desc) ~= "string" or desc == "" then
desc = "(no description)"
end
lines[#lines + 1] = desc
local args = entry.arguments
if type(args) == "table" and #args > 0 then
lines[#lines + 1] = ""
lines[#lines + 1] = "Arguments:"
for _, a in ipairs(args) do
if type(a) == "table" and type(a.name) == "string" then
local req_tag = a.required == true and ", required" or ""
local d = a.description or ""
local suffix = (d ~= "" and (": " .. d)) or ""
lines[#lines + 1] = " " .. a.name .. " (string" .. req_tag .. ")" .. suffix
end
end
end
return table.concat(lines, "\n")
end
function M._render_prompt_doc(entry)
return render_prompt_doc(entry)
end
function M._prompt_hash(entry)
return prompt_hash(entry)
end
-- ---------------------------------------------------------------------------
-- Register / unregister
-- ---------------------------------------------------------------------------
local function fetch_prompts(server)
local response = pmacs.mcp.send_request(server, "prompts/list", {}):await()
local list = (type(response) == "table") and response.prompts or nil
if type(list) ~= "table" then return {} end
local out = {}
for _, entry in ipairs(list) do
if type(entry) == "table" and type(entry.name) == "string" then
out[#out + 1] = entry
end
end
return out
end
local function register_one(state, server, label, entry)
local cmd_name = M.command_name(label, entry.name)
-- Live in-package collision check (M9.6 finding 8 carry-forward).
for pname, pentry in pairs(state.prompts) do
if pentry.command_name == cmd_name and pname ~= entry.name then
notify(string.format(
"collision on %q (skipping %q)",
cmd_name, entry.name))
return
end
end
-- Cross-source collision (M9.6 finding 6 + 10).
if pmacs.command.exists(cmd_name) then
notify(string.format(
"command %q already defined (skipping %q)",
cmd_name, entry.name))
return
end
local req = required_args(entry)
pmacs.command.define {
name = cmd_name,
description = render_prompt_doc(entry),
fn = make_command_body(server, label, entry.name, req),
}
state.prompts[entry.name] = {
command_name = cmd_name,
hash = prompt_hash(entry),
}
end
local function unregister_one(state, prompt_name)
local entry = state.prompts[prompt_name]
if entry == nil then return end
pmacs.command.unregister(entry.command_name)
state.prompts[prompt_name] = nil
end
local function apply_fresh(state, server, fresh)
local fresh_by_name = {}
for _, p in ipairs(fresh) do fresh_by_name[p.name] = p end
local to_drop = {}
for name, _ in pairs(state.prompts) do
if fresh_by_name[name] == nil then to_drop[#to_drop + 1] = name end
end
-- Per-iteration cancellation check. The reconcile() entry point
-- gates against cancellation between the fetch and the apply, but
-- a long apply on a server with many prompts can overlap a fast
-- unregister-then-shutdown. Bail out cleanly mid-loop rather than
-- reviving commands the user has just dropped.
for _, name in ipairs(to_drop) do
if state.cancelled then return end
unregister_one(state, name)
end
for _, p in ipairs(fresh) do
if state.cancelled then return end
local existing = state.prompts[p.name]
if existing == nil then
register_one(state, server, state.label, p)
else
local fresh_hash = prompt_hash(p)
if fresh_hash ~= existing.hash then
-- Schema changed. Unregister and re-register so the captured
-- required-args closure picks up the new list. Keep unregister
-- + register adjacent — see M9.6 finding 8 comment.
unregister_one(state, p.name)
register_one(state, server, state.label, p)
end
end
end
end
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_prompts, server)
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
state.in_flight = false
_unregister_for_teardown(server)
return
end
state.in_flight = false
if state.rerun and _by_server[sid] == state and not state.cancelled then
reconcile(server)
end
end)
end
-- Test seams for the apply_fresh cancellation-during-apply
-- regression test (audit finding E2). Build a state with no
-- registrations, then drive apply_fresh with cancelled = true and
-- verify nothing landed. Leading-underscore: not stable surface.
function M._make_test_state(label)
return { label = label, prompts = {}, in_flight = false, rerun = false, cancelled = false }
end
function M._apply_fresh(state, server, fresh)
apply_fresh(state, server, fresh)
end
-- ---------------------------------------------------------------------------
-- Notification dispatcher (M9.5 third consumer — M9.5 + M9.6 + M9.7)
-- ---------------------------------------------------------------------------
local _notification_method = "notifications/prompts/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
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
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-prompts.register: server handle has no resolvable id")
end
if _by_server[sid] ~= nil then
reconcile(server)
return
end
ensure_notification_handler()
local label = server_label(server)
_by_server[sid] = {
label = label,
prompts = {},
in_flight = false,
rerun = false,
cancelled = false,
}
_registered_count = _registered_count + 1
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
state.cancelled = true
for name, _ in pairs(state.prompts) do
pmacs.command.unregister(state.prompts[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
_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.prompts) do
out[#out + 1] = entry.command_name
end
table.sort(out)
return out
end
return M