445 lines
16 KiB
Lua
445 lines
16 KiB
Lua
-- pmacs-mcp-resources/init.lua --- T M9.5 resources-as-buffers.
|
|
--
|
|
-- Public API:
|
|
--
|
|
-- local mcp_res = require("pmacs-mcp-resources")
|
|
-- local buf = mcp_res.open(server, uri) -- returns BufferIdLua
|
|
-- mcp_res.close(buf)
|
|
-- mcp_res.is_stale(buf) -> bool -- buffer hasn't refreshed since server died
|
|
-- mcp_res.children(buf) -> { uri1, uri2, ... } -- only meaningful for directory buffers
|
|
--
|
|
-- Implementation notes:
|
|
--
|
|
-- * `open` is called from inside a `pmacs.async(function() ... end)`
|
|
-- coroutine, since it awaits `pmacs.mcp.read_resource(...)`.
|
|
-- * Subscriptions: when `open` runs against a server whose
|
|
-- capabilities advertise `resources.subscribe`, send a
|
|
-- `resources/subscribe` request and register the (server, uri,
|
|
-- buffer) triple in a refresh registry.
|
|
-- * Refresh on `notifications/resources/updated`: hooked once at
|
|
-- module load via `pmacs.mcp.on_notification`. The handler
|
|
-- looks up the buffer by (server, uri) and dispatches a fresh
|
|
-- async coroutine to refetch + repaint.
|
|
-- * Server lifecycle: when a subscribed buffer's server transitions
|
|
-- to a non-Initialized state, the buffer is marked stale.
|
|
-- `is_stale(buf)` returns true; the buffer keeps its last-known
|
|
-- content. Re-opening the resource after the server returns to
|
|
-- Initialized re-establishes the subscription.
|
|
-- * The visible buffer is read-only via intercept; package paints
|
|
-- bypass with a per-buffer painting flag (M8 CC-1 pattern).
|
|
|
|
local view = require("pmacs-mcp-resources.view")
|
|
|
|
local M = {}
|
|
|
|
-- Per-buffer state. Keyed by `tostring(buf)`, which is stable per
|
|
-- underlying BufferId — see `builtin/runtime/syntax.lua`'s
|
|
-- `highlighted_buffers` and `pmacs-mcp-prompts`'s `_buffer_state`
|
|
-- (M9.7 audit finding) for the same pattern. NOT keyed by the
|
|
-- userdata itself: `pmacs.window.buffer()` and `pmacs.buffer.list()`
|
|
-- return fresh BufferIdLua wrappings on each call, so a userdata-
|
|
-- keyed table only finds the *first* wrapping and silently misses
|
|
-- every subsequent lookup. The `pmacs-mcp-resources.open-at-point`
|
|
-- command body fetches the active buffer via `pmacs.window.buffer()`
|
|
-- and feeds it into `open_child_at_point(buf)`; if the storage table
|
|
-- were keyed by the userdata itself, that lookup would miss and RET
|
|
-- would be a silent no-op. Pinned by
|
|
-- `m9_5_state_lookup_survives_fresh_buffer_userdata`.
|
|
--
|
|
-- Value is a record:
|
|
-- { server = McpServerIdLua, uri = "...", server_raw = u64,
|
|
-- painting = bool, stale = bool, children = {...},
|
|
-- subscribed = bool, kind = "...", mimeType = "..." }
|
|
local _state = {}
|
|
|
|
local function buffer_key(buf)
|
|
return tostring(buf)
|
|
end
|
|
|
|
-- Reverse lookup: (server_raw, uri) -> buffer handle. Used by the
|
|
-- notification handler and the lifecycle observer.
|
|
local _by_server_uri = {}
|
|
|
|
local function reverse_key(server_raw, uri)
|
|
return tostring(server_raw) .. "\0" .. uri
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Painting
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function paint(buf, text)
|
|
local s = _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
|
|
|
|
local function make_readonly_intercept(buf)
|
|
return function(_op)
|
|
local s = _state[buffer_key(buf)]
|
|
if s and s.painting then return nil end
|
|
error("pmacs-mcp-resources: resource buffers are read-only; " ..
|
|
"use pmacs.mcp.send_request('tools/call', ...) to mutate " ..
|
|
"server-side resources.")
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Server-capability check
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function server_supports_subscribe(server)
|
|
local caps = pmacs.mcp.capabilities(server)
|
|
if type(caps) ~= "table" then return false end
|
|
local r = caps.resources
|
|
if type(r) ~= "table" then return false end
|
|
return r.subscribe == true
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Render-into-buffer
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function render_into(buf, content_response)
|
|
local rendered = view.render(content_response)
|
|
local s = _state[buffer_key(buf)]
|
|
if s ~= nil then
|
|
s.kind = rendered.kind
|
|
s.mimeType = rendered.mimeType
|
|
s.children = rendered.children or {}
|
|
end
|
|
paint(buf, rendered.body)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Refresh
|
|
-- ---------------------------------------------------------------------------
|
|
--
|
|
-- Called from the notifications/resources/updated handler. Dispatches
|
|
-- an async coroutine that re-reads the resource and re-paints the
|
|
-- buffer. If the read fails (e.g. server crashed mid-update), the
|
|
-- error is swallowed and the buffer is marked stale.
|
|
|
|
local function refresh(buf)
|
|
local s = _state[buffer_key(buf)]
|
|
if s == nil then return end
|
|
pmacs.async(function()
|
|
-- The M9.2 cache holds the prior content. notifications/resources/
|
|
-- updated is exactly the signal that the cached content is no
|
|
-- longer valid; invalidate before re-reading so we get a fresh
|
|
-- wire fetch rather than the stale cached value.
|
|
pmacs.mcp.invalidate_resource(s.server, s.uri)
|
|
local ok, response = pcall(function()
|
|
return pmacs.mcp.read_resource(s.server, s.uri):await()
|
|
end)
|
|
if not ok then
|
|
s.stale = true
|
|
if pmacs.error then
|
|
pmacs.error("pmacs-mcp-resources: refresh failed for " ..
|
|
s.uri .. ": " .. tostring(response))
|
|
end
|
|
return
|
|
end
|
|
render_into(buf, response)
|
|
s.stale = false
|
|
end)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Notification handler — hooked once at module load.
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
pmacs.mcp.on_notification("notifications/resources/updated",
|
|
function(server, params)
|
|
local uri = params and params.uri
|
|
if type(uri) ~= "string" then return end
|
|
local buf = _by_server_uri[reverse_key(server:raw(), uri)]
|
|
if buf ~= nil then refresh(buf) end
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Open child (directory navigation)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Resolve the URI at the cursor's current line (in a directory
|
|
-- buffer). Returns the URI string or nil.
|
|
--
|
|
-- Line indexing: `pmacs.editor.cursor_line()` is *0-based* — the
|
|
-- first displayed line is line 0. The directory buffer renders one
|
|
-- child URI per line, so cursor line N corresponds to
|
|
-- `s.children[N + 1]` (Lua arrays are 1-indexed). An earlier draft
|
|
-- compared `line < 1` against the 0-based result, which silently
|
|
-- swallowed line 0 (RET on the first URI did nothing) and shifted
|
|
-- every other line up by one (RET on line 1 opened children[1] —
|
|
-- still the first URI — instead of children[2]). Pinned by
|
|
-- `m9_5_directory_ret_keybinding_opens_first_child`.
|
|
local function child_uri_at_cursor(buf)
|
|
local s = _state[buffer_key(buf)]
|
|
if s == nil or s.kind ~= "directory" then return nil end
|
|
if pmacs.editor == nil or pmacs.editor.cursor_line == nil then
|
|
-- Fallback: use the first child if we can't query the cursor
|
|
-- (test environments without a window may not have cursor_line).
|
|
return s.children[1]
|
|
end
|
|
local line = pmacs.editor.cursor_line()
|
|
if type(line) ~= "number" or line < 0 then return nil end
|
|
return s.children[line + 1]
|
|
end
|
|
|
|
-- Bound to RET on directory buffers. Opens the URI under cursor
|
|
-- via M.open. The new buffer is pushed via pmacs.window.show if
|
|
-- available.
|
|
local function open_child_at_point(buf)
|
|
local s = _state[buffer_key(buf)]
|
|
if s == nil then return end
|
|
local child = child_uri_at_cursor(buf)
|
|
if child == nil then return end
|
|
-- Capture server reference before async to avoid closure-over
|
|
-- mutated state.
|
|
local server = s.server
|
|
pmacs.async(function()
|
|
local _ = M.open(server, child)
|
|
end)
|
|
end
|
|
|
|
-- Define a buffer-scoped command for RET dispatch.
|
|
if pmacs.command and pmacs.command.define then
|
|
pmacs.command.define {
|
|
name = "pmacs-mcp-resources.open-at-point",
|
|
description = "Open the MCP resource URI on the current line.",
|
|
fn = function()
|
|
if pmacs.window == nil or pmacs.window.buffer == nil then return end
|
|
local buf = pmacs.window.buffer()
|
|
if buf ~= nil then open_child_at_point(buf) end
|
|
end,
|
|
}
|
|
end
|
|
|
|
-- Test seam: open the resource at line N of a directory buffer
|
|
-- without requiring window/cursor APIs. Used by tests and by the
|
|
-- RET binding's fallback path.
|
|
function M.open_child_at_line(buf, line)
|
|
local s = _state[buffer_key(buf)]
|
|
if s == nil or s.kind ~= "directory" then return nil end
|
|
local child = s.children[line]
|
|
if child == nil then return nil end
|
|
return M.open(s.server, child)
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Open
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Helper: is the (Lua-visible) server currently in the
|
|
-- `Initialized` lifecycle state?
|
|
local function server_is_initialized(server)
|
|
if pmacs.mcp == nil or pmacs.mcp.list == nil then return false end
|
|
local raw = server:raw()
|
|
for _, row in ipairs(pmacs.mcp.list()) do
|
|
if row.id and row.id:raw() == raw then
|
|
return row.state and row.state.kind == "initialized"
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Helper: fetch + render + (re-)subscribe an existing buffer.
|
|
-- Used by open()'s fresh-buffer path AND by the stale-recovery
|
|
-- path (Pass-2 finding 3). Returns true on success, false on
|
|
-- failure (in which case the buffer is left in its prior state).
|
|
local function fetch_subscribe_render(buf, server, uri)
|
|
local response
|
|
do
|
|
local ok, result = pcall(function()
|
|
return pmacs.mcp.read_resource(server, uri):await()
|
|
end)
|
|
if not ok then return false, result end
|
|
response = result
|
|
end
|
|
render_into(buf, response)
|
|
-- Subscribe if the server supports it. Best-effort: a subscribe
|
|
-- failure leaves the buffer rendered but un-subscribed.
|
|
if server_supports_subscribe(server) then
|
|
local ok, err = pcall(function()
|
|
pmacs.mcp.send_request(server, "resources/subscribe",
|
|
{ uri = uri }):await()
|
|
end)
|
|
if ok then
|
|
_state[buffer_key(buf)].subscribed = true
|
|
elseif pmacs.error then
|
|
pmacs.error("pmacs-mcp-resources: resources/subscribe failed " ..
|
|
"for " .. uri .. ": " .. tostring(err) ..
|
|
" (buffer will not auto-refresh)")
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
function M.open(server, uri)
|
|
if server == nil then
|
|
error("pmacs-mcp-resources.open: server is nil")
|
|
end
|
|
if type(uri) ~= "string" then
|
|
error("pmacs-mcp-resources.open: uri must be a string, got " .. type(uri))
|
|
end
|
|
|
|
local server_raw = server:raw()
|
|
local key = reverse_key(server_raw, uri)
|
|
local existing = _by_server_uri[key]
|
|
if existing ~= nil then
|
|
-- Pass-2 finding 3: an existing stale buffer should attempt
|
|
-- to recover when the server is back to Initialized. If the
|
|
-- server is still non-Initialized, return the stale buffer
|
|
-- unchanged (caller can poll is_stale and retry, or close +
|
|
-- reopen if they want fresh bookkeeping).
|
|
local s = _state[buffer_key(existing)]
|
|
if s and s.stale and server_is_initialized(server) then
|
|
-- Re-bind the server handle (the caller may have a fresh
|
|
-- McpServerIdLua even though server_raw matches), then
|
|
-- refetch + re-subscribe.
|
|
s.server = server
|
|
s.subscribed = false
|
|
local ok = fetch_subscribe_render(existing, server, uri)
|
|
if ok then
|
|
s.stale = false
|
|
end
|
|
end
|
|
return existing
|
|
end
|
|
|
|
local buf = pmacs.buffer.create("*mcp:" .. uri .. "*")
|
|
_state[buffer_key(buf)] = {
|
|
server = server,
|
|
server_raw = server_raw,
|
|
uri = uri,
|
|
painting = false,
|
|
stale = false,
|
|
children = {},
|
|
subscribed = false,
|
|
kind = "raw",
|
|
mimeType = "",
|
|
}
|
|
_by_server_uri[key] = buf
|
|
|
|
-- Read-only intercept (M8 CC-1 pattern).
|
|
pmacs.buffer.add_intercept(buf, make_readonly_intercept(buf))
|
|
|
|
-- Pass-2 finding 2 / Pass-3 finding 1: wrap the initial fetch in
|
|
-- pcall so a read-failure doesn't leave a half-initialized entry
|
|
-- in the registry OR a dead `*mcp:...*` buffer in the editor's
|
|
-- buffer list. On failure, drop the registry entries, remove the
|
|
-- buffer, and re-raise so the caller knows it didn't work and
|
|
-- can retry.
|
|
local ok, err = pcall(function()
|
|
local response = pmacs.mcp.read_resource(server, uri):await()
|
|
render_into(buf, response)
|
|
end)
|
|
if not ok then
|
|
_state[buffer_key(buf)] = nil
|
|
_by_server_uri[key] = nil
|
|
-- Best-effort buffer cleanup. If the remove itself errors (e.g.
|
|
-- buffer already gone), ignore it — the original `err` is what
|
|
-- the caller cares about.
|
|
pcall(function() pmacs.buffer.remove(buf) end)
|
|
error(err)
|
|
end
|
|
|
|
-- Subscribe if the server supports it (best-effort; subscribe
|
|
-- failure leaves the buffer un-subscribed but otherwise valid).
|
|
if server_supports_subscribe(server) then
|
|
local sub_ok, sub_err = pcall(function()
|
|
pmacs.mcp.send_request(server, "resources/subscribe",
|
|
{ uri = uri }):await()
|
|
end)
|
|
if sub_ok then
|
|
_state[buffer_key(buf)].subscribed = true
|
|
elseif pmacs.error then
|
|
pmacs.error("pmacs-mcp-resources: resources/subscribe failed " ..
|
|
"for " .. uri .. ": " .. tostring(sub_err) ..
|
|
" (buffer will not auto-refresh)")
|
|
end
|
|
end
|
|
|
|
-- Bind RET on directory buffers to open-child-at-point.
|
|
if _state[buffer_key(buf)].kind == "directory" and pmacs.keymap and pmacs.keymap.bind then
|
|
pmacs.keymap.bind {
|
|
scope = "buffer",
|
|
buffer = buf,
|
|
sequence = "RET",
|
|
command = "pmacs-mcp-resources.open-at-point",
|
|
}
|
|
end
|
|
|
|
return buf
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Close
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
function M.close(buf)
|
|
local s = _state[buffer_key(buf)]
|
|
if s == nil then return end
|
|
if s.subscribed then
|
|
-- Best-effort unsubscribe — if the server has died, this errors
|
|
-- and we ignore it.
|
|
pcall(function()
|
|
pmacs.mcp.send_request(s.server, "resources/unsubscribe",
|
|
{ uri = s.uri }):await()
|
|
end)
|
|
end
|
|
_by_server_uri[reverse_key(s.server_raw, s.uri)] = nil
|
|
_state[buffer_key(buf)] = nil
|
|
-- v0.1: leave buffer destruction to the caller / editor's normal
|
|
-- buffer-lifecycle path. Future work: pmacs.buffer.destroy.
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Stale state
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
function M.is_stale(buf)
|
|
local s = _state[buffer_key(buf)]
|
|
if s == nil then return false end
|
|
return s.stale == true
|
|
end
|
|
|
|
-- Mark all buffers for `server_raw` stale. Called from the lifecycle
|
|
-- watchdog when a subscribed server transitions to non-Initialized.
|
|
local function mark_server_stale(server_raw)
|
|
for _, buf in pairs(_by_server_uri) do
|
|
local s = _state[buffer_key(buf)]
|
|
if s ~= nil and s.server_raw == server_raw and s.subscribed then
|
|
s.stale = true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Watchdog: hook into the same async tick to observe server state
|
|
-- transitions. Cheap enough to run every tick (one walk over the
|
|
-- mcp.list output, which is small).
|
|
local _orig_async_tick = pmacs._async.tick
|
|
function pmacs._async.tick()
|
|
_orig_async_tick()
|
|
if pmacs.mcp == nil or pmacs.mcp.list == nil then return end
|
|
local rows = pmacs.mcp.list()
|
|
for _, row in ipairs(rows) do
|
|
if row.state and row.state.kind ~= "initialized" then
|
|
mark_server_stale(row.id:raw())
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Test seams (mirror the M8 fixture pattern).
|
|
M.__pmacs_mcp_resources_test_state = function(buf) return _state[buffer_key(buf)] end
|
|
M.__pmacs_mcp_resources_test_buffer_for = function(server_raw, uri)
|
|
return _by_server_uri[reverse_key(server_raw, uri)]
|
|
end
|
|
|
|
return M
|