M9 ship gate
Land the Model Context Protocol (MCP) integration as a transport binding, not a built-in feature. Six Lua functions plus userdata methods expose the substance of three MCP feature areas (resources, tools, prompts), a notification dispatcher, and a non-trivial AI-assistance example package that meets the architectural ship gate (spec/pmacs-spec.tex:1572): zero direct calls into the Rust core, zero special-cased MCP handling outside the public API, source under 2000 lines of Lua. The M9.5 -> M9.6 -> M9.7 -> M9.8 layered composition validates the claim "AI is a transport binding, not a feature" -- pmacs-mcp-ai composes with pmacs-mcp-prompts.render and inherits notification handling transitively through M9.7's package, demonstrating that the AI domain is a layer above MCP, not a thread woven through the core. Subtask shape: M9.1 stdio transport + initialize handshake + restart policy M9.2 resources with in-flight + settled cache and per-uri invalidation M9.3 tools with isError-vs-JSON-RPC-error semantics + cancellation M9.4 prompts with required-argument validation M9.5 notification dispatcher (on_notification, off_notification) M9.6 tools-as-commands fixture package + 12 audit findings disposed M9.7 prompts-as-result-buffers fixture package + tree-sitter-md grammar M9.8 AI-assistance fixture package (363+ LoC; 17/17 acceptance tests) M9.9 formal package audit -- PASS on all three criteria M9.10 release: TRANSITION-M9.md + MCP-for-package-authors guide Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a35d0b0f8
commit
587a2a15de
|
|
@ -621,6 +621,7 @@ dependencies = [
|
|||
"toml",
|
||||
"tree-sitter",
|
||||
"tree-sitter-lua",
|
||||
"tree-sitter-md",
|
||||
"tree-sitter-rust",
|
||||
"unicode-width",
|
||||
]
|
||||
|
|
@ -1193,6 +1194,16 @@ dependencies = [
|
|||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-md"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2efd398be546456c814598ee56c0f51769a77241511b4a58077815d120afa882"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree-sitter-rust"
|
||||
version = "0.24.2"
|
||||
|
|
|
|||
|
|
@ -95,6 +95,15 @@ tree-sitter = "0.26"
|
|||
# `crate::syntax::BUILTIN_LANGUAGES`.
|
||||
tree-sitter-rust = "0.24"
|
||||
tree-sitter-lua = "0.5"
|
||||
# T M9.7: markdown grammar so prompt result buffers with
|
||||
# `_meta.format = "markdown"` get structured highlighting through the
|
||||
# same M4 path as rust/lua — no special-case painter in Lua.
|
||||
# Pinned to `0.5` (caret-excludes-major for 0.x; see
|
||||
# `tree-sitter-rust = "0.24"` style above). The crate is the official
|
||||
# tree-sitter-org grammar (`tree-sitter-md` on crates.io); the
|
||||
# alternative names `tree-sitter-markdown*` are forks of varying
|
||||
# maintenance status — pick the upstream one.
|
||||
tree-sitter-md = "0.5"
|
||||
# T M4.4 process supervisor: signal sending without `unsafe`. Keep
|
||||
# the feature surface tight to keep build time low (no syscalls
|
||||
# beyond `kill(2)` for v0.1).
|
||||
|
|
|
|||
|
|
@ -615,3 +615,83 @@ cmd { name = "buffer.kill-this",
|
|||
local id = pmacs.window.buffer()
|
||||
if id ~= nil then pmacs.buffer.kill(id) end
|
||||
end }
|
||||
|
||||
-- describe-command (M9.6 acceptance lever) ---------------------------------
|
||||
--
|
||||
-- M9.6's third acceptance bullet ("describe-command reports the
|
||||
-- tool's schema as the documentation") needs a user-callable entry
|
||||
-- point — `pmacs.describe.command(name)` returns the table at the
|
||||
-- Rust layer, but without this M-x command there's no way to reach
|
||||
-- it interactively. Modeled on `editor.describe-instance-buffer`:
|
||||
-- a single `*help*` buffer reused across invocations, with a
|
||||
-- buffer-local `q` → `buffer.kill-this` for dismissal.
|
||||
|
||||
local HELP_BUFFER_NAME = "*help*"
|
||||
local help_buffer_id = nil
|
||||
|
||||
local function find_or_create_help_buffer()
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
local d = pmacs.describe.buffer(id)
|
||||
if d ~= nil and d.name == HELP_BUFFER_NAME then
|
||||
return id, false
|
||||
end
|
||||
end
|
||||
return pmacs.buffer.create(HELP_BUFFER_NAME), true
|
||||
end
|
||||
|
||||
local function show_help_text(text)
|
||||
local buf, fresh = find_or_create_help_buffer()
|
||||
-- `buf:delete(0, len)` matches `editor.list-buffers` render_list — we
|
||||
-- replace contents wholesale rather than diffing, since *help* is
|
||||
-- always reflowed for the new subject.
|
||||
local len = buf:len()
|
||||
if len > 0 then buf:delete(0, len) end
|
||||
if #text > 0 then buf:insert(0, text) end
|
||||
if fresh or help_buffer_id ~= buf then
|
||||
pcall(function()
|
||||
pmacs.keymap.unbind { scope = "buffer", buffer = buf, sequence = "q" }
|
||||
end)
|
||||
pmacs.keymap.bind {
|
||||
scope = "buffer",
|
||||
buffer = buf,
|
||||
sequence = "q",
|
||||
command = "buffer.kill-this",
|
||||
}
|
||||
help_buffer_id = buf
|
||||
end
|
||||
pmacs.window.switch_buffer(buf)
|
||||
end
|
||||
|
||||
cmd { name = "editor.describe-command",
|
||||
description = "Prompt for a command name and render its description in *help*.",
|
||||
fn = function()
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Describe command: ",
|
||||
source = "commands",
|
||||
history = "command",
|
||||
on_accept = function(name)
|
||||
if name == nil or name == "" then return end
|
||||
local info = pmacs.describe.command(name)
|
||||
if info == nil then
|
||||
pmacs.editor.set_status("describe-command: no such command: " .. name)
|
||||
return
|
||||
end
|
||||
local lines = { name, "" }
|
||||
local desc = info.description
|
||||
if type(desc) ~= "string" or desc == "" then
|
||||
desc = "(no description)"
|
||||
end
|
||||
lines[#lines + 1] = desc
|
||||
local key_bindings = info.key_bindings
|
||||
if type(key_bindings) == "table" and #key_bindings > 0 then
|
||||
lines[#lines + 1] = ""
|
||||
lines[#lines + 1] = "Bindings:"
|
||||
for _, b in ipairs(key_bindings) do
|
||||
local seq = (type(b) == "table" and b.sequence) or tostring(b)
|
||||
lines[#lines + 1] = " " .. tostring(seq)
|
||||
end
|
||||
end
|
||||
show_help_text(table.concat(lines, "\n"))
|
||||
end,
|
||||
}
|
||||
end }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,232 @@
|
|||
-- builtin/runtime/mcp.lua --- T M9.1 friendly Lua surface for pmacs.mcp.*
|
||||
--
|
||||
-- The Rust binding (`pmacs.mcp._send_request_raw`) returns a raw
|
||||
-- async-runtime JobId. Package code wants the same dispatch shape
|
||||
-- as `pmacs.workers.compute_sum`, `pmacs.fs.read_dir`, and friends:
|
||||
--
|
||||
-- pmacs.async(function()
|
||||
-- local result = pmacs.mcp.send_request(server, "ping", {}):await()
|
||||
-- ...
|
||||
-- end)
|
||||
--
|
||||
-- This file replaces `pmacs.mcp.send_request` with the Handle-
|
||||
-- returning wrapper. The Rust manager registers each request with
|
||||
-- the async runtime and settles the job when the JSON-RPC response
|
||||
-- lands on the supervisor pipe; the Handle's :await() resumes the
|
||||
-- parked coroutine with the response's `result` value (already
|
||||
-- translated to a Lua table) — or raises `{ tag = "failed",
|
||||
-- message = ... }` if the server returned a JSON-RPC error, or
|
||||
-- `{ tag = "cancelled", id = ... }` if the awaiter cancelled or the
|
||||
-- server died before responding.
|
||||
|
||||
local mcp_mod = pmacs.mcp
|
||||
assert(mcp_mod, "pmacs.mcp must be installed before mcp.lua loads")
|
||||
assert(mcp_mod._send_request_raw,
|
||||
"pmacs.mcp._send_request_raw missing; lua_bindings::install_mcp not run?")
|
||||
|
||||
local workers_mod = pmacs.workers
|
||||
assert(workers_mod and workers_mod._new_handle,
|
||||
"pmacs.workers._new_handle missing; did async.lua load before mcp.lua?")
|
||||
|
||||
local raw_send_request = mcp_mod._send_request_raw
|
||||
local new_handle = workers_mod._new_handle
|
||||
|
||||
-- pmacs.mcp.send_request(server, method, params) -> Handle
|
||||
--
|
||||
-- `server` is the McpServerIdLua handle returned by pmacs.mcp.spawn.
|
||||
-- `method` is a string. `params` is an optional table (or nil). The
|
||||
-- return value is a Handle that completes with the response's
|
||||
-- `result` table.
|
||||
function mcp_mod.send_request(server, method, params)
|
||||
if type(method) ~= "string" then
|
||||
error("pmacs.mcp.send_request: method must be a string, got " .. type(method))
|
||||
end
|
||||
if params ~= nil and type(params) ~= "table" then
|
||||
error("pmacs.mcp.send_request: params must be a table or nil, got " .. type(params))
|
||||
end
|
||||
local job_id = raw_send_request(server, method, params)
|
||||
return new_handle(job_id)
|
||||
end
|
||||
|
||||
-- T M9.2: pmacs.mcp.read_resource(server, uri) -> Handle
|
||||
--
|
||||
-- Cache-aware MCP resource fetch. Three observable outcomes, all
|
||||
-- delivered through the same Handle shape:
|
||||
--
|
||||
-- * cache hit: handle settles with the cached result (one tick late)
|
||||
-- * in-flight coalesce: handle attaches to an existing in-flight
|
||||
-- request, settles with the same result
|
||||
-- * cache miss: dispatches a fresh `resources/read`, settles with
|
||||
-- the response
|
||||
--
|
||||
-- `pmacs.mcp.invalidate_resource(server, uri)` (installed directly
|
||||
-- by Rust as a non-Handle function) drops the cache entry; subsequent
|
||||
-- read_resource calls re-dispatch.
|
||||
local raw_read_resource = mcp_mod._read_resource_raw
|
||||
assert(raw_read_resource,
|
||||
"pmacs.mcp._read_resource_raw missing; lua_bindings::install_mcp not run?")
|
||||
|
||||
function mcp_mod.read_resource(server, uri)
|
||||
if type(uri) ~= "string" then
|
||||
error("pmacs.mcp.read_resource: uri must be a string, got " .. type(uri))
|
||||
end
|
||||
local job_id = raw_read_resource(server, uri)
|
||||
return new_handle(job_id)
|
||||
end
|
||||
|
||||
-- T M9.3: pmacs.mcp.invoke_tool(server, name, args) -> Handle
|
||||
--
|
||||
-- Tool invocation via JSON-RPC `tools/call`. The handle settles
|
||||
-- with the response's `result` table (`{ content = [...],
|
||||
-- isError = false }` shape from the MCP spec) on success, or
|
||||
-- raises a Lua error on either of two failure paths:
|
||||
--
|
||||
-- * JSON-RPC error response from the server: standard async
|
||||
-- failure path.
|
||||
-- * MCP "tool errored" success response (`isError: true`):
|
||||
-- translated by the manager into a Failed outcome with the
|
||||
-- extracted text content as the message. This is a deliberate
|
||||
-- API choice (see M9.3 audit) — invoke_tool's contract is
|
||||
-- "raises Lua errors on tool failure", so callers don't have
|
||||
-- to write `if r.isError then ... end` boilerplate at every
|
||||
-- call site.
|
||||
--
|
||||
-- Callers needing structured access to the raw `{isError, content}`
|
||||
-- table use `pmacs.mcp.send_request(server, "tools/call", { name = ...,
|
||||
-- arguments = ... })` to bypass the translator.
|
||||
local raw_invoke_tool = mcp_mod._invoke_tool_raw
|
||||
assert(raw_invoke_tool,
|
||||
"pmacs.mcp._invoke_tool_raw missing; lua_bindings::install_mcp not run?")
|
||||
|
||||
function mcp_mod.invoke_tool(server, name, args)
|
||||
if type(name) ~= "string" then
|
||||
error("pmacs.mcp.invoke_tool: name must be a string, got " .. type(name))
|
||||
end
|
||||
if args ~= nil and type(args) ~= "table" then
|
||||
error("pmacs.mcp.invoke_tool: args must be a table or nil, got " .. type(args))
|
||||
end
|
||||
local job_id = raw_invoke_tool(server, name, args)
|
||||
return new_handle(job_id)
|
||||
end
|
||||
|
||||
-- T M9.4: pmacs.mcp.get_prompt(server, name [, args]) -> Handle
|
||||
--
|
||||
-- Resolve a prompt template via JSON-RPC `prompts/get`. The handle
|
||||
-- settles with the response's `result` table:
|
||||
--
|
||||
-- { description = "...", messages = [{ role = "user", content = ... }, ...] }
|
||||
--
|
||||
-- on success, or raises a Lua error (via the async runtime's
|
||||
-- `tag = "failed"` path) on JSON-RPC errors — including the
|
||||
-- "missing required argument" case (`-32602`) which is how MCP
|
||||
-- servers report missing args.
|
||||
--
|
||||
-- Unlike `invoke_tool`, there is no `isError`-style translator —
|
||||
-- `prompts/get` has no semantic-failure path in the MCP spec; either
|
||||
-- the prompt resolves (success) or the protocol-level call fails
|
||||
-- (Lua error).
|
||||
--
|
||||
-- `args` may be omitted, nil, or an empty table — all three send
|
||||
-- `arguments: {}` on the wire (the MCP spec requires the field).
|
||||
local raw_get_prompt = mcp_mod._get_prompt_raw
|
||||
assert(raw_get_prompt,
|
||||
"pmacs.mcp._get_prompt_raw missing; lua_bindings::install_mcp not run?")
|
||||
|
||||
function mcp_mod.get_prompt(server, name, args)
|
||||
if type(name) ~= "string" then
|
||||
error("pmacs.mcp.get_prompt: name must be a string, got " .. type(name))
|
||||
end
|
||||
if args ~= nil and type(args) ~= "table" then
|
||||
error("pmacs.mcp.get_prompt: args must be a table or nil, got " .. type(args))
|
||||
end
|
||||
local job_id = raw_get_prompt(server, name, args)
|
||||
return new_handle(job_id)
|
||||
end
|
||||
|
||||
-- T M9.5: notification dispatcher.
|
||||
--
|
||||
-- pmacs.mcp.on_notification(method, fn) -> token
|
||||
--
|
||||
-- Register `fn` to be called whenever an MCP server emits a
|
||||
-- notification with the given method. Multiple handlers per method
|
||||
-- are allowed; they fire in registration order. The handler
|
||||
-- receives `(server, params)` where `server` is an McpServerIdLua
|
||||
-- and `params` is the notification's params table.
|
||||
--
|
||||
-- Returns an opaque token; pass it to `pmacs.mcp.off_notification`
|
||||
-- to unregister.
|
||||
--
|
||||
-- The dispatcher hooks into pmacs._async.tick: on each tick, the
|
||||
-- Rust manager's drain_notifications API is called, and pending
|
||||
-- notifications are dispatched to registered handlers. Single
|
||||
-- per-tick walk regardless of how many packages have registered;
|
||||
-- M9.5/M9.6/M9.7 all share the mechanism.
|
||||
local subscribe_raw = mcp_mod._subscribe_notification
|
||||
local unsubscribe_raw = mcp_mod._unsubscribe_notification
|
||||
local drain_raw = mcp_mod._drain_notifications
|
||||
assert(subscribe_raw and unsubscribe_raw and drain_raw,
|
||||
"pmacs.mcp._subscribe_notification / _unsubscribe_notification / _drain_notifications missing")
|
||||
|
||||
-- Per-method handler list. `_handlers[method]` is a table where
|
||||
-- entries are `{ token = <unique>, fn = <function> }`. Tokens are
|
||||
-- monotonic so unregistration is unambiguous.
|
||||
local _handlers = {}
|
||||
local _next_token = 1
|
||||
|
||||
function mcp_mod.on_notification(method, fn)
|
||||
if type(method) ~= "string" then
|
||||
error("pmacs.mcp.on_notification: method must be a string, got " .. type(method))
|
||||
end
|
||||
if type(fn) ~= "function" then
|
||||
error("pmacs.mcp.on_notification: fn must be a function, got " .. type(fn))
|
||||
end
|
||||
local list = _handlers[method]
|
||||
if list == nil then
|
||||
list = {}
|
||||
_handlers[method] = list
|
||||
-- First handler for this method — register interest with the
|
||||
-- Rust manager so notifications/<method> get queued.
|
||||
subscribe_raw(method)
|
||||
end
|
||||
local token = _next_token
|
||||
_next_token = _next_token + 1
|
||||
list[#list + 1] = { token = token, fn = fn }
|
||||
return token
|
||||
end
|
||||
|
||||
function mcp_mod.off_notification(method, token)
|
||||
local list = _handlers[method]
|
||||
if list == nil then return end
|
||||
for i, entry in ipairs(list) do
|
||||
if entry.token == token then
|
||||
table.remove(list, i)
|
||||
break
|
||||
end
|
||||
end
|
||||
if #list == 0 then
|
||||
_handlers[method] = nil
|
||||
-- Last handler dropped — tell Rust to stop queuing.
|
||||
unsubscribe_raw(method)
|
||||
end
|
||||
end
|
||||
|
||||
-- Tick hook: drain queued notifications and dispatch.
|
||||
local _orig_async_tick = pmacs._async.tick
|
||||
function pmacs._async.tick()
|
||||
_orig_async_tick()
|
||||
local drained = drain_raw()
|
||||
for method, entries in pairs(drained) do
|
||||
local list = _handlers[method]
|
||||
if list ~= nil then
|
||||
for _, entry in ipairs(entries) do
|
||||
for _, handler in ipairs(list) do
|
||||
local ok, err = pcall(handler.fn, entry.server, entry.params)
|
||||
if not ok and pmacs.error then
|
||||
pmacs.error("pmacs.mcp.on_notification(" .. method ..
|
||||
") handler raised: " .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,594 @@
|
|||
# MCP for package authors
|
||||
|
||||
This guide is for package authors who want to integrate Model
|
||||
Context Protocol (MCP) servers into pmacs. It covers the
|
||||
`pmacs.mcp.*` API, the architectural pattern for AI-assistance
|
||||
packages, and the disciplines distilled from M9.5 – M9.8's audit
|
||||
work.
|
||||
|
||||
The general package-publishing mechanics are at
|
||||
[`docs/package-author-guide.md`](package-author-guide.md). This
|
||||
document is the inverse: how to write a package that *uses* an
|
||||
MCP server.
|
||||
|
||||
Five fixture packages live under `tests/fixtures/` as worked
|
||||
examples:
|
||||
|
||||
- [`pmacs-mcp-resources/`](../tests/fixtures/pmacs-mcp-resources/) — resources
|
||||
- [`pmacs-mcp-tools/`](../tests/fixtures/pmacs-mcp-tools/) — tools-as-commands
|
||||
- [`pmacs-mcp-prompts/`](../tests/fixtures/pmacs-mcp-prompts/) — prompts-as-result-buffers
|
||||
- [`pmacs-mcp-ai/`](../tests/fixtures/pmacs-mcp-ai/) — AI-assistance composing the above
|
||||
|
||||
---
|
||||
|
||||
## 1. Why MCP for package authors
|
||||
|
||||
The architectural claim (`spec/pmacs-spec.tex`, §sec:m9-ai) is:
|
||||
|
||||
> AI is a transport binding, not a feature.
|
||||
|
||||
In practical terms: pmacs has no built-in "ask Claude" command, no
|
||||
hard-coded model API, no Anthropic-specific or OpenAI-specific code
|
||||
anywhere in the editor. Instead, pmacs ships an MCP transport
|
||||
layer, and AI features are built as packages on top.
|
||||
|
||||
This means:
|
||||
|
||||
1. **Your package speaks MCP, not a model API.** The model behind
|
||||
the configured server is interchangeable. Re-pointing your
|
||||
package at a different MCP server changes which model serves
|
||||
the prompts; your code is unchanged.
|
||||
2. **Your package composes with other MCP packages.** The
|
||||
transport layer is shared; subscriptions, caching, cancellation
|
||||
are uniform. Two packages talking to the same server share a
|
||||
single process.
|
||||
3. **The user's API keys live in the MCP server's environment, not
|
||||
yours.** Your package never sees credentials. The server
|
||||
process inherits them; pmacs's spawn API takes a `command` and
|
||||
optional `env`.
|
||||
|
||||
The recommendation: **write against the MCP layer, not against
|
||||
any specific model API.** If your package wants to talk to Claude,
|
||||
spawn an MCP server that talks to Claude. If your package wants to
|
||||
talk to GPT, spawn an MCP server that talks to GPT. Your package
|
||||
code is the same.
|
||||
|
||||
---
|
||||
|
||||
## 2. The transport: `pmacs.mcp.*`
|
||||
|
||||
The full public API surface is six Lua functions plus userdata
|
||||
methods. Everything else MCP-related is reachable from these.
|
||||
|
||||
### `pmacs.mcp.spawn { ... }` → `McpServerIdLua`
|
||||
|
||||
Spawn an MCP server as a child process and start the initialize
|
||||
handshake.
|
||||
|
||||
```lua
|
||||
local server = pmacs.mcp.spawn {
|
||||
label = "my-server", -- string. Used as buffer-name prefix and roster key.
|
||||
command = "/usr/local/bin/mcp-claude",
|
||||
args = { "--config", "/path/to/config.toml" }, -- optional
|
||||
env = { ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY") }, -- optional
|
||||
restart = "OnCrash", -- "OnCrash" (default) | "Always" | "Never"
|
||||
}
|
||||
```
|
||||
|
||||
Spawning is *asynchronous*: `spawn` returns immediately with a
|
||||
handle. The server may take some time to initialize. Use
|
||||
`pmacs.mcp.list()` to observe state transitions.
|
||||
|
||||
### `pmacs.mcp.list()` → array
|
||||
|
||||
Returns the current server roster:
|
||||
|
||||
```lua
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
print(row.label, row.id, row.state.kind)
|
||||
-- row.state.kind: "spawning" | "initializing" | "initialized" | "crashed" | "exited"
|
||||
end
|
||||
```
|
||||
|
||||
Use `state.kind == "initialized"` as the gate for sending requests.
|
||||
Sending to a non-initialized server raises `not ready for
|
||||
requests`.
|
||||
|
||||
### `pmacs.mcp.read_resource(server, uri)` → handle
|
||||
|
||||
Read an MCP resource. Returns an *awaitable handle*:
|
||||
|
||||
```lua
|
||||
pmacs.async(function()
|
||||
local body = pmacs.mcp.read_resource(server, "file:///etc/config.toml"):await()
|
||||
-- body = { contents = [ { uri, text? | blob?, mimeType? }, ... ] }
|
||||
end)
|
||||
```
|
||||
|
||||
The handle is *cache-aware*: a settled response is returned from
|
||||
cache for subsequent calls with the same `(server, uri)` until
|
||||
invalidation. Concurrent calls during an in-flight request share
|
||||
the awaitable. Cancellation of an awaiter is independent — the
|
||||
wire request is only cancelled when *all* awaiters cancel.
|
||||
|
||||
Invalidation triggers:
|
||||
|
||||
- `notifications/resources/updated` (per-uri) — invalidates that uri.
|
||||
- `notifications/resources/list_changed` — invalidates all
|
||||
resources for that server.
|
||||
|
||||
You don't typically call `on_notification` for these — the cache
|
||||
listens internally.
|
||||
|
||||
### `pmacs.mcp.invoke_tool(server, name, args)` → handle
|
||||
|
||||
Invoke an MCP tool. Returns an awaitable handle:
|
||||
|
||||
```lua
|
||||
pmacs.async(function()
|
||||
local result = pmacs.mcp.invoke_tool(server, "search", { query = "foo" }):await()
|
||||
-- result = { content = [ { type, text? | image? | resource? }, ... ],
|
||||
-- isError = bool, _meta? }
|
||||
if result.isError then
|
||||
-- semantic failure: the tool ran but reported a failure
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
**Three failure modes** to distinguish:
|
||||
|
||||
1. **Success**: `isError = false`, content has the result.
|
||||
2. **Semantic failure**: `isError = true`, content describes the
|
||||
failure ("file not found", "permission denied"). The tool
|
||||
ran; the operation failed.
|
||||
3. **Transport / protocol failure**: the `:await()` call raises.
|
||||
Either the server is gone, the request was cancelled, the
|
||||
server returned a JSON-RPC error (unknown tool, invalid args).
|
||||
|
||||
Don't conflate (2) and (3). Semantic failures are *results*;
|
||||
transport failures are *exceptions*.
|
||||
|
||||
Tool calls are not cached client-side — the server may have side
|
||||
effects, and v0.1 doesn't read the MCP idempotency hint.
|
||||
Cancellation is via `:cancel()` on the handle.
|
||||
|
||||
### `pmacs.mcp.get_prompt(server, name, args)` → handle
|
||||
|
||||
Get an MCP prompt response. Returns an awaitable handle:
|
||||
|
||||
```lua
|
||||
pmacs.async(function()
|
||||
local response = pmacs.mcp.get_prompt(server, "review_function", {
|
||||
language = "rust",
|
||||
file_path = "src/main.rs",
|
||||
source = "fn main() { ... }",
|
||||
}):await()
|
||||
-- response = { description?, _meta?, messages = [ { role, content }, ... ] }
|
||||
end)
|
||||
```
|
||||
|
||||
Required arguments are validated by the server — missing them
|
||||
raises a JSON-RPC error.
|
||||
|
||||
`response._meta.format` carries a content-type hint when the
|
||||
server supports it: `"text"` (default), `"code"` (with
|
||||
`_meta.language` for syntax highlighting), `"markdown"`. Unknown
|
||||
formats fall back to `text`. The `pmacs-mcp-prompts.render`
|
||||
function (see §6) reads these hints and routes the buffer through
|
||||
the appropriate highlight pipeline.
|
||||
|
||||
`args` may be a Lua table with structured values — arrays, nested
|
||||
objects. The wire shape is JSON. M9.8's `pmacs-mcp-ai` uses this
|
||||
to send a structured `files: [{path, content}, ...]` array for
|
||||
its project-context prompt. Don't separator-encode JSON into
|
||||
strings; let the marshaler handle it.
|
||||
|
||||
### `pmacs.mcp.on_notification(method, fn)` → token
|
||||
|
||||
Subscribe to MCP server-to-client notifications. The subscription
|
||||
is **global per method**, not per-server: the handler `fn` receives
|
||||
`(server, params)` and is responsible for filtering by `server`
|
||||
if it cares.
|
||||
|
||||
```lua
|
||||
local token = pmacs.mcp.on_notification("notifications/tools/list_changed", function(server, params)
|
||||
-- This fires for *any* server's list_changed. Filter if you only
|
||||
-- want events from servers your package has registered:
|
||||
if not _registered_servers[server:raw()] then return end
|
||||
-- re-fetch tools/list and reconcile commands
|
||||
end)
|
||||
|
||||
-- Later:
|
||||
pmacs.mcp.off_notification("notifications/tools/list_changed", token)
|
||||
```
|
||||
|
||||
Multiple subscriptions to the same method fire in registration
|
||||
order. A throwing callback doesn't break the dispatcher — the
|
||||
error is logged via `pmacs.error` and the next callback fires.
|
||||
|
||||
The dispatcher under the hood is a single per-tick drain regardless
|
||||
of how many packages have registered. The Rust side is told to
|
||||
queue notifications for `method` on the first subscription and
|
||||
stop on the last unsubscription, so there's no idle-cost when no
|
||||
package cares about a method.
|
||||
|
||||
**Subscription discipline**: balance subscribes with cancels.
|
||||
M9.6's per-server-refcount finding showed how easily a re-register
|
||||
flow leaks subscriptions: if your package's `register(server)` calls
|
||||
`on_notification` unconditionally, registering N servers leaks N-1
|
||||
subscriptions for the same method. Use a per-server refcount —
|
||||
add the subscription on the *first* server registered, drop it on
|
||||
the *last* server unregistered.
|
||||
|
||||
---
|
||||
|
||||
## 3. Server lifecycle in your package
|
||||
|
||||
The shape M9.6/M9.7/M9.8 settled on:
|
||||
|
||||
```lua
|
||||
local M = {}
|
||||
|
||||
local _registered_servers = {} -- per-server state, keyed by server:raw()
|
||||
|
||||
function M.register(server)
|
||||
local key = server:raw()
|
||||
if _registered_servers[key] then return end -- idempotent
|
||||
_registered_servers[key] = {
|
||||
-- ... per-server state: subscriptions, label, etc.
|
||||
}
|
||||
-- subscribe, fetch initial state, define commands, etc.
|
||||
end
|
||||
|
||||
function M.unregister(server)
|
||||
local key = server:raw()
|
||||
local state = _registered_servers[key]
|
||||
if state == nil then return end
|
||||
-- unsubscribe, drop commands, etc.
|
||||
_registered_servers[key] = nil
|
||||
end
|
||||
```
|
||||
|
||||
Why `server:raw()` and not `tostring(server)`: M9.6 finding 2.
|
||||
The `:raw()` userdata method returns the canonical underlying id
|
||||
as a string; `tostring` formats it for display and may not be
|
||||
stable across pmacs versions.
|
||||
|
||||
### Server-gone teardown
|
||||
|
||||
When the server crashes or exits, in-flight requests fail with
|
||||
`unknown server` or `not ready for requests`. Detect these in
|
||||
your dispatch path:
|
||||
|
||||
```lua
|
||||
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
|
||||
```
|
||||
|
||||
When this fires, tear down your per-server state (drop commands,
|
||||
clear caches) so the next user invocation surfaces a useful
|
||||
"reconfigure" message rather than the same dead-server error on
|
||||
every retry. M9.6 finding 5.
|
||||
|
||||
---
|
||||
|
||||
## 4. Pattern: tools as commands
|
||||
|
||||
Pattern from `pmacs-mcp-tools`. Each tool advertised by the
|
||||
server becomes a command at `<label>-<tool-name>`. The list of
|
||||
advertised tools is fetched via the generic `pmacs.mcp.send_request`
|
||||
seam — there's no dedicated `pmacs.mcp.list_tools` in v0.1
|
||||
because every server's `tools/list` shape is identical and the
|
||||
generic seam handles it cleanly:
|
||||
|
||||
```lua
|
||||
function M.register(server)
|
||||
pmacs.async(function()
|
||||
local result = pmacs.mcp.send_request(server, "tools/list", {}):await()
|
||||
-- result.tools is the array of tool entries: { name, description?, inputSchema? }
|
||||
for _, tool in ipairs(result.tools or {}) do
|
||||
define_command_for(server, tool)
|
||||
end
|
||||
end)
|
||||
end
|
||||
```
|
||||
|
||||
`pmacs.mcp.send_request(server, method, params)` returns the same
|
||||
awaitable handle shape as `read_resource` / `invoke_tool` /
|
||||
`get_prompt` and is the bottom-rung public seam: any MCP request
|
||||
the spec defines is reachable through it. The
|
||||
`pmacs-mcp-tools/init.lua` fixture is the worked example.
|
||||
|
||||
(Whether `tools/list` and `prompts/list` deserve *dedicated* typed
|
||||
surfaces is an audit consideration; v0.2 territory if real package
|
||||
authors find the generic seam awkward.)
|
||||
|
||||
### Reconciliation on `list_changed`
|
||||
|
||||
Tools can change at runtime. Subscribe (global per-method; filter
|
||||
by server inside the handler):
|
||||
|
||||
```lua
|
||||
pmacs.mcp.on_notification("notifications/tools/list_changed", function(server, params)
|
||||
if not _registered_servers[server:raw()] then return end
|
||||
-- Re-fetch tools/list, diff against current commands, add/remove.
|
||||
end)
|
||||
```
|
||||
|
||||
Compute a *schema hash* per tool to detect schema changes (not
|
||||
just name changes). Add commands for new tools, remove for gone
|
||||
tools, redefine for changed-schema tools. M9.6's package shows
|
||||
the canonical implementation.
|
||||
|
||||
### Cross-source command collisions
|
||||
|
||||
Two servers advertising a tool with the same name can both want
|
||||
to register `<label>-<tool-name>` if the labels collide, or
|
||||
different name shapes can collide with builtins. Always check:
|
||||
|
||||
```lua
|
||||
if pmacs.command.exists(name) then
|
||||
-- skip + warn, don't abort the rest of the registration
|
||||
else
|
||||
pmacs.command.define { name = name, ... }
|
||||
end
|
||||
```
|
||||
|
||||
M9.6 finding 6.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pattern: prompts as result buffers
|
||||
|
||||
Pattern from `pmacs-mcp-prompts`. Each prompt becomes a command
|
||||
that prompts for required args, calls `get_prompt`, renders the
|
||||
response into a `*mcp:<label>:<prompt>*` buffer.
|
||||
|
||||
The package exposes a public function that v0.2+ packages should
|
||||
*compose with*, not duplicate:
|
||||
|
||||
```lua
|
||||
local mcp_prompts = require("pmacs-mcp-prompts")
|
||||
|
||||
-- After calling pmacs.mcp.get_prompt and awaiting:
|
||||
mcp_prompts.render(server_label, prompt_name, response)
|
||||
```
|
||||
|
||||
`render` handles:
|
||||
|
||||
- buffer creation / reuse (keyed by `(label, prompt)`)
|
||||
- read-only intercept (so the user can't accidentally edit the
|
||||
result)
|
||||
- format dispatch via `_meta.format` (text / code / markdown)
|
||||
- syntax highlighting attach (for `code` / `markdown` formats)
|
||||
- cursor / region / scroll reset on re-paint
|
||||
|
||||
If your package wants different rendering — multi-turn
|
||||
conversation history, inline image rendering, etc. — you write
|
||||
your own. The composition story is *opt-in*: M9.8 chose to
|
||||
compose so that re-invoking the same prompt from M9.7's auto-
|
||||
registered command and M9.8's `ai.ask-about-X` lands in the same
|
||||
buffer. Your package may have different ergonomic goals.
|
||||
|
||||
---
|
||||
|
||||
## 6. Pattern: AI assistance composing the above
|
||||
|
||||
Pattern from `pmacs-mcp-ai`. The full M9.8 example is 247 lines
|
||||
of code (461 total with comments). Three commands, tree-sitter
|
||||
context selection, project-buffer collection, structured-arg
|
||||
prompts.
|
||||
|
||||
The architectural commitment, recorded in `init.lua`'s header:
|
||||
|
||||
```
|
||||
-- Architectural commitment (the M9.8 ship gate):
|
||||
--
|
||||
-- * Zero direct calls into the Rust core. Everything reaches the
|
||||
-- Rust side through the public Lua surface (`pmacs.mcp.*`,
|
||||
-- `pmacs.parse.*`, `pmacs.command.*`, etc.).
|
||||
-- * Zero model-specific code. The package speaks MCP; the model
|
||||
-- behind the configured server is interchangeable.
|
||||
```
|
||||
|
||||
The `configure { server_label, prompts = { ... } }` shape is the
|
||||
key:
|
||||
|
||||
```lua
|
||||
local ai = require("pmacs-mcp-ai")
|
||||
ai.configure {
|
||||
server_label = "claude-mcp",
|
||||
prompts = {
|
||||
fn = "review_function",
|
||||
project = "review_project",
|
||||
ask = "ask_freeform",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`server_label` is the *only* model-specific input. Re-configure
|
||||
to a different `server_label` (with the same prompt names served
|
||||
by a different MCP server) and the same commands route to the
|
||||
new server. Zero code changes.
|
||||
|
||||
This is the recommendation in concrete form: **structure your
|
||||
package's API so that the model is a configuration knob, not
|
||||
a code path.**
|
||||
|
||||
---
|
||||
|
||||
## 7. Disciplines from the M9 audits
|
||||
|
||||
The audit findings from M9.6 – M9.8 distill into a small set of
|
||||
disciplines worth applying up-front in every new MCP package:
|
||||
|
||||
### `notify()` should hit both status and error
|
||||
|
||||
Status messages are overwritten by the next status message.
|
||||
Errors persist in `*pmacs-error*`. Use both:
|
||||
|
||||
```lua
|
||||
local function notify(msg)
|
||||
pmacs.editor.set_status(msg)
|
||||
if pmacs.error then
|
||||
pmacs.error("my-package: " .. msg)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
M9.6 finding 10.
|
||||
|
||||
### Server-gone clears your local state
|
||||
|
||||
See §3.
|
||||
|
||||
### Cross-source DuplicateName via `pmacs.command.exists`
|
||||
|
||||
See §4.
|
||||
|
||||
### Subscription refcount
|
||||
|
||||
If your `register` flow subscribes to notifications, balance
|
||||
subscribes with cancels. Use a per-server refcount, not "always
|
||||
subscribe."
|
||||
|
||||
M9.6 finding 3.
|
||||
|
||||
### Required-arg-order is identity, not order
|
||||
|
||||
When validating prompts/tools args, treat the `arguments` field
|
||||
as a *set* of required names. The advertised order is for UI
|
||||
prompting; required-ness is per-arg, not per-position.
|
||||
|
||||
M9.6 finding 4.
|
||||
|
||||
### Buffer state keyed by `tostring(buf)`, not userdata
|
||||
|
||||
When tracking per-buffer state in a Lua table:
|
||||
|
||||
```lua
|
||||
-- WRONG: silently fails on every re-lookup
|
||||
local _state = {}
|
||||
_state[buf] = { ... }
|
||||
|
||||
-- RIGHT: stable per underlying BufferId
|
||||
local _state = {}
|
||||
_state[tostring(buf)] = { ... }
|
||||
```
|
||||
|
||||
`pmacs.buffer.list()` and `pmacs.window.buffer()` return *fresh*
|
||||
userdata wrappings on every call — a userdata-keyed lookup only
|
||||
finds the first wrapping ever inserted. `tostring(buf)` is
|
||||
stable per underlying id, same convention
|
||||
`builtin/runtime/syntax.lua` already uses for
|
||||
`highlighted_buffers`.
|
||||
|
||||
M9.8 amendment to M9.7 audit. The bug went undetected through
|
||||
M9.7's full acceptance suite because the suite only checked
|
||||
buffer *count*, not body update.
|
||||
|
||||
### Test seams use `_underscore_prefix`
|
||||
|
||||
Functions exposed on your module table for tests but not part of
|
||||
your stable public API should be `_prefix`-named:
|
||||
|
||||
```lua
|
||||
function M._find_enclosing_function(buf, byte_pos)
|
||||
-- Test seam (unstable). Public API uses the higher-level
|
||||
-- ai.ask-about-function command.
|
||||
end
|
||||
```
|
||||
|
||||
This is an idiom, not enforced — but it makes the audit's
|
||||
"public API surface" math unambiguous. The M9.6 audit's
|
||||
`_render_schema_doc`, M9.7's `_format_messages`, M9.8's
|
||||
`_collect_project_files` all follow this pattern.
|
||||
|
||||
---
|
||||
|
||||
## 8. The five fixture packages as worked examples
|
||||
|
||||
| Package | Demonstrates |
|
||||
|---------|--------------|
|
||||
| `pmacs-mcp-resources` | Resource read with cache awareness; `notifications/resources/updated` consumption |
|
||||
| `pmacs-mcp-tools` | Tool-call dispatch; tools-as-commands reconciliation on `list_changed`; M9.6's twelve audit findings disposed |
|
||||
| `pmacs-mcp-prompts` | Prompt-as-result-buffer rendering; format-hint dispatch; tree-sitter highlight attach for code/markdown |
|
||||
| `pmacs-mcp-ai` | AI-assistance composing the above; tree-sitter context selection; structured-arg prompts; server pluggability |
|
||||
|
||||
Each ships under `tests/fixtures/` because they're audit
|
||||
fixtures. The packages compile and run as real packages — the
|
||||
fixture location is just where they live in-tree. v0.2+ may move
|
||||
them to `builtin/packages/` once the audit pipeline is settled.
|
||||
|
||||
The corresponding audit docs are `M9.5-AUDIT.md` ...
|
||||
`M9.9-AUDIT.md`. Each documents the per-package architectural
|
||||
decisions, surface-area math, and audit-finding disposition.
|
||||
|
||||
---
|
||||
|
||||
## 9. Where things go wrong
|
||||
|
||||
Common pitfalls:
|
||||
|
||||
1. **Sending requests before `state.kind == "initialized"`**.
|
||||
`pmacs.mcp.spawn` returns before the handshake completes. Gate
|
||||
on the roster row's state.kind, or use the wait-and-poll
|
||||
pattern from `tests/m9_8_acceptance.rs`'s
|
||||
`spawn_initialized_server`.
|
||||
|
||||
2. **Not distinguishing `isError` from JSON-RPC error**. A tool
|
||||
reporting `isError = true` is a normal result. Don't `pcall`
|
||||
the await and treat all failures equally — the user wants
|
||||
different UX for "the tool said no" vs "the server fell over."
|
||||
|
||||
3. **Caching tool results client-side**. Don't. v0.1's tool layer
|
||||
doesn't, and you shouldn't either — tools may have side
|
||||
effects, and the MCP idempotency hint isn't surfaced yet.
|
||||
|
||||
4. **Subscribing per-call instead of per-server**. If your
|
||||
command subscribes to `list_changed` every invocation, you'll
|
||||
accumulate subscriptions. Subscribe at `register` time,
|
||||
unsubscribe at `unregister` time, refcount across servers.
|
||||
|
||||
5. **Reaching past `pmacs.mcp.*` to a transport detail**. If you
|
||||
find yourself wanting `pmacs.mcp._send_raw_jsonrpc` or similar,
|
||||
stop and ask whether your use case warrants a public API
|
||||
addition. The audit pattern is: surface real use cases, then
|
||||
promote when a second consumer materializes (the
|
||||
"promote-on-second-consumer" discipline). Don't reach around.
|
||||
|
||||
---
|
||||
|
||||
## 10. Versioning and stability
|
||||
|
||||
The `pmacs.mcp.*` Lua API is the stable surface. Six functions
|
||||
plus userdata methods. The shape is locked for v1.0; additions
|
||||
are backwards-compatible.
|
||||
|
||||
The fixture packages are *fixtures*: their public shape is
|
||||
stable enough for examples to keep working, but minor versions
|
||||
may add fields to response shapes (e.g., M9.7 added
|
||||
`_meta.format` honoring; M9.8 amendment promoted `M.render`).
|
||||
Treat their documented public functions as semver-respecting;
|
||||
treat their `_underscore_prefix` test seams as unstable.
|
||||
|
||||
The MCP protocol itself versions independently. pmacs's transport
|
||||
layer reads `protocolVersion` from the initialize result and
|
||||
rejects unsupported versions (M9 Pass-2 finding 3). Your package
|
||||
doesn't need to know about protocol versions — pmacs handles that.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [`docs/package-author-guide.md`](package-author-guide.md) — general
|
||||
package mechanics (manifest, addresses, lockfiles, audit lint)
|
||||
- [`TRANSITION-M9.md`](../TRANSITION-M9.md) — M9 milestone summary,
|
||||
deferred items, audit-finding accumulation
|
||||
- [`spec/pmacs-spec.tex` §sec:m9-ai](../spec/pmacs-spec.tex) — the
|
||||
architectural claim *AI is a transport binding, not a feature*
|
||||
- `tests/fixtures/pmacs-mcp-{resources,tools,prompts,ai}/init.lua`
|
||||
— the four worked examples
|
||||
|
|
@ -231,6 +231,11 @@ enum ReplyKind {
|
|||
/// [`Self::Sleep`] so the worker observability layer can label
|
||||
/// fs jobs separately. T M8.1.
|
||||
FsUnit,
|
||||
/// Externally-settled job produced a JSON value. Sent by
|
||||
/// [`AsyncRuntime::complete_external_ok`] from the main thread;
|
||||
/// the runtime's `tick` translates it to
|
||||
/// [`JobResult::Json`]. T M9.1.
|
||||
Json(serde_json::Value),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
|
@ -269,6 +274,12 @@ pub enum JobResult {
|
|||
/// Lua boundary turns the [`FsDirEntry`] into the same table
|
||||
/// shape `read_dir` entries use. T M8.1.
|
||||
Stat(FsDirEntry),
|
||||
/// External request/reply produced a JSON-shaped result. Used by
|
||||
/// the M9.1 MCP integration; the Lua boundary in
|
||||
/// [`crate::lua_bindings`] translates the inner
|
||||
/// [`serde_json::Value`] to a Lua table when `_take_result` is
|
||||
/// called. T M9.1.
|
||||
Json(serde_json::Value),
|
||||
}
|
||||
|
||||
/// Terminal state a [`PendingJob`] settles into.
|
||||
|
|
@ -310,6 +321,15 @@ pub enum JobKind {
|
|||
FsChmod,
|
||||
/// `dispatch_fs_remove` --- delete a single object ([T M8.1]).
|
||||
FsRemove,
|
||||
/// External request/reply settled by code outside the worker
|
||||
/// pool. Used by the M9.1 MCP integration: the manager registers
|
||||
/// a pending entry via [`AsyncRuntime::register_external`] and
|
||||
/// settles it via [`AsyncRuntime::complete_external_ok`] /
|
||||
/// `complete_external_failed` / `complete_external_cancelled`
|
||||
/// when the corresponding JSON-RPC response arrives on the
|
||||
/// supervisor pipe. No worker thread is occupied for the
|
||||
/// round-trip.
|
||||
McpRequest,
|
||||
}
|
||||
|
||||
impl JobKind {
|
||||
|
|
@ -327,6 +347,7 @@ impl JobKind {
|
|||
JobKind::FsRename => "fs_rename",
|
||||
JobKind::FsChmod => "fs_chmod",
|
||||
JobKind::FsRemove => "fs_remove",
|
||||
JobKind::McpRequest => "mcp_request",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -844,6 +865,71 @@ impl AsyncRuntime {
|
|||
id
|
||||
}
|
||||
|
||||
/// Register a pending entry that will be settled from outside the
|
||||
/// worker pool. Returns `(JobId, CancellationToken)`. The caller
|
||||
/// is responsible for eventually calling
|
||||
/// [`Self::complete_external_ok`], [`Self::complete_external_failed`],
|
||||
/// or [`Self::complete_external_cancelled`] on the returned id;
|
||||
/// the cancellation token is what `pmacs.workers._cancel(id)`
|
||||
/// flips, and the caller should poll it (e.g. inside its tick)
|
||||
/// to give up on outstanding requests when the user cancels.
|
||||
///
|
||||
/// T M9.1: this is the entry point the MCP layer uses to bind
|
||||
/// JSON-RPC request ids to async-runtime job ids without
|
||||
/// occupying a worker thread for the synchronous-write +
|
||||
/// pipe-response round-trip. Future protocols that ride on the
|
||||
/// same supervisor (DAP, etc.) reuse this surface.
|
||||
///
|
||||
/// `supersede` follows the same rule as the worker dispatchers.
|
||||
pub fn register_external(
|
||||
&self,
|
||||
kind: JobKind,
|
||||
supersede: Option<&str>,
|
||||
) -> (JobId, CancellationToken) {
|
||||
self.allocate(kind, supersede, None)
|
||||
}
|
||||
|
||||
/// Settle an externally-registered job with a JSON value. Wakes
|
||||
/// any coroutine parked on the corresponding [`Handle:await()`]
|
||||
/// on the next [`Self::tick`].
|
||||
///
|
||||
/// Idempotent against double-completion: the second call is a
|
||||
/// no-op (the entry has already settled). T M9.1.
|
||||
pub fn complete_external_ok(&self, id: JobId, value: serde_json::Value) {
|
||||
let _ = self.workers.send(
|
||||
ASYNC_REPLY_TOPIC,
|
||||
&WorkerReply {
|
||||
job_id: id,
|
||||
kind: ReplyKind::Json(value),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Settle an externally-registered job with a failure message.
|
||||
/// T M9.1.
|
||||
pub fn complete_external_failed(&self, id: JobId, message: impl Into<String>) {
|
||||
let _ = self.workers.send(
|
||||
ASYNC_REPLY_TOPIC,
|
||||
&WorkerReply {
|
||||
job_id: id,
|
||||
kind: ReplyKind::Error(message.into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Settle an externally-registered job as cancelled. Used when
|
||||
/// the underlying request was abandoned without a response (e.g.
|
||||
/// the MCP server crashed mid-flight). T M9.1.
|
||||
pub fn complete_external_cancelled(&self, id: JobId) {
|
||||
let _ = self.workers.send(
|
||||
ASYNC_REPLY_TOPIC,
|
||||
&WorkerReply {
|
||||
job_id: id,
|
||||
kind: ReplyKind::Cancelled,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Drain the parse-tree bundle for `id` from the side handoff.
|
||||
/// Returns `None` if the job is unknown, still running, didn't
|
||||
/// produce a tree (cancelled or failed), or has already been
|
||||
|
|
@ -916,6 +1002,7 @@ impl AsyncRuntime {
|
|||
| ReplyKind::ReadDir(_)
|
||||
| ReplyKind::Stat(_)
|
||||
| ReplyKind::FsUnit
|
||||
| ReplyKind::Json(_)
|
||||
| ReplyKind::Cancelled
|
||||
| ReplyKind::Error(_)
|
||||
if matches!(job.state, PendingState::Running) =>
|
||||
|
|
@ -932,6 +1019,7 @@ impl AsyncRuntime {
|
|||
PendingState::Complete(JobResult::ReadDir(entries))
|
||||
}
|
||||
ReplyKind::Stat(entry) => PendingState::Complete(JobResult::Stat(entry)),
|
||||
ReplyKind::Json(v) => PendingState::Complete(JobResult::Json(v)),
|
||||
ReplyKind::Cancelled => PendingState::Cancelled,
|
||||
ReplyKind::Error(msg) => PendingState::Failed(msg),
|
||||
_ => unreachable!("matched above"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -69,6 +69,14 @@ pub struct EditorState {
|
|||
/// supervisor's reader threads, which means a runaway server's
|
||||
/// log-flood doesn't stall the editor.
|
||||
pub lsp_manager: crate::lsp::SharedLspManager,
|
||||
/// MCP manager (T M9.1). Holds one [`crate::mcp::McpClient`] per
|
||||
/// MCP server; rides on top of [`Self::process_supervisor`] for
|
||||
/// spawn / I/O / restart, sharing the supervisor with the LSP
|
||||
/// manager. The two managers are siblings — the protocol-uniformity
|
||||
/// claim from spec §sec:concurrency holds because the dispatch
|
||||
/// machinery (supervisor → bytes → parser → state machine →
|
||||
/// events) is identical; only the per-protocol parser differs.
|
||||
pub mcp_manager: crate::mcp::SharedMcpManager,
|
||||
/// Workspace / project model (T M4.9). Owns one
|
||||
/// [`crate::project::Project`] per open project root and tracks
|
||||
/// which one is active for project-scoped commands. Sits next to
|
||||
|
|
@ -178,6 +186,27 @@ impl EditorState {
|
|||
let lsp_manager =
|
||||
crate::lua_bindings::make_lsp_manager(lua_host.lua(), process_supervisor.clone())
|
||||
.expect("install pmacs.lsp");
|
||||
// T M9.1 MCP manager. Wires onto the same supervisor that LSP
|
||||
// and `pmacs.process.*` use; the protocol-uniformity claim is
|
||||
// that this share is sufficient (no parallel dispatch path).
|
||||
// `pmacs.mcp.*` is the Lua surface; the manager itself is a
|
||||
// sibling of `lsp_manager`.
|
||||
let mcp_manager = crate::lua_bindings::make_mcp_manager(
|
||||
lua_host.lua(),
|
||||
process_supervisor.clone(),
|
||||
async_runtime.clone(),
|
||||
)
|
||||
.expect("install pmacs.mcp");
|
||||
// builtin/runtime/mcp.lua overrides `pmacs.mcp.send_request`
|
||||
// with the Handle-returning friendly wrapper. Loaded after
|
||||
// both async.lua (provides `pmacs.workers._new_handle`) and
|
||||
// make_mcp_manager (provides `pmacs.mcp._send_request_raw`).
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/mcp.lua"),
|
||||
include_str!("../builtin/runtime/mcp.lua"),
|
||||
)
|
||||
.expect("load mcp builtin chunk");
|
||||
// T M4.9 project / workspace surface. Built atop the LSP
|
||||
// manager so `pmacs.project.lsp_for` can hand back a
|
||||
// server scoped to (project_root, language_id).
|
||||
|
|
@ -284,6 +313,7 @@ impl EditorState {
|
|||
syntax_registry,
|
||||
process_supervisor,
|
||||
lsp_manager,
|
||||
mcp_manager,
|
||||
workspace,
|
||||
project_indexer,
|
||||
completion_registry,
|
||||
|
|
@ -316,6 +346,16 @@ impl EditorState {
|
|||
self.lsp_manager.borrow_mut().tick();
|
||||
}
|
||||
|
||||
/// One pass of the MCP manager (T M9.1): same shape as
|
||||
/// [`Self::tick_lsp`], applied to MCP servers. The supervisor
|
||||
/// is shared, so [`Self::tick_processes`] feeding LSP and MCP is
|
||||
/// a single call; only the per-manager parse-and-dispatch step
|
||||
/// is per-protocol. Order is `tick_processes` → `tick_lsp` →
|
||||
/// `tick_mcp` so any in-the-same-batch I/O lands deterministically.
|
||||
pub fn tick_mcp(&mut self) {
|
||||
self.mcp_manager.borrow_mut().tick();
|
||||
}
|
||||
|
||||
/// One pass of the main-thread async runtime: drain the worker
|
||||
/// reply bus, fire `on_complete` callbacks, resume coroutines
|
||||
/// parked on settled handles. Called every iteration of the run
|
||||
|
|
@ -897,6 +937,7 @@ pub fn run(file: Option<PathBuf>) -> io::Result<()> {
|
|||
state.tick_async();
|
||||
state.tick_processes();
|
||||
state.tick_lsp();
|
||||
state.tick_mcp();
|
||||
}
|
||||
let _ = frontend.poll_event(Duration::from_millis(0));
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -332,6 +332,10 @@ impl SyntaxHighlightView {
|
|||
}
|
||||
|
||||
impl View for SyntaxHighlightView {
|
||||
fn kind(&self) -> &'static str {
|
||||
"syntax-highlight"
|
||||
}
|
||||
|
||||
fn render(&mut self, _buf: &Buffer, viewport: Viewport, cells: &mut CellGrid<'_>) {
|
||||
self.refresh_cache_if_stale();
|
||||
let Some(bundle) = self.cache.bundle.clone() else {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ pub mod lsp_status;
|
|||
pub mod lua;
|
||||
pub mod lua_bindings;
|
||||
pub mod lua_isolation;
|
||||
pub mod mcp;
|
||||
pub mod message_bus;
|
||||
pub mod minibuffer;
|
||||
pub mod overlay;
|
||||
|
|
|
|||
|
|
@ -4963,7 +4963,8 @@ pub fn install_async(
|
|||
JobResult::Unit
|
||||
| JobResult::Parse { .. }
|
||||
| JobResult::ReadDir(_)
|
||||
| JobResult::Stat(_),
|
||||
| JobResult::Stat(_)
|
||||
| JobResult::Json(_),
|
||||
))
|
||||
| None => ("ok", mlua::Value::Nil),
|
||||
};
|
||||
|
|
@ -5085,6 +5086,13 @@ pub fn install_async(
|
|||
out.push_back(mlua::Value::String(lua.create_string("ok")?));
|
||||
out.push_back(mlua::Value::Table(fs_dir_entry_to_lua(lua, &entry)?));
|
||||
}
|
||||
Some(JobOutcome::Complete(JobResult::Json(value))) => {
|
||||
// Lua surface for externally-completed JSON
|
||||
// jobs (M9.1 MCP requests): status "ok",
|
||||
// value = JSON-translated Lua table.
|
||||
out.push_back(mlua::Value::String(lua.create_string("ok")?));
|
||||
out.push_back(json_to_lua(lua, &value)?);
|
||||
}
|
||||
Some(JobOutcome::Cancelled) => {
|
||||
out.push_back(mlua::Value::String(lua.create_string("cancelled")?));
|
||||
out.push_back(mlua::Value::Nil);
|
||||
|
|
@ -5219,6 +5227,13 @@ fn workers_snapshot_to_lua(lua: &Lua, runtime: &SharedAsyncRuntime) -> mlua::Res
|
|||
JobOutcome::Complete(JobResult::Stat(entry)) => {
|
||||
("ok", mlua::Value::String(lua.create_string(&entry.name)?))
|
||||
}
|
||||
JobOutcome::Complete(JobResult::Json(_)) => {
|
||||
// M9.1 MCP responses surface in the workers buffer
|
||||
// by status only; the JSON payload itself is
|
||||
// displayed in the consumer's own buffer (e.g. an
|
||||
// M9.5 resource buffer).
|
||||
("ok", mlua::Value::Nil)
|
||||
}
|
||||
JobOutcome::Cancelled => ("cancelled", mlua::Value::Nil),
|
||||
JobOutcome::Failed(msg) => ("failed", mlua::Value::String(lua.create_string(msg)?)),
|
||||
};
|
||||
|
|
@ -5624,9 +5639,13 @@ pub fn install_parse(
|
|||
// T M4.3 highlight overlay attach. Looks up the parse view +
|
||||
// bundled `highlights.scm` for the language; constructs a
|
||||
// `SyntaxHighlightView` over them and pushes it as an overlay on
|
||||
// the active window. Idempotent: if an overlay for the same
|
||||
// ParseViewHandle already lives on the active window, this is a
|
||||
// no-op (saves rebuilding query state for re-attach paths).
|
||||
// the active window. Each call pushes a fresh overlay — there is
|
||||
// no dedup at this layer. Callers that may re-attach (the M4
|
||||
// after-load path, the M9.7 prompt-result path) gate against
|
||||
// double-push themselves: the M4 path tracks attached buffer ids
|
||||
// in `builtin/runtime/syntax.lua`'s `highlighted_buffers`; the
|
||||
// M9.7 path issues `pmacs.window.switch_buffer` immediately
|
||||
// before, which clears overlays.
|
||||
{
|
||||
let s = syntax.clone();
|
||||
parse_mod.set(
|
||||
|
|
@ -7093,6 +7112,599 @@ pub fn make_lsp_manager(
|
|||
Ok(manager)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pmacs.mcp: MCP client surface (T M9.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use crate::mcp::{
|
||||
McpClientState, McpError, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
|
||||
McpServerSpec, SharedMcpManager, state_label_for as mcp_state_label_for,
|
||||
};
|
||||
|
||||
/// Lua-facing wrapper around [`McpServerId`]. Mirrors
|
||||
/// [`LspServerIdLua`].
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct McpServerIdLua(pub McpServerId);
|
||||
|
||||
impl McpServerIdLua {
|
||||
/// The wrapped [`McpServerId`].
|
||||
#[must_use]
|
||||
pub fn id(self) -> McpServerId {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromLua for McpServerIdLua {
|
||||
fn from_lua(value: Value, _: &Lua) -> mlua::Result<Self> {
|
||||
match value {
|
||||
Value::UserData(ud) => Ok(*ud.borrow::<Self>()?),
|
||||
other => Err(mlua::Error::FromLuaConversionError {
|
||||
from: other.type_name(),
|
||||
to: "McpServerIdLua".to_string(),
|
||||
message: Some("expected an MCP server handle".to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl UserData for McpServerIdLua {
|
||||
fn add_methods<M: UserDataMethods<Self>>(methods: &mut M) {
|
||||
methods.add_meta_method(mlua::MetaMethod::ToString, |_, this, ()| {
|
||||
Ok(format!("{}", this.0))
|
||||
});
|
||||
methods.add_meta_method(mlua::MetaMethod::Eq, |_, this, other: McpServerIdLua| {
|
||||
Ok(this.0 == other.0)
|
||||
});
|
||||
methods.add_method("raw", |_, this, ()| Ok(this.0.raw()));
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mcp_restart(name: &str) -> mlua::Result<McpRestartPolicy> {
|
||||
Ok(match name {
|
||||
"never" | "Never" => McpRestartPolicy::Never,
|
||||
"on_crash" | "OnCrash" | "on-crash" => McpRestartPolicy::OnCrash,
|
||||
"always" | "Always" => McpRestartPolicy::Always,
|
||||
other => {
|
||||
return Err(mlua::Error::external(format!(
|
||||
"unknown MCP restart policy: {other:?} (expected never|on_crash|always)"
|
||||
)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn lua_to_mcp_spec(t: &Table) -> mlua::Result<McpServerSpec> {
|
||||
let label: String = t.get("label").unwrap_or_else(|_| "unnamed".to_owned());
|
||||
let command: String = t.get("command")?;
|
||||
let args: Vec<String> = t.get("args").unwrap_or_default();
|
||||
let cwd: Option<String> = t.get("cwd").ok().flatten();
|
||||
let env_t: Option<Table> = t.get("env").ok().flatten();
|
||||
let mut env: Vec<(String, String)> = Vec::new();
|
||||
if let Some(env_t) = env_t {
|
||||
env_t.for_each(|k: String, v: String| {
|
||||
env.push((k, v));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let restart = match t.get::<Option<String>>("restart").ok().flatten() {
|
||||
Some(s) => parse_mcp_restart(&s)?,
|
||||
None => McpRestartPolicy::OnCrash,
|
||||
};
|
||||
Ok(McpServerSpec {
|
||||
label,
|
||||
command,
|
||||
args,
|
||||
cwd: cwd.map(std::path::PathBuf::from),
|
||||
env,
|
||||
restart,
|
||||
})
|
||||
}
|
||||
|
||||
fn mcp_state_to_lua(lua: &Lua, state: &McpClientState) -> mlua::Result<Table> {
|
||||
let t = lua.create_table_with_capacity(0, 5)?;
|
||||
t.set("kind", mcp_state_label_for(state))?;
|
||||
match state {
|
||||
McpClientState::Starting
|
||||
| McpClientState::Stopped { .. }
|
||||
| McpClientState::ShuttingDown => {}
|
||||
McpClientState::Initializing {
|
||||
init_request_id, ..
|
||||
} => {
|
||||
t.set("init_request_id", *init_request_id)?;
|
||||
}
|
||||
McpClientState::Initialized {
|
||||
capabilities,
|
||||
server_info,
|
||||
protocol_version,
|
||||
..
|
||||
} => {
|
||||
t.set("capabilities", json_to_lua(lua, capabilities)?)?;
|
||||
if let Some(info) = server_info {
|
||||
t.set("server_info", json_to_lua(lua, info)?)?;
|
||||
}
|
||||
if let Some(v) = protocol_version {
|
||||
t.set("protocol_version", v.as_str())?;
|
||||
}
|
||||
}
|
||||
McpClientState::Crashed { reason, .. } => {
|
||||
t.set("reason", reason.as_str())?;
|
||||
}
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
fn mcp_event_to_lua(lua: &Lua, ev: &McpEvent) -> mlua::Result<Table> {
|
||||
let t = lua.create_table_with_capacity(0, 5)?;
|
||||
t.set("server", McpServerIdLua(ev.server))?;
|
||||
match &ev.kind {
|
||||
McpEventKind::Started { pid } => {
|
||||
t.set("kind", "started")?;
|
||||
t.set("pid", *pid)?;
|
||||
}
|
||||
McpEventKind::Initialized { capabilities } => {
|
||||
t.set("kind", "initialized")?;
|
||||
t.set("capabilities", json_to_lua(lua, capabilities)?)?;
|
||||
}
|
||||
McpEventKind::Notification { method, params } => {
|
||||
t.set("kind", "notification")?;
|
||||
t.set("method", method.as_str())?;
|
||||
t.set("params", json_to_lua(lua, params)?)?;
|
||||
}
|
||||
McpEventKind::Request { id, method, params } => {
|
||||
t.set("kind", "request")?;
|
||||
t.set("request_id", json_to_lua(lua, id)?)?;
|
||||
t.set("method", method.as_str())?;
|
||||
t.set("params", json_to_lua(lua, params)?)?;
|
||||
}
|
||||
McpEventKind::Response {
|
||||
id,
|
||||
result,
|
||||
error,
|
||||
method,
|
||||
} => {
|
||||
t.set("kind", "response")?;
|
||||
t.set("request_id", *id)?;
|
||||
t.set("method", method.as_str())?;
|
||||
t.set("result", json_to_lua(lua, result)?)?;
|
||||
if let Some(e) = error {
|
||||
let err_t = lua.create_table_with_capacity(0, 3)?;
|
||||
err_t.set("code", e.code)?;
|
||||
err_t.set("message", e.message.as_str())?;
|
||||
if let Some(d) = &e.data {
|
||||
err_t.set("data", json_to_lua(lua, d)?)?;
|
||||
}
|
||||
t.set("error", err_t)?;
|
||||
}
|
||||
}
|
||||
McpEventKind::ShuttingDown => {
|
||||
t.set("kind", "shutting_down")?;
|
||||
}
|
||||
McpEventKind::Stopped => {
|
||||
t.set("kind", "stopped")?;
|
||||
}
|
||||
McpEventKind::Crashed { reason } => {
|
||||
t.set("kind", "crashed")?;
|
||||
t.set("reason", reason.as_str())?;
|
||||
}
|
||||
McpEventKind::Restarting { attempt } => {
|
||||
t.set("kind", "restarting")?;
|
||||
t.set("attempt", *attempt)?;
|
||||
}
|
||||
McpEventKind::Stderr(bytes) => {
|
||||
t.set("kind", "stderr")?;
|
||||
t.set("bytes", lua.create_string(bytes)?)?;
|
||||
}
|
||||
McpEventKind::ProtocolError { message } => {
|
||||
t.set("kind", "protocol_error")?;
|
||||
t.set("message", message.as_str())?;
|
||||
}
|
||||
}
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// Lua → [`McpError`]. Either a plain string (becomes a generic
|
||||
/// `code = -32603` internal error) or a table with `{code, message,
|
||||
/// data?}`.
|
||||
fn lua_to_mcp_error(value: Value) -> mlua::Result<McpError> {
|
||||
match value {
|
||||
Value::String(s) => Ok(McpError {
|
||||
code: -32603,
|
||||
message: s.to_str()?.to_owned(),
|
||||
data: None,
|
||||
}),
|
||||
Value::Table(t) => {
|
||||
let code: i64 = t.get("code").unwrap_or(-32603);
|
||||
let message: String = t.get("message").unwrap_or_else(|_| "error".to_owned());
|
||||
let data = match t.get::<Option<Value>>("data")? {
|
||||
Some(Value::Nil) | None => None,
|
||||
Some(other) => Some(lua_to_json(other)?),
|
||||
};
|
||||
Ok(McpError {
|
||||
code,
|
||||
message,
|
||||
data,
|
||||
})
|
||||
}
|
||||
other => Err(mlua::Error::external(format!(
|
||||
"mcp error must be a string or table, got {}",
|
||||
other.type_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Install `pmacs.mcp.*` (T M9.1).
|
||||
///
|
||||
/// Function inventory (10 entries; each parallels the same-named
|
||||
/// `pmacs.lsp.*` entry, with the single addition of `capabilities`
|
||||
/// for M9.1's "discoverable through the worker" acceptance bullet):
|
||||
///
|
||||
/// | `pmacs.mcp.*` | `pmacs.lsp.*` | Notes |
|
||||
/// |----------------------|----------------------|-----------------------------------------|
|
||||
/// | `spawn` | `spawn` | Same shape; MCP spec drops `language_id`/`root_uri`. |
|
||||
/// | `stop` | `stop` | Identical. |
|
||||
/// | `send_request` | `send_request` | Identical (JSON-RPC 2.0 layer is same). |
|
||||
/// | `send_notification` | `send_notification` | Identical. |
|
||||
/// | `send_response` | `send_response` | Identical. |
|
||||
/// | `events_take` | `events_take` | Identical. |
|
||||
/// | `list` | `list` | Identical. |
|
||||
/// | `forget` | `forget` | Identical. |
|
||||
/// | `_tick` | `_tick` | Identical. |
|
||||
/// | `capabilities` | (none — folded into `list`/`status_summary`) | M9.1 acceptance: `pmacs.mcp.capabilities(id)` returns the server's declared capabilities table or nil. |
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "linear list of raw bindings; splitting fragments a coherent surface"
|
||||
)]
|
||||
pub fn install_mcp(lua: &Lua, manager: &SharedMcpManager) -> mlua::Result<()> {
|
||||
lua.set_app_data(manager.clone());
|
||||
let pmacs: Table = lua.globals().get("pmacs")?;
|
||||
let mcp_mod = lua.create_table()?;
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"spawn",
|
||||
lua.create_function(move |_, spec: Table| {
|
||||
let parsed = lua_to_mcp_spec(&spec)?;
|
||||
let id = m
|
||||
.borrow_mut()
|
||||
.spawn(parsed)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(McpServerIdLua(id))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"stop",
|
||||
lua.create_function(move |_, id: McpServerIdLua| {
|
||||
m.borrow_mut().stop(id.0).map_err(mlua::Error::external)?;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Raw MCP request dispatcher. Returns the async-runtime
|
||||
// [`JobId`] the response will settle. Package code does not
|
||||
// call this directly — `builtin/runtime/mcp.lua` wraps it
|
||||
// into a Handle-returning `pmacs.mcp.send_request` that
|
||||
// matches the dispatch shape of `pmacs.workers.compute_sum`,
|
||||
// `pmacs.fs.read_dir`, etc. The underscore-prefixed name
|
||||
// marks this as the unwrapped primitive (mirrors
|
||||
// `pmacs._async._dispatch_*` in async.lua).
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_send_request_raw",
|
||||
lua.create_function(
|
||||
move |_, (id, method, params): (McpServerIdLua, String, Option<Value>)| {
|
||||
let json_params = match params {
|
||||
Some(Value::Nil) | None => serde_json::Value::Null,
|
||||
Some(other) => lua_to_json(other)?,
|
||||
};
|
||||
let job_id = m
|
||||
.borrow_mut()
|
||||
.send_request(id.0, method, json_params)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(job_id)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"send_notification",
|
||||
lua.create_function(
|
||||
move |_, (id, method, params): (McpServerIdLua, String, Option<Value>)| {
|
||||
let json_params = match params {
|
||||
Some(Value::Nil) | None => serde_json::Value::Null,
|
||||
Some(other) => lua_to_json(other)?,
|
||||
};
|
||||
m.borrow_mut()
|
||||
.send_notification(id.0, method, json_params)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"send_response",
|
||||
lua.create_function(
|
||||
move |_,
|
||||
(id, request_id, result, err): (
|
||||
McpServerIdLua,
|
||||
Value,
|
||||
Value,
|
||||
Option<Value>,
|
||||
)| {
|
||||
let request_id_json = lua_to_json(request_id)?;
|
||||
let outcome = match err {
|
||||
Some(Value::Nil) | None => Ok(lua_to_json(result)?),
|
||||
Some(e) => Err(lua_to_mcp_error(e)?),
|
||||
};
|
||||
m.borrow_mut()
|
||||
.send_response(id.0, request_id_json, outcome)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"events_take",
|
||||
lua.create_function(move |lua, id: McpServerIdLua| {
|
||||
let evs = m.borrow_mut().take_events(id.0);
|
||||
let out = lua.create_table_with_capacity(evs.len(), 0)?;
|
||||
for (i, ev) in evs.iter().enumerate() {
|
||||
out.set(i + 1, mcp_event_to_lua(lua, ev)?)?;
|
||||
}
|
||||
Ok(out)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"list",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let mgr = m.borrow();
|
||||
let ids: Vec<McpServerId> = mgr.ids().collect();
|
||||
let out = lua.create_table_with_capacity(ids.len(), 0)?;
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let row = lua.create_table_with_capacity(0, 4)?;
|
||||
row.set("id", McpServerIdLua(*id))?;
|
||||
if let Some(spec) = mgr.spec(*id) {
|
||||
row.set("label", spec.label.as_str())?;
|
||||
row.set("command", spec.command.as_str())?;
|
||||
}
|
||||
if let Some(state) = mgr.state(*id) {
|
||||
row.set("state", mcp_state_to_lua(lua, state)?)?;
|
||||
}
|
||||
if let Some(attempt) = mgr.attempt(*id) {
|
||||
row.set("attempt", attempt)?;
|
||||
}
|
||||
out.set(i + 1, row)?;
|
||||
}
|
||||
Ok(out)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"forget",
|
||||
lua.create_function(move |_, id: McpServerIdLua| {
|
||||
m.borrow_mut().forget(id.0).map_err(mlua::Error::external)?;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_tick",
|
||||
lua.create_function(move |_, ()| {
|
||||
m.borrow_mut().tick();
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// M9.1 acceptance: declared capabilities are discoverable
|
||||
// through the worker. Returns the server's `capabilities`
|
||||
// table (as JSON-translated Lua) or `nil` if the server is
|
||||
// not yet initialized.
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"capabilities",
|
||||
lua.create_function(move |lua, id: McpServerIdLua| {
|
||||
let mgr_ref = m.borrow();
|
||||
match mgr_ref.capabilities(id.0) {
|
||||
Some(caps) => json_to_lua(lua, caps),
|
||||
None => Ok(Value::Nil),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M9.2: raw resource-read dispatcher. Returns the
|
||||
// async-runtime [`JobId`] the response will settle.
|
||||
// `builtin/runtime/mcp.lua` wraps it into a Handle-returning
|
||||
// `pmacs.mcp.read_resource(server, uri)`.
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_read_resource_raw",
|
||||
lua.create_function(move |_, (id, uri): (McpServerIdLua, String)| {
|
||||
let job_id = m
|
||||
.borrow_mut()
|
||||
.read_resource(id.0, uri)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(job_id)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M9.2: explicit cache invalidation. Drops the per-(server,
|
||||
// uri) cache entry; subsequent `read_resource` calls
|
||||
// re-dispatch. In-flight requests at the moment of
|
||||
// invalidation still settle their awaiters with the arriving
|
||||
// result, but do not re-cache.
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"invalidate_resource",
|
||||
lua.create_function(move |_, (id, uri): (McpServerIdLua, String)| {
|
||||
m.borrow_mut().invalidate_resource(id.0, uri);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M9.3: raw tool dispatcher. Returns the async-runtime
|
||||
// [`JobId`] the response will settle. `builtin/runtime/mcp.lua`
|
||||
// wraps it into a Handle-returning
|
||||
// `pmacs.mcp.invoke_tool(server, name, args)`.
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_invoke_tool_raw",
|
||||
lua.create_function(
|
||||
move |_, (id, name, args): (McpServerIdLua, String, Option<Value>)| {
|
||||
let json_args = match args {
|
||||
Some(Value::Nil) | None => {
|
||||
serde_json::Value::Object(serde_json::Map::default())
|
||||
}
|
||||
Some(other) => lua_to_json(other)?,
|
||||
};
|
||||
let job_id = m
|
||||
.borrow_mut()
|
||||
.invoke_tool(id.0, name, json_args)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(job_id)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M9.5: register interest in a JSON-RPC notification method.
|
||||
// The notification dispatcher in `builtin/runtime/mcp.lua`
|
||||
// calls this when the first handler for a method is added,
|
||||
// and `_unsubscribe_notification` when the last one drops.
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_subscribe_notification",
|
||||
lua.create_function(move |_, method: String| {
|
||||
m.borrow_mut().subscribe_notification(method);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_unsubscribe_notification",
|
||||
lua.create_function(move |_, method: String| {
|
||||
m.borrow_mut().unsubscribe_notification(&method);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M9.5: drain all queued subscribed notifications. Returns
|
||||
// a Lua table { [method] = { { server = ..., params = ... }, ... } }.
|
||||
// The Lua tick hook walks this and invokes registered
|
||||
// handlers.
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_drain_notifications",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let drained = m.borrow_mut().drain_notifications();
|
||||
let out = lua.create_table_with_capacity(0, drained.len())?;
|
||||
for (method, entries) in drained {
|
||||
let arr = lua.create_table_with_capacity(entries.len(), 0)?;
|
||||
for (i, (sid, params)) in entries.into_iter().enumerate() {
|
||||
let entry = lua.create_table_with_capacity(0, 2)?;
|
||||
entry.set("server", McpServerIdLua(sid))?;
|
||||
entry.set("params", json_to_lua(lua, ¶ms)?)?;
|
||||
arr.set(i + 1, entry)?;
|
||||
}
|
||||
out.set(method.as_str(), arr)?;
|
||||
}
|
||||
Ok(out)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M9.4: raw prompt dispatcher. Returns the async-runtime
|
||||
// [`JobId`] the response will settle. `builtin/runtime/mcp.lua`
|
||||
// wraps it into a Handle-returning
|
||||
// `pmacs.mcp.get_prompt(server, name, args)`.
|
||||
//
|
||||
// `args` of `nil` (or omitted) translates to an empty object
|
||||
// on the wire — the MCP spec requires the `arguments` field
|
||||
// even for prompts that take no arguments. Three Lua call
|
||||
// patterns produce identical wire requests:
|
||||
// pmacs.mcp.get_prompt(s, "p") (no third arg)
|
||||
// pmacs.mcp.get_prompt(s, "p", nil) (explicit nil)
|
||||
// pmacs.mcp.get_prompt(s, "p", {}) (empty table)
|
||||
let m = manager.clone();
|
||||
mcp_mod.set(
|
||||
"_get_prompt_raw",
|
||||
lua.create_function(
|
||||
move |_, (id, name, args): (McpServerIdLua, String, Option<Value>)| {
|
||||
let json_args = match args {
|
||||
Some(Value::Nil) | None => {
|
||||
serde_json::Value::Object(serde_json::Map::default())
|
||||
}
|
||||
Some(other) => lua_to_json(other)?,
|
||||
};
|
||||
let job_id = m
|
||||
.borrow_mut()
|
||||
.get_prompt(id.0, name, json_args)
|
||||
.map_err(mlua::Error::external)?;
|
||||
Ok(job_id)
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
}
|
||||
|
||||
pmacs.set("mcp", mcp_mod)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a fresh [`McpManager`] over `supervisor` and install
|
||||
/// `pmacs.mcp.*` over it. Mirrors [`make_lsp_manager`] in shape;
|
||||
/// also takes the editor's [`SharedAsyncRuntime`] so MCP responses
|
||||
/// settle Lua-visible job ids without occupying a worker thread
|
||||
/// (T M9.1, Pass-2 finding 1).
|
||||
pub fn make_mcp_manager(
|
||||
lua: &Lua,
|
||||
supervisor: SharedProcessSupervisor,
|
||||
runtime: crate::async_runtime::SharedAsyncRuntime,
|
||||
) -> mlua::Result<SharedMcpManager> {
|
||||
let manager = Rc::new(RefCell::new(McpManager::new(supervisor, runtime)));
|
||||
install_mcp(lua, &manager)?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pmacs.diag: diagnostics surface (T M4.6)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -9090,6 +9702,54 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
)?;
|
||||
}
|
||||
|
||||
// Test seam: list of overlay-`kind` strings on the active window,
|
||||
// in push order. Used by acceptance tests to verify that an
|
||||
// overlay-attaching wire-up step (e.g. T M9.7's
|
||||
// `pmacs.parse._attach_highlight` call for code/markdown prompt
|
||||
// results) actually landed an overlay of the expected kind.
|
||||
// Leading-underscore prefix marks this as test/internal surface,
|
||||
// not stable user-facing API — feature packages shouldn't depend
|
||||
// on it.
|
||||
{
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"_overlay_kinds",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let c = cc.borrow();
|
||||
let kinds = c.active_window().overlay_kinds();
|
||||
let t = lua.create_table_with_capacity(kinds.len(), 0)?;
|
||||
for (i, k) in kinds.into_iter().enumerate() {
|
||||
t.set(i + 1, k)?;
|
||||
}
|
||||
Ok(t)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// Test seams for view-top introspection / poking. Used by T M9.7's
|
||||
// re-invoke test to verify the buffer's switch-on-paint resets the
|
||||
// viewport to the top. Leading-underscore — test surface only.
|
||||
{
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"_view_top",
|
||||
lua.create_function(move |_, ()| {
|
||||
Ok(i64::try_from(cc.borrow().active_window().view_top).unwrap_or(i64::MAX))
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let cc = core.clone();
|
||||
win.set(
|
||||
"_set_view_top",
|
||||
lua.create_function(move |_, n: i64| {
|
||||
let n = usize::try_from(n).map_err(mlua::Error::external)?;
|
||||
cc.borrow_mut().active_window_mut().view_top = n;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(win)
|
||||
}
|
||||
|
||||
|
|
@ -9465,11 +10125,31 @@ fn install_minibuffer_query(mb: &Table, lua: &Lua, core: &SharedCore) -> mlua::R
|
|||
|
||||
fn install_minibuffer_motion(mb: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result<()> {
|
||||
{
|
||||
// `set_contents` mirrors the keyboard-driven typing path
|
||||
// (`MinibufferAction::SelfInsert` runs `replace_contents` *and*
|
||||
// `recompute_candidates` together). Without the recompute, a
|
||||
// Lua-driven `set_contents("foo")` followed by `accept()`
|
||||
// resolves against a stale candidate list — selected was
|
||||
// computed against the *previous* needle, so accept may pick
|
||||
// an unrelated candidate. M9.6's `editor.describe-command`
|
||||
// acceptance test is the first user that hit this; the keyboard
|
||||
// path was always coherent so it never came up before.
|
||||
let cc = core.clone();
|
||||
let lua_for_app = lua.clone();
|
||||
mb.set(
|
||||
"set_contents",
|
||||
lua.create_function(move |_, s: String| {
|
||||
cc.borrow_mut().minibuffer.replace_contents(&s);
|
||||
let cmds_app = lua_for_app
|
||||
.app_data_ref::<SharedCommandRegistry>()
|
||||
.ok_or_else(|| mlua::Error::external(BindingError::NoRegistry))?;
|
||||
let reg_app = lua_for_app
|
||||
.app_data_ref::<SharedRegistry>()
|
||||
.ok_or_else(|| mlua::Error::external(BindingError::NoRegistry))?;
|
||||
let cmds = cmds_app.borrow();
|
||||
let reg = reg_app.borrow();
|
||||
let mut core = cc.borrow_mut();
|
||||
core.minibuffer.replace_contents(&s);
|
||||
core.minibuffer.recompute_candidates(&cmds, ®)?;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -706,6 +706,31 @@ impl ProcessSupervisor {
|
|||
self.signal(id, Signal::SIGTERM)
|
||||
}
|
||||
|
||||
/// Close `id`'s stdin pipe by dropping the writer. The child
|
||||
/// observes EOF on its next read, which is the canonical
|
||||
/// stdio-graceful-shutdown signal for protocols (notably MCP)
|
||||
/// that have no protocol-level shutdown message. Idempotent: a
|
||||
/// second call after the writer is gone is a no-op. Errors only
|
||||
/// if the process id is unknown.
|
||||
///
|
||||
/// Note: this does NOT kill the process. Callers that want a
|
||||
/// guaranteed exit follow up with [`Self::terminate`] (SIGTERM)
|
||||
/// after a grace window, and the supervisor's
|
||||
/// [`Self::shutdown`] applies the SIGKILL fallback at editor
|
||||
/// drop time.
|
||||
pub fn close_stdin(&mut self, id: ProcessId) -> Result<(), String> {
|
||||
let proc = self
|
||||
.processes
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| format!("unknown process: {id}"))?;
|
||||
if let Some(runtime) = proc.runtime.as_mut() {
|
||||
// Dropping the writer closes the pipe at the kernel
|
||||
// level. `take()` is idempotent — second call sees None.
|
||||
let _ = runtime.stdin.take();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `bytes` to `id`'s stdin. Errors if the id is unknown,
|
||||
/// the process is not running, or stdin is closed (the child
|
||||
/// closed stdin on its end, or stdin was never piped in the
|
||||
|
|
|
|||
|
|
@ -358,6 +358,25 @@ pub const BUILTIN_LANGUAGES: &[LanguageEntry] = &[
|
|||
loader: || tree_sitter_lua::LANGUAGE.into(),
|
||||
highlights_query: tree_sitter_lua::HIGHLIGHTS_QUERY,
|
||||
},
|
||||
// T M9.7: markdown grammar for prompt-result buffers with
|
||||
// `_meta.format = "markdown"`. Uses only the block grammar
|
||||
// (`tree_sitter_md::LANGUAGE`) — block-level highlighting (headers,
|
||||
// lists, fenced code blocks, blockquotes) is the v0.1 floor.
|
||||
// Inline highlighting (emphasis, links inside running text) would
|
||||
// require the dual-grammar `MarkdownParser` and is M9.8+ work.
|
||||
// The `markdown_inline` fixture prompt + matching acceptance test
|
||||
// pin this floor: an `**emphasis**` span must not crash, and is
|
||||
// expected to render unhighlighted; any future expansion that
|
||||
// adds inline coverage is additive, not a regression.
|
||||
// Note the constant name: `HIGHLIGHT_QUERY_BLOCK` (singular) is
|
||||
// the markdown crate's idiom; `tree-sitter-rust` and
|
||||
// `tree-sitter-lua` use `HIGHLIGHTS_QUERY` (plural).
|
||||
LanguageEntry {
|
||||
name: "markdown",
|
||||
extensions: &["md", "markdown"],
|
||||
loader: || tree_sitter_md::LANGUAGE.into(),
|
||||
highlights_query: tree_sitter_md::HIGHLIGHT_QUERY_BLOCK,
|
||||
},
|
||||
];
|
||||
|
||||
/// Registry that the Lua surface ([`crate::lua_bindings::install_parse`])
|
||||
|
|
|
|||
11
src/view.rs
11
src/view.rs
|
|
@ -219,6 +219,17 @@ pub trait View {
|
|||
fn display_to_pos(&self, _buf: &Buffer, _coord: DisplayCoord) -> Option<Position> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Stable identifier for this view's *kind*. Used by introspection
|
||||
/// seams (e.g. `pmacs.window._overlay_kinds()`) to verify that a
|
||||
/// specific overlay type actually attached after a wire-up step,
|
||||
/// without needing `Any` downcasts. The default `"unknown"` is
|
||||
/// fine for views that no test cares about; views that participate
|
||||
/// in cross-package wiring (syntax highlight, style overlays)
|
||||
/// should override.
|
||||
fn kind(&self) -> &'static str {
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -184,6 +184,15 @@ impl Window {
|
|||
self.overlays.push(view);
|
||||
}
|
||||
|
||||
/// Stable kind identifiers of every overlay on this window, in
|
||||
/// push order. Test seam used by `pmacs.window._overlay_kinds()`
|
||||
/// to verify that a specific overlay type actually attached
|
||||
/// (e.g. a code-format prompt result buffer expects a
|
||||
/// `"syntax-highlight"` overlay after the wire-up step).
|
||||
pub fn overlay_kinds(&self) -> Vec<&'static str> {
|
||||
self.overlays.iter().map(|v| v.kind()).collect()
|
||||
}
|
||||
|
||||
/// Active region as `(lo, hi)` byte positions, if any. Returns
|
||||
/// `None` when no selection is active or when the selection is
|
||||
/// empty (anchor == cursor).
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ fn format_outcome(outcome: &JobOutcome) -> String {
|
|||
JobOutcome::Complete(JobResult::Stat(entry)) => {
|
||||
format!("ok (stat {:?})", entry.name)
|
||||
}
|
||||
JobOutcome::Complete(JobResult::Json(_)) => "ok (json)".to_string(),
|
||||
JobOutcome::Cancelled => "cancelled".to_string(),
|
||||
JobOutcome::Failed(msg) => {
|
||||
// Trim the failure message for the table; the full
|
||||
|
|
|
|||
|
|
@ -0,0 +1,461 @@
|
|||
-- pmacs-mcp-ai/init.lua --- T M9.8 AI-assistance example package.
|
||||
--
|
||||
-- Public API:
|
||||
--
|
||||
-- local ai = require("pmacs-mcp-ai")
|
||||
-- ai.configure {
|
||||
-- server_label = "claude-mcp", -- the label of an MCP server already
|
||||
-- -- spawned via pmacs.mcp.spawn
|
||||
-- prompts = {
|
||||
-- fn = "review_function", -- which advertised prompt handles
|
||||
-- -- the function-context flow
|
||||
-- project = "review_project", -- ... project-context flow
|
||||
-- ask = "ask_freeform", -- ... freeform-question flow
|
||||
-- },
|
||||
-- }
|
||||
-- ai.unconfigure() -- drop commands; no server change
|
||||
--
|
||||
-- The package's three commands (defined on first configure):
|
||||
--
|
||||
-- ai.ask-about-function -- selects the enclosing function via tree-sitter
|
||||
-- and sends as code-context
|
||||
-- ai.ask-about-project -- collects all file-backed buffers and sends
|
||||
-- as a structured `files: [{path, content}, ...]`
|
||||
-- array (Q5: explicit JSON beats separator
|
||||
-- encoding)
|
||||
-- ai.ask -- prompts for a question, sends with no buffer
|
||||
-- context
|
||||
--
|
||||
-- Architectural commitment (the M9.8 ship gate):
|
||||
--
|
||||
-- * Zero direct calls into the Rust core. Everything reaches the
|
||||
-- Rust side through the public Lua surface (`pmacs.mcp.*`,
|
||||
-- `pmacs.parse.*`, `pmacs.command.*`, etc.).
|
||||
-- * Zero model-specific code. The package speaks MCP; the model
|
||||
-- behind the configured server is interchangeable. The
|
||||
-- `m9_8_server_pluggability_*` tests pin this by configure'ing
|
||||
-- two distinct fake servers and verifying the same command
|
||||
-- routes to whichever is currently configured.
|
||||
--
|
||||
-- Composition story:
|
||||
--
|
||||
-- * Rendering: composes with `pmacs-mcp-prompts.render(label,
|
||||
-- prompt_name, response)` (promoted from internal to public on
|
||||
-- M9.8's request as the second consumer). Result buffers land
|
||||
-- in the M9.7 `*mcp:<label>:<prompt>*` namespace, so re-invoking
|
||||
-- the underlying prompt from either path (M9.8's
|
||||
-- `ai.ask-about-X` or M9.7's auto-registered
|
||||
-- `<label>-<prompt>`) lands in the same buffer.
|
||||
-- * Notifications: M9.8 doesn't subscribe directly. The user is
|
||||
-- expected to also `require("pmacs-mcp-prompts")` and call
|
||||
-- `register(server)` on their AI server if they want the auto-
|
||||
-- registered prompt-commands surface. Either layer functions
|
||||
-- standalone; the AI commands work without M9.7 registration.
|
||||
--
|
||||
-- Context selection (v0.1):
|
||||
--
|
||||
-- * Function context: walk the buffer's tree-sitter parse view to
|
||||
-- find the deepest function-shaped node enclosing the cursor.
|
||||
-- The "function-shaped" mapping is per-language with a generic
|
||||
-- fallback for grammars without a hand-coded entry. v0.1 ships
|
||||
-- with rust + lua mappings (matches the M4 builtin grammars).
|
||||
-- * Project context: all open buffers whose name does NOT start
|
||||
-- with `*`. The exclusion rule is intentional — anything in
|
||||
-- `*name*` is a special buffer (REPL, *help*, *mcp:* result
|
||||
-- buffers, *scratch*, etc.) by convention. Power users wanting
|
||||
-- custom collection should call `pmacs.mcp.get_prompt` directly.
|
||||
-- * Freeform: minibuffer-prompted question; no buffer context.
|
||||
--
|
||||
-- M9.6+M9.7 audit-finding carry-forward:
|
||||
--
|
||||
-- * Server-gone teardown (M9.6 finding 5): `dispatch` detects
|
||||
-- "unknown server" / "not ready for requests" on get_prompt
|
||||
-- failure and clears `_config` so subsequent invocations
|
||||
-- surface the configure-needed message rather than the same
|
||||
-- dead-server error. The user re-configures (or re-spawns the
|
||||
-- server with the same label) to recover.
|
||||
-- * Cross-source collision (M9.6 finding 6): the three command
|
||||
-- names are namespaced under `ai.*` to minimize collision risk
|
||||
-- with builtins. `pmacs.command.exists` is checked before
|
||||
-- defining; on hit, the package warns and skips the colliding
|
||||
-- command — the rest still register cleanly.
|
||||
-- * `notify()` helper (M9.6 finding 10): warnings hit both
|
||||
-- `set_status` and `pmacs.error` so they survive past the
|
||||
-- next set_status overwrite.
|
||||
-- * Notification subscription refcount (M9.6 finding 3): n/a —
|
||||
-- M9.8 doesn't subscribe to notifications directly. M9.7's
|
||||
-- package handles its own subscription lifecycle if the user
|
||||
-- also registers it.
|
||||
|
||||
local mcp_prompts = require("pmacs-mcp-prompts")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Single global config. Re-configure replaces; unconfigure clears.
|
||||
-- Shape: { server_label, prompts = { fn, project, ask } }
|
||||
local _config = nil
|
||||
|
||||
-- Tracks whether the three commands are currently defined. Re-
|
||||
-- configure does NOT redefine — the existing command bodies read
|
||||
-- `_config` lazily, so flipping `server_label` between configure
|
||||
-- calls reroutes invocations without touching the registry.
|
||||
local _commands_defined = false
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Helpers
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function notify(msg)
|
||||
pmacs.editor.set_status(msg)
|
||||
if pmacs.error then
|
||||
pmacs.error("pmacs-mcp-ai: " .. msg)
|
||||
end
|
||||
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
|
||||
|
||||
-- Resolve the configured server label to a live McpServerIdLua, or
|
||||
-- return nil + a friendly message. Done at *invocation time*, not
|
||||
-- configure time, so re-configure is observable on the very next
|
||||
-- invocation without recomputing anything cached.
|
||||
local function resolve_server()
|
||||
if _config == nil then
|
||||
return nil, "ai: not configured (call ai.configure first)"
|
||||
end
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
if row.label == _config.server_label then
|
||||
return row.id, nil
|
||||
end
|
||||
end
|
||||
return nil, string.format(
|
||||
"ai: no MCP server with label %q (spawn first, then configure)",
|
||||
_config.server_label)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Tree-sitter context selection
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Per-language mapping of "function-shaped node types". Adding a new
|
||||
-- language is a one-line addition; languages without a mapping fall
|
||||
-- through to a generic set that covers most C-family / dynamic-
|
||||
-- language grammars.
|
||||
local _FUNCTION_NODE_TYPES = {
|
||||
rust = { "function_item" },
|
||||
lua = { "function_declaration", "local_function", "function_definition" },
|
||||
}
|
||||
|
||||
local _GENERIC_FUNCTION_NODE_TYPES = {
|
||||
"function_declaration",
|
||||
"function_definition",
|
||||
"function_item",
|
||||
"method_declaration",
|
||||
"method_definition",
|
||||
}
|
||||
|
||||
local function function_types_set(language)
|
||||
local list = _FUNCTION_NODE_TYPES[language] or _GENERIC_FUNCTION_NODE_TYPES
|
||||
local set = {}
|
||||
for _, t in ipairs(list) do set[t] = true end
|
||||
return set
|
||||
end
|
||||
|
||||
-- Find the deepest node of any type in `type_set` whose byte range
|
||||
-- contains `byte_pos`. Returns the node or nil. Walks the parse tree
|
||||
-- depth-first; deeper matches take precedence so a method inside a
|
||||
-- struct returns the method (not the struct).
|
||||
--
|
||||
-- Boundary: tree-sitter `end_byte` is exclusive (`end_byte` is the
|
||||
-- position just past the node's last byte). The check `byte_pos > eb`
|
||||
-- — strictly greater than — therefore *includes* `byte_pos == eb`
|
||||
-- as enclosing. This is deliberate and inclusive at the right edge:
|
||||
-- a cursor that has just stepped past the closing brace of a function
|
||||
-- still gets that function as context, which matches the way users
|
||||
-- think about "I'm working on this function." The trade is that a
|
||||
-- cursor on the very first byte of a sibling function will return
|
||||
-- the previous function, since it's `eb` of the previous one *and*
|
||||
-- `sb` of the next, and depth-first ordering visits the previous
|
||||
-- one first. Pinned by `m9_8_find_enclosing_at_end_byte_includes_node`.
|
||||
local function find_enclosing(node, byte_pos, type_set)
|
||||
if node == nil then return nil end
|
||||
local sb = node:start_byte()
|
||||
local eb = node:end_byte()
|
||||
if sb == nil or eb == nil then return nil end
|
||||
if byte_pos < sb or byte_pos > eb then return nil end
|
||||
local children = node:children()
|
||||
if type(children) == "table" then
|
||||
for _, child in ipairs(children) do
|
||||
local found = find_enclosing(child, byte_pos, type_set)
|
||||
if found ~= nil then return found end
|
||||
end
|
||||
end
|
||||
if type_set[node:type()] then return node end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Test seam (unstable): returns the function-shaped node enclosing
|
||||
-- byte_pos in `buf`, plus the language string and a failure-kind
|
||||
-- string (or nil on success). The third return distinguishes the
|
||||
-- two failure modes the body callers care about:
|
||||
--
|
||||
-- * `"no_tree"` — buffer has no parse view yet (common for
|
||||
-- `pmacs.buffer.from_bytes` / `pmacs.buffer.create` buffers, or
|
||||
-- in the brief async-parse window right after a file-open).
|
||||
-- * `"no_enclosing"` — there is a tree, but no function-shaped
|
||||
-- node contains the cursor (cursor in a comment, top-level
|
||||
-- scope, etc.).
|
||||
--
|
||||
-- The seam exists so the M9.8 enclosing-walk test can pin the lookup
|
||||
-- without driving a full M-x → minibuffer → render flow.
|
||||
function M._find_enclosing_function(buf, byte_pos)
|
||||
local tree = pmacs.parse.tree(buf)
|
||||
if tree == nil then return nil, nil, "no_tree" end
|
||||
local language = tree:language()
|
||||
local type_set = function_types_set(language)
|
||||
local node = find_enclosing(tree:root(), byte_pos, type_set)
|
||||
if node == nil then return nil, language, "no_enclosing" end
|
||||
return node, language, nil
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Project context selection
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Collect all "user-content" buffers — file-backed or otherwise, but
|
||||
-- excluding `*<anything>*` star-buffers (REPL, *help*, *mcp:*
|
||||
-- result buffers, *scratch*, etc.). The shape returned to the wire is
|
||||
-- `{path, content}` per Q5; `path` is the buffer name (which is the
|
||||
-- file path for file-backed buffers).
|
||||
--
|
||||
-- Soft size guardrail: if the projected payload (sum of paths +
|
||||
-- contents) exceeds `_PROJECT_PAYLOAD_WARN_BYTES`, surface a notify
|
||||
-- so the user knows they're about to send (and pay for) a large
|
||||
-- request. The collection still proceeds — the warning is
|
||||
-- informational, not a hard cap. Power users wanting a hard limit
|
||||
-- should call `pmacs.mcp.get_prompt` directly with their own
|
||||
-- selection.
|
||||
M._PROJECT_PAYLOAD_WARN_BYTES = 500 * 1024
|
||||
|
||||
function M._collect_project_files()
|
||||
local out = {}
|
||||
local total = 0
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
local d = pmacs.describe.buffer(id)
|
||||
if d ~= nil and type(d.name) == "string" and not d.name:match("^%*") then
|
||||
local content = id:slice(0, id:len())
|
||||
total = total + #content + #d.name
|
||||
out[#out + 1] = { path = d.name, content = content }
|
||||
end
|
||||
end
|
||||
if total > M._PROJECT_PAYLOAD_WARN_BYTES then
|
||||
notify(string.format(
|
||||
"project context is %d bytes (>%d KB warning threshold); proceeding",
|
||||
total, math.floor(M._PROJECT_PAYLOAD_WARN_BYTES / 1024)))
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Dispatch
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function dispatch(server, 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
|
||||
mcp_prompts.render(server_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("ai " .. prompt_name .. " error: " .. msg)
|
||||
if looks_like_server_gone(response_or_err) then
|
||||
-- The configured server vanished. Clear `_config` so the next
|
||||
-- ai.* invocation surfaces the configure-needed message
|
||||
-- rather than the same dead-server error on every retry. The
|
||||
-- user re-configures (or re-spawns the server with the same
|
||||
-- label) to recover. Mirrors M9.6 finding 5 — but since this
|
||||
-- package's commands are stable across configure cycles, we
|
||||
-- clear the *config* rather than unregistering the commands.
|
||||
_config = nil
|
||||
end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Command bodies
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function ask_about_function_body()
|
||||
local server, err = resolve_server()
|
||||
if server == nil then
|
||||
pmacs.editor.set_status(err)
|
||||
return
|
||||
end
|
||||
local prompt_name = (_config.prompts or {}).fn
|
||||
if type(prompt_name) ~= "string" or prompt_name == "" then
|
||||
pmacs.editor.set_status("ai: no `prompts.fn` configured for ask-about-function")
|
||||
return
|
||||
end
|
||||
local buf = pmacs.window.buffer()
|
||||
if buf == nil then
|
||||
pmacs.editor.set_status("ai: no active buffer")
|
||||
return
|
||||
end
|
||||
local cursor = pmacs.editor.cursor()
|
||||
local node, language, fail_kind = M._find_enclosing_function(buf, cursor)
|
||||
if fail_kind == "no_tree" then
|
||||
pmacs.editor.set_status(
|
||||
"ai: buffer not parsed yet (open as a file, or wait for the parse to settle)")
|
||||
return
|
||||
end
|
||||
if node == nil then
|
||||
pmacs.editor.set_status("ai: no enclosing function at cursor (place cursor inside a function)")
|
||||
return
|
||||
end
|
||||
local source = node:text()
|
||||
local file_path = (pmacs.describe.buffer(buf) or {}).name or "<unnamed>"
|
||||
dispatch(server, _config.server_label, prompt_name, {
|
||||
language = language or "text",
|
||||
file_path = file_path,
|
||||
source = source,
|
||||
})
|
||||
end
|
||||
|
||||
local function ask_about_project_body()
|
||||
local server, err = resolve_server()
|
||||
if server == nil then
|
||||
pmacs.editor.set_status(err)
|
||||
return
|
||||
end
|
||||
local prompt_name = (_config.prompts or {}).project
|
||||
if type(prompt_name) ~= "string" or prompt_name == "" then
|
||||
pmacs.editor.set_status("ai: no `prompts.project` configured for ask-about-project")
|
||||
return
|
||||
end
|
||||
local files = M._collect_project_files()
|
||||
if #files == 0 then
|
||||
pmacs.editor.set_status("ai: no file-backed buffers to send as project context")
|
||||
return
|
||||
end
|
||||
dispatch(server, _config.server_label, prompt_name, { files = files })
|
||||
end
|
||||
|
||||
local function ask_body()
|
||||
local server, err = resolve_server()
|
||||
if server == nil then
|
||||
pmacs.editor.set_status(err)
|
||||
return
|
||||
end
|
||||
local prompt_name = (_config.prompts or {}).ask
|
||||
if type(prompt_name) ~= "string" or prompt_name == "" then
|
||||
pmacs.editor.set_status("ai: no `prompts.ask` configured for ask")
|
||||
return
|
||||
end
|
||||
pmacs.minibuffer.read {
|
||||
prompt = "Ask: ",
|
||||
on_accept = function(question)
|
||||
if question == nil or question == "" then return end
|
||||
dispatch(server, _config.server_label, prompt_name, { question = question })
|
||||
end,
|
||||
on_cancel = function()
|
||||
pmacs.editor.set_status("ai: cancelled")
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Command lifecycle
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- Define-once + cross-source-collision skip (M9.6 finding 6
|
||||
-- carry-forward). Each (name, body) pair is gated on
|
||||
-- `pmacs.command.exists` so a builtin or user command already owning
|
||||
-- the slot doesn't abort the whole register. Returns the count of
|
||||
-- commands actually defined.
|
||||
local _COMMAND_DEFS = {
|
||||
{ name = "ai.ask-about-function",
|
||||
description = "Send the enclosing function as context to the configured AI server.",
|
||||
fn = ask_about_function_body },
|
||||
{ name = "ai.ask-about-project",
|
||||
description = "Send all file-backed buffers as project context to the configured AI server.",
|
||||
fn = ask_about_project_body },
|
||||
{ name = "ai.ask",
|
||||
description = "Prompt for a freeform question and send to the configured AI server.",
|
||||
fn = ask_body },
|
||||
}
|
||||
|
||||
local function define_commands()
|
||||
local defined = 0
|
||||
for _, spec in ipairs(_COMMAND_DEFS) do
|
||||
if pmacs.command.exists(spec.name) then
|
||||
notify(string.format(
|
||||
"command %q already defined (skipping)", spec.name))
|
||||
else
|
||||
pmacs.command.define(spec)
|
||||
defined = defined + 1
|
||||
end
|
||||
end
|
||||
return defined
|
||||
end
|
||||
|
||||
local function undefine_commands()
|
||||
for _, spec in ipairs(_COMMAND_DEFS) do
|
||||
if pmacs.command.exists(spec.name) then
|
||||
pmacs.command.unregister(spec.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Public API
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
function M.configure(opts)
|
||||
if type(opts) ~= "table" then
|
||||
error("pmacs-mcp-ai.configure: opts must be a table")
|
||||
end
|
||||
if type(opts.server_label) ~= "string" or opts.server_label == "" then
|
||||
error("pmacs-mcp-ai.configure: opts.server_label must be a non-empty string")
|
||||
end
|
||||
local prompts = opts.prompts
|
||||
if prompts ~= nil and type(prompts) ~= "table" then
|
||||
error("pmacs-mcp-ai.configure: opts.prompts must be a table or nil")
|
||||
end
|
||||
_config = {
|
||||
server_label = opts.server_label,
|
||||
prompts = prompts or {},
|
||||
}
|
||||
if not _commands_defined then
|
||||
define_commands()
|
||||
_commands_defined = true
|
||||
end
|
||||
end
|
||||
|
||||
function M.unconfigure()
|
||||
_config = nil
|
||||
if _commands_defined then
|
||||
undefine_commands()
|
||||
_commands_defined = false
|
||||
end
|
||||
end
|
||||
|
||||
-- Test seam (unstable): returns the current config table or nil.
|
||||
-- The seam exists so configure / re-configure / unconfigure tests
|
||||
-- can pin the state transitions without scraping commands_for or
|
||||
-- pmacs.command.list.
|
||||
function M._config()
|
||||
return _config
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
name = "pmacs-mcp-ai"
|
||||
version = "0.1.0"
|
||||
summary = "AI-assistance example package. Selects context from the current buffer or project, sends a prompt to a configured MCP server, renders the response in a result buffer. The package's only AI dependency is the MCP server it talks to — no model-specific code."
|
||||
pmacs_required = ">= 0.1.0"
|
||||
entry = "init.lua"
|
||||
exports = ["pmacs-mcp-ai"]
|
||||
|
||||
# init.lua does `require("pmacs-mcp-prompts")` for its result-buffer
|
||||
# rendering layer (M9.7 promoted `M.render` to public surface as the
|
||||
# stable contract M9.8 depends on). The dependency is declared here so
|
||||
# the resolver pulls it in transitively for registry-based installs;
|
||||
# `install_local` ignores the dependencies block (per
|
||||
# `src/packages/installer.rs:plan_local`), so the test harness's
|
||||
# explicit two-call install is unaffected. The address is a
|
||||
# placeholder pending the M9.10 release pipeline's choice of registry
|
||||
# scheme; the version constraint is real and binds at resolve time.
|
||||
[[dependencies]]
|
||||
address = "github:pmacs/pmacs-mcp-prompts"
|
||||
version = "^0.1.0"
|
||||
|
|
@ -0,0 +1,807 @@
|
|||
-- 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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
name = "pmacs-mcp-prompts"
|
||||
version = "0.1.0"
|
||||
summary = "Surface MCP prompts as pmacs commands. Required-arg prompts via minibuffer; results land in *mcp:<label>:<prompt>* buffers rendered by format (text / code with tree-sitter / markdown). Auto-reconciles on notifications/prompts/list_changed."
|
||||
pmacs_required = ">= 0.1.0"
|
||||
entry = "init.lua"
|
||||
exports = ["pmacs-mcp-prompts"]
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
-- 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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
name = "pmacs-mcp-resources"
|
||||
version = "0.1.0"
|
||||
summary = "Render MCP resources as buffers (text, markdown, directory) with subscription-driven refresh."
|
||||
pmacs_required = ">= 0.1.0"
|
||||
entry = "init.lua"
|
||||
exports = ["pmacs-mcp-resources", "pmacs-mcp-resources.view"]
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
-- pmacs-mcp-resources/view.lua --- T M9.5 rendering by mimeType.
|
||||
--
|
||||
-- Three render modes routed by the response shape:
|
||||
--
|
||||
-- 1. text (mimeType: text/* or application/json — JSON pretty-
|
||||
-- printed; raw text otherwise) → single buffer with the
|
||||
-- resource's text content.
|
||||
-- 2. directory (mimeType: application/vnd.pmacs.mcp.directory+json;
|
||||
-- text content is a JSON array of child URI strings) →
|
||||
-- navigable buffer with one child URI per line.
|
||||
-- 3. raw (anything else, including unknown mimeTypes) → text
|
||||
-- fallback. Binary blobs are rendered as a placeholder.
|
||||
--
|
||||
-- Returns: { kind = "text" | "directory" | "raw", body = "...",
|
||||
-- mimeType = "...", children = {...} (only for directory) }
|
||||
|
||||
local M = {}
|
||||
|
||||
local function pretty_json(text)
|
||||
-- v0.1: minimal pretty-printer. Walks the input adding newlines
|
||||
-- after `{`, `,`, `[` and indentation; not a full JSON formatter
|
||||
-- but readable for typical MCP server payloads.
|
||||
-- Lua's string library doesn't ship a JSON parser; for v0.1 we
|
||||
-- simply return the raw text. M9.8+ may layer a real formatter.
|
||||
return text
|
||||
end
|
||||
|
||||
-- Permissive JSON-array-of-strings extractor. Returns the strings
|
||||
-- in document order, or nil if the input isn't a JSON array of
|
||||
-- strings. Used by the directory and table renderers; v0.1 doesn't
|
||||
-- pull in a real JSON parser.
|
||||
local function json_string_array(text)
|
||||
if type(text) ~= "string" then return nil end
|
||||
local stripped = text:gsub("^%s*%[", ""):gsub("%]%s*$", "")
|
||||
local out = {}
|
||||
for entry in stripped:gmatch("\"([^\"]+)\"") do
|
||||
out[#out + 1] = entry
|
||||
end
|
||||
if #out == 0 then return nil end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Walk a single row's bracket-pair contents, picking up cells in
|
||||
-- document order regardless of whether they are quoted strings or
|
||||
-- bareword tokens (numbers, true/false/null, identifiers). This is
|
||||
-- the v0.1 mixed-type tokenizer for table rows; Pass-3 finding 2
|
||||
-- replaced an earlier two-pass approach that dropped barewords
|
||||
-- whenever any quoted string was present in the row.
|
||||
--
|
||||
-- Cells are returned as Lua strings; numeric values become their
|
||||
-- text form (e.g. `30`). The renderer pads them as text — column
|
||||
-- alignment doesn't depend on cell type.
|
||||
local function parse_row_cells(inner)
|
||||
local cells = {}
|
||||
-- inner looks like `["alice", 30]` or `[1, 2, 3]` or
|
||||
-- `["a", true, "b"]`; strip the outer brackets first.
|
||||
local body = inner:sub(2, -2)
|
||||
local pos = 1
|
||||
local len = #body
|
||||
while pos <= len do
|
||||
local c = body:sub(pos, pos)
|
||||
if c == '"' then
|
||||
-- Quoted string: scan to the next unescaped quote. v0.1
|
||||
-- doesn't handle backslash escapes; the synthetic fake
|
||||
-- doesn't produce them.
|
||||
local close = body:find('"', pos + 1, true)
|
||||
if close == nil then break end
|
||||
cells[#cells + 1] = body:sub(pos + 1, close - 1)
|
||||
pos = close + 1
|
||||
elseif c == "," or c == " " or c == "\t" or c == "\n" then
|
||||
pos = pos + 1
|
||||
else
|
||||
-- Bareword: number, true, false, null, identifier.
|
||||
local s, e = body:find("[%-%w%.]+", pos)
|
||||
if s == nil or s > pos then
|
||||
-- Unrecognized character; skip it so we don't loop.
|
||||
pos = pos + 1
|
||||
else
|
||||
cells[#cells + 1] = body:sub(s, e)
|
||||
pos = e + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
return cells
|
||||
end
|
||||
|
||||
-- Parse a tiny subset of JSON: an object of the form
|
||||
-- `{ "columns": [...strings...], "rows": [[...strings|numbers...], ...] }`
|
||||
-- and return `{ columns = {...}, rows = {{...}, ...} }`. Returns
|
||||
-- nil if the input doesn't match the expected shape.
|
||||
--
|
||||
-- v0.1's "JSON parser" is regex-based and intentionally permissive:
|
||||
-- it works for the synthetic table the fake server produces; real
|
||||
-- production callers would use a real JSON library when M9.8 hands
|
||||
-- us one.
|
||||
local function parse_table_payload(text)
|
||||
if type(text) ~= "string" then return nil end
|
||||
-- Extract the columns array.
|
||||
local cols_block = text:match("\"columns\"%s*:%s*(%b[])")
|
||||
if cols_block == nil then return nil end
|
||||
local columns = json_string_array(cols_block)
|
||||
if columns == nil then return nil end
|
||||
-- Extract the rows array; each row is an inner array. `%b[]`
|
||||
-- matches balanced brackets but gmatch only finds the outermost,
|
||||
-- so we strip the outer pair and walk the inner ones.
|
||||
local rows_block = text:match("\"rows\"%s*:%s*(%b[])")
|
||||
if rows_block == nil then return nil end
|
||||
local inner_block = rows_block:sub(2, -2)
|
||||
local rows = {}
|
||||
for inner in inner_block:gmatch("(%b[])") do
|
||||
rows[#rows + 1] = parse_row_cells(inner)
|
||||
end
|
||||
return { columns = columns, rows = rows }
|
||||
end
|
||||
|
||||
-- Render `{ columns, rows }` as a column-aligned text table.
|
||||
-- Columns are padded to the max width of their column's values
|
||||
-- (header included). Returns nil if the payload doesn't parse.
|
||||
local function render_table(text)
|
||||
local parsed = parse_table_payload(text)
|
||||
if parsed == nil then return nil end
|
||||
local cols = parsed.columns
|
||||
local rows = parsed.rows
|
||||
-- Compute max width per column.
|
||||
local widths = {}
|
||||
for i, col in ipairs(cols) do
|
||||
widths[i] = #col
|
||||
end
|
||||
for _, row in ipairs(rows) do
|
||||
for i, cell in ipairs(row) do
|
||||
if widths[i] == nil or #cell > widths[i] then
|
||||
widths[i] = #cell
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Pad each cell to its column's width.
|
||||
local function pad(s, w)
|
||||
if #s >= w then return s end
|
||||
return s .. string.rep(" ", w - #s)
|
||||
end
|
||||
local lines = {}
|
||||
-- Header row.
|
||||
local header_cells = {}
|
||||
for i, col in ipairs(cols) do
|
||||
header_cells[i] = pad(col, widths[i])
|
||||
end
|
||||
lines[#lines + 1] = table.concat(header_cells, " | ")
|
||||
-- Separator: dashes per column, joined by " + ".
|
||||
local sep_cells = {}
|
||||
for i = 1, #cols do
|
||||
sep_cells[i] = string.rep("-", widths[i])
|
||||
end
|
||||
lines[#lines + 1] = table.concat(sep_cells, " + ")
|
||||
-- Data rows.
|
||||
for _, row in ipairs(rows) do
|
||||
local cells = {}
|
||||
for i = 1, #cols do
|
||||
cells[i] = pad(row[i] or "", widths[i])
|
||||
end
|
||||
lines[#lines + 1] = table.concat(cells, " | ")
|
||||
end
|
||||
return {
|
||||
body = table.concat(lines, "\n") .. "\n",
|
||||
columns = cols,
|
||||
rows = rows,
|
||||
}
|
||||
end
|
||||
|
||||
function M.render(content_response)
|
||||
-- content_response is the `result` table from `resources/read`:
|
||||
-- { contents = [{ uri, mimeType, text }, ...] }
|
||||
-- The MCP spec allows multiple content entries for a single read;
|
||||
-- v0.1 renders the first entry and surfaces extras as a footer
|
||||
-- comment line.
|
||||
local contents = content_response and content_response.contents
|
||||
if type(contents) ~= "table" or #contents == 0 then
|
||||
return {
|
||||
kind = "raw",
|
||||
body = "[empty resources/read response]\n",
|
||||
mimeType = "text/plain",
|
||||
}
|
||||
end
|
||||
local primary = contents[1]
|
||||
local mime = primary.mimeType or "text/plain"
|
||||
local text = primary.text or ""
|
||||
|
||||
if mime == "application/vnd.pmacs.mcp.directory+json" then
|
||||
-- Parse the JSON array of child URIs.
|
||||
local children = json_string_array(text) or {}
|
||||
return {
|
||||
kind = "directory",
|
||||
body = table.concat(children, "\n") .. "\n",
|
||||
mimeType = mime,
|
||||
children = children,
|
||||
}
|
||||
end
|
||||
|
||||
if mime == "application/vnd.pmacs.mcp.table+json" then
|
||||
-- Pass-2 finding 1: the spec calls out query-result-shaped
|
||||
-- resources rendering as table buffers. v0.1 supports a
|
||||
-- minimal `{ "columns": [...], "rows": [[...]] }` shape on a
|
||||
-- pmacs-specific MIME; servers that already render their
|
||||
-- query results in this shape get column-aligned tables for
|
||||
-- free, while servers using application/json pass through to
|
||||
-- the text fallback below. Generic JSON-to-table inference is
|
||||
-- still M9.8's call.
|
||||
--
|
||||
-- The body is rendered as:
|
||||
-- <col1> | <col2> | ...
|
||||
-- ------ + ------ + ...
|
||||
-- <r1c1> | <r1c2> | ...
|
||||
-- ...
|
||||
local rendered = render_table(text)
|
||||
if rendered then
|
||||
return {
|
||||
kind = "table",
|
||||
body = rendered.body,
|
||||
mimeType = mime,
|
||||
columns = rendered.columns,
|
||||
rows = rendered.rows,
|
||||
}
|
||||
end
|
||||
-- If the table content didn't parse, fall through to text
|
||||
-- so the user at least sees the raw payload.
|
||||
end
|
||||
|
||||
if mime == "application/json" then
|
||||
return {
|
||||
kind = "text",
|
||||
body = pretty_json(text) .. "\n",
|
||||
mimeType = mime,
|
||||
}
|
||||
end
|
||||
|
||||
-- Default: text rendering for any text/* mimeType, or raw for
|
||||
-- everything else. Either way, we put the text in the buffer.
|
||||
local kind = (mime:sub(1, 5) == "text/") and "text" or "raw"
|
||||
-- Ensure the body ends with a newline so cursor positioning at
|
||||
-- end-of-buffer doesn't behave oddly.
|
||||
if text:sub(-1) ~= "\n" then text = text .. "\n" end
|
||||
return {
|
||||
kind = kind,
|
||||
body = text,
|
||||
mimeType = mime,
|
||||
}
|
||||
end
|
||||
|
||||
return M
|
||||
|
|
@ -0,0 +1,725 @@
|
|||
-- 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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
name = "pmacs-mcp-tools"
|
||||
version = "0.1.0"
|
||||
summary = "Surface MCP tools as pmacs commands. Required-arg prompts via minibuffer; auto-reconciles on notifications/tools/list_changed."
|
||||
pmacs_required = ">= 0.1.0"
|
||||
entry = "init.lua"
|
||||
exports = ["pmacs-mcp-tools"]
|
||||
|
|
@ -0,0 +1,926 @@
|
|||
// m9_1_acceptance.rs --- T M9.1 MCP worker variant acceptance.
|
||||
|
||||
//! Acceptance tests for T M9.1 (`spec/pmacs-tasks.tex:3891`):
|
||||
//!
|
||||
//! 1. MCP server supervised through the same supervisor as LSP.
|
||||
//! 2. Initial `initialize` handshake completes; the server's
|
||||
//! declared capabilities are discoverable through the worker.
|
||||
//! 3. Server crash and restart are handled identically to LSP.
|
||||
//!
|
||||
//! Plus the framing-claim cross-check called out during the M9.1
|
||||
//! design review: the supervisor is protocol-agnostic, which is the
|
||||
//! load-bearing property behind the "no new dispatch path" claim.
|
||||
//! `lsp_and_mcp_can_coexist_on_one_supervisor` is the explicit test
|
||||
//! for that — multiple MCPs is the easy case, LSP+MCP exercises the
|
||||
//! protocol-agnostic property.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::async_runtime::{AsyncRuntime, JobOutcome, JobResult, SharedAsyncRuntime};
|
||||
use pmacs::lsp::{LspEventKind, LspManager, LspRestartPolicy, LspServerSpec};
|
||||
use pmacs::lua_bindings::SharedProcessSupervisor;
|
||||
use pmacs::mcp::{
|
||||
McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
|
||||
McpServerSpec, SharedMcpManager,
|
||||
};
|
||||
use pmacs::process::ProcessSupervisor;
|
||||
|
||||
fn fake_mcp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
|
||||
}
|
||||
|
||||
fn fake_lsp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned()
|
||||
}
|
||||
|
||||
fn make_test_triple() -> (
|
||||
SharedProcessSupervisor,
|
||||
SharedAsyncRuntime,
|
||||
SharedMcpManager,
|
||||
) {
|
||||
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
||||
// Pool of 1 is enough — MCP requests don't dispatch through the
|
||||
// pool (Pass-2 finding 1 wires response delivery via
|
||||
// register_external + complete_external_*), so the pool is
|
||||
// sized for tests that may want to mix MCP with another worker
|
||||
// dispatch in the same harness.
|
||||
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||
let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
||||
(sup, runtime, mgr)
|
||||
}
|
||||
|
||||
/// Drain MCP events until `pred` is satisfied or the deadline lapses.
|
||||
/// Mirrors `drain_lsp_until` from the M4 acceptance tests; also
|
||||
/// drives the async runtime's tick so externally-settled jobs are
|
||||
/// observable to callers that `take_result` on a `JobId`.
|
||||
fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
sid: McpServerId,
|
||||
deadline: Duration,
|
||||
pred: F,
|
||||
) -> Vec<McpEvent> {
|
||||
let stop = Instant::now() + deadline;
|
||||
let mut all: Vec<McpEvent> = Vec::new();
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
let mut evs = mgr.borrow_mut().take_events(sid);
|
||||
all.append(&mut evs);
|
||||
if pred(&all) {
|
||||
return all;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn fake_spec(label: &str) -> McpServerSpec {
|
||||
let mut spec = McpServerSpec::new(label, fake_mcp_path());
|
||||
spec.restart = McpRestartPolicy::Never;
|
||||
spec
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Acceptance bullet 2: initialize handshake + capability discovery
|
||||
// ===========================================================================
|
||||
|
||||
/// The handshake completes and the server's declared capabilities
|
||||
/// surface through the manager.
|
||||
#[test]
|
||||
fn m9_1_initialize_handshake_completes_and_capabilities_discoverable() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = mgr.borrow_mut().spawn(fake_spec("init")).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
let caps = evs
|
||||
.iter()
|
||||
.find_map(|e| match &e.kind {
|
||||
McpEventKind::Initialized { capabilities } => Some(capabilities.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("must observe Initialized event");
|
||||
assert!(caps.is_object(), "capabilities should be a JSON object");
|
||||
// The fake server advertises resources, tools, and prompts.
|
||||
assert!(
|
||||
caps.get("resources").is_some(),
|
||||
"fake mcp must advertise resources, got {caps}"
|
||||
);
|
||||
assert!(
|
||||
caps.get("tools").is_some(),
|
||||
"fake mcp must advertise tools, got {caps}"
|
||||
);
|
||||
assert!(
|
||||
caps.get("prompts").is_some(),
|
||||
"fake mcp must advertise prompts, got {caps}"
|
||||
);
|
||||
// State surface mirrors event surface.
|
||||
let state = mgr.borrow().state(sid).cloned();
|
||||
assert!(matches!(state, Some(McpClientState::Initialized { .. })));
|
||||
// Capabilities also reachable via the worker-level getter (M9.1
|
||||
// acceptance: "discoverable through the worker").
|
||||
let mgr_caps = mgr
|
||||
.borrow()
|
||||
.capabilities(sid)
|
||||
.cloned()
|
||||
.expect("capabilities must be discoverable");
|
||||
assert_eq!(
|
||||
mgr_caps, caps,
|
||||
"manager capabilities must match the Initialized event's"
|
||||
);
|
||||
// Protocol version was echoed back. Pass-2 finding 3: pmacs
|
||||
// sends 2025-11-25 (the latest revision) and the fake server
|
||||
// echoes whatever the client sent.
|
||||
assert_eq!(mgr.borrow().protocol_version(sid), Some("2025-11-25"));
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m9_1_server_info_surfaces_after_initialize() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = mgr.borrow_mut().spawn(fake_spec("info")).expect("spawn");
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
let info = mgr
|
||||
.borrow()
|
||||
.server_info(sid)
|
||||
.cloned()
|
||||
.expect("serverInfo must be reported");
|
||||
assert_eq!(
|
||||
info.get("name").and_then(|v| v.as_str()),
|
||||
Some("pmacs-fake-mcp")
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Acceptance bullet 3: crash + restart parity with LSP
|
||||
// ===========================================================================
|
||||
|
||||
/// `OnCrash` policy respawns after the fake server's `exit 7`. The
|
||||
/// observable signal is two `Started` events bracketing a `Crashed`
|
||||
/// + `Restarting` — exactly the LSP test's shape.
|
||||
#[test]
|
||||
fn m9_1_server_crash_auto_restarts() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
mgr.borrow_mut()
|
||||
.set_restart_backoff(Duration::from_millis(50));
|
||||
let mut spec = fake_spec("crasher");
|
||||
spec.restart = McpRestartPolicy::OnCrash;
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "crash".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(10), |evs| {
|
||||
evs.iter()
|
||||
.filter(|e| matches!(e.kind, McpEventKind::Started { .. }))
|
||||
.count()
|
||||
>= 2
|
||||
});
|
||||
let started_count = evs
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, McpEventKind::Started { .. }))
|
||||
.count();
|
||||
assert!(
|
||||
started_count >= 2,
|
||||
"OnCrash policy should respawn after the fake MCP exits non-zero; saw Started count {started_count}"
|
||||
);
|
||||
assert!(
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Crashed { .. })),
|
||||
"must observe Crashed event after the fake MCP's exit 7"
|
||||
);
|
||||
assert!(
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Restarting { .. })),
|
||||
"must observe Restarting event when policy is OnCrash"
|
||||
);
|
||||
assert!(
|
||||
mgr.borrow().attempt(sid).unwrap_or(0) >= 2,
|
||||
"attempt count should be >= 2 after at least one restart"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// After a crash + restart, the new generation re-runs `initialize`.
|
||||
/// Two `Initialized` events prove the handshake replays, which is
|
||||
/// the LSP-parity claim in concrete form.
|
||||
#[test]
|
||||
fn m9_1_restart_reruns_initialize_handshake() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
mgr.borrow_mut()
|
||||
.set_restart_backoff(Duration::from_millis(50));
|
||||
let mut spec = fake_spec("re-init");
|
||||
spec.restart = McpRestartPolicy::OnCrash;
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "crash".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(10), |evs| {
|
||||
evs.iter()
|
||||
.filter(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
.count()
|
||||
>= 2
|
||||
});
|
||||
let init_count = evs
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
.count();
|
||||
assert!(
|
||||
init_count >= 2,
|
||||
"must see Initialized for the original generation and the restart; saw {init_count}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Acceptance bullet 1: same supervisor as LSP
|
||||
// (multi-server coexistence; LSP+MCP cross-protocol)
|
||||
// ===========================================================================
|
||||
|
||||
/// Two MCP servers spawned simultaneously through the same manager
|
||||
/// reach `Initialized` independently. This is the easy case — both
|
||||
/// peers speak the same wire format.
|
||||
#[test]
|
||||
fn m9_1_multiple_mcps_coexist_on_one_manager() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let a = mgr.borrow_mut().spawn(fake_spec("a")).expect("spawn a");
|
||||
let b = mgr.borrow_mut().spawn(fake_spec("b")).expect("spawn b");
|
||||
drain_mcp_until(&sup, &runtime, &mgr, a, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
drain_mcp_until(&sup, &runtime, &mgr, b, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
assert!(matches!(
|
||||
mgr.borrow().state(a),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
mgr.borrow().state(b),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
));
|
||||
let _ = mgr.borrow_mut().stop(a);
|
||||
let _ = mgr.borrow_mut().stop(b);
|
||||
}
|
||||
|
||||
/// **The protocol-uniformity test.** An LSP server and an MCP server
|
||||
/// share one [`ProcessSupervisor`]. Both reach `Initialized`. Neither
|
||||
/// observes events from the other's process. If this test passes
|
||||
/// without special wiring, the supervisor is genuinely
|
||||
/// protocol-agnostic and the spec's "no new dispatch path" claim
|
||||
/// holds. A failure would be a real M9.1 finding worth surfacing.
|
||||
#[test]
|
||||
fn m9_1_lsp_and_mcp_can_coexist_on_one_supervisor() {
|
||||
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
||||
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||
let lsp_mgr = Rc::new(RefCell::new(LspManager::new(sup.clone())));
|
||||
let mcp_mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
||||
|
||||
// Spin up one LSP and one MCP through the same supervisor.
|
||||
let mut lsp_spec = LspServerSpec::new("co-lsp", "rust", fake_lsp_path());
|
||||
lsp_spec.restart = LspRestartPolicy::Never;
|
||||
let lsp_sid = lsp_mgr.borrow_mut().spawn(lsp_spec).expect("spawn lsp");
|
||||
let mcp_sid = mcp_mgr
|
||||
.borrow_mut()
|
||||
.spawn(fake_spec("co-mcp"))
|
||||
.expect("spawn mcp");
|
||||
|
||||
// Drive both for up to 5s, each ticking through the shared
|
||||
// supervisor. The deadline is shared; we exit early once both
|
||||
// have initialized.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut lsp_evs: Vec<LspEventKind> = Vec::new();
|
||||
let mut mcp_evs: Vec<McpEventKind> = Vec::new();
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
lsp_mgr.borrow_mut().tick();
|
||||
mcp_mgr.borrow_mut().tick();
|
||||
let next_lsp: Vec<_> = lsp_mgr
|
||||
.borrow_mut()
|
||||
.take_events(lsp_sid)
|
||||
.into_iter()
|
||||
.map(|e| e.kind)
|
||||
.collect();
|
||||
let next_mcp: Vec<_> = mcp_mgr
|
||||
.borrow_mut()
|
||||
.take_events(mcp_sid)
|
||||
.into_iter()
|
||||
.map(|e| e.kind)
|
||||
.collect();
|
||||
lsp_evs.extend(next_lsp);
|
||||
mcp_evs.extend(next_mcp);
|
||||
let lsp_init = lsp_evs
|
||||
.iter()
|
||||
.any(|k| matches!(k, LspEventKind::Initialized { .. }));
|
||||
let mcp_init = mcp_evs
|
||||
.iter()
|
||||
.any(|k| matches!(k, McpEventKind::Initialized { .. }));
|
||||
if lsp_init && mcp_init {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
|
||||
assert!(
|
||||
lsp_evs
|
||||
.iter()
|
||||
.any(|k| matches!(k, LspEventKind::Initialized { .. })),
|
||||
"LSP server must complete its handshake when sharing a supervisor with MCP; events: {lsp_evs:?}"
|
||||
);
|
||||
assert!(
|
||||
mcp_evs
|
||||
.iter()
|
||||
.any(|k| matches!(k, McpEventKind::Initialized { .. })),
|
||||
"MCP server must complete its handshake when sharing a supervisor with LSP; events: {mcp_evs:?}"
|
||||
);
|
||||
|
||||
// Cross-leakage check: ProtocolError on either side would mean
|
||||
// bytes destined for one peer landed in the other's parser.
|
||||
assert!(
|
||||
!lsp_evs
|
||||
.iter()
|
||||
.any(|k| matches!(k, LspEventKind::ProtocolError { .. })),
|
||||
"LSP saw ProtocolError under coexistence; cross-leak suspected"
|
||||
);
|
||||
assert!(
|
||||
!mcp_evs
|
||||
.iter()
|
||||
.any(|k| matches!(k, McpEventKind::ProtocolError { .. })),
|
||||
"MCP saw ProtocolError under coexistence; cross-leak suspected"
|
||||
);
|
||||
|
||||
let _ = lsp_mgr.borrow_mut().stop(lsp_sid);
|
||||
let _ = mcp_mgr.borrow_mut().stop(mcp_sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Auxiliary: send_request gating, forget, protocol violation
|
||||
// ===========================================================================
|
||||
|
||||
/// `send_request` before the server is `Initialized` errors loudly
|
||||
/// rather than silently dropping the bytes.
|
||||
#[test]
|
||||
fn m9_1_send_request_before_initialized_errors() {
|
||||
let (_sup, _runtime, mgr) = make_test_triple();
|
||||
let sid = mgr.borrow_mut().spawn(fake_spec("early")).expect("spawn");
|
||||
// Don't drain — server is in Starting/Initializing state.
|
||||
let res = mgr
|
||||
.borrow_mut()
|
||||
.send_request(sid, "ping", serde_json::Value::Null);
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"send_request before Initialized must error; got {res:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// `ping` round-trips through the async runtime. Pass-2 finding 1:
|
||||
/// `send_request` returns a `JobId`; the response settles the
|
||||
/// runtime's pending entry rather than landing as a poll-style event
|
||||
/// the caller has to scan for. `Response` events still fire for
|
||||
/// observers, but the canonical settle path is the async runtime.
|
||||
#[test]
|
||||
fn m9_1_ping_request_settles_runtime_job() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = mgr.borrow_mut().spawn(fake_spec("ping")).expect("spawn");
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
let job_id = mgr
|
||||
.borrow_mut()
|
||||
.send_request(sid, "ping", serde_json::Value::Null)
|
||||
.expect("send_request");
|
||||
// Drive the manager + runtime until the job settles. We can't
|
||||
// use drain_mcp_until's predicate over events because the
|
||||
// canonical settle is on the runtime side, not in the
|
||||
// McpManager event queue.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < stop && !runtime.is_complete(job_id) {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
assert!(
|
||||
runtime.is_complete(job_id),
|
||||
"ping job must settle within deadline"
|
||||
);
|
||||
let outcome = runtime.take_result(job_id).expect("take_result");
|
||||
let value = match outcome {
|
||||
JobOutcome::Complete(JobResult::Json(v)) => v,
|
||||
other => panic!("expected Complete(Json(...)), got {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
value.is_object(),
|
||||
"fake mcp returns `result: {{}}` for ping; got {value}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// A garbage line on the wire surfaces as a `ProtocolError` event.
|
||||
/// Mirrors the LSP protocol-violation acceptance test.
|
||||
#[test]
|
||||
fn m9_1_protocol_violation_surfaces_as_structured_error() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("garbager");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "garbage".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter().any(|e| {
|
||||
matches!(
|
||||
e.kind,
|
||||
McpEventKind::ProtocolError { .. }
|
||||
| McpEventKind::Crashed { .. }
|
||||
| McpEventKind::Stopped
|
||||
)
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
evs.iter()
|
||||
.any(|e| matches!(&e.kind, McpEventKind::ProtocolError { .. })),
|
||||
"non-JSON line should surface as a ProtocolError; got events: {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// `forget` removes a terminal-state server from the registry; a
|
||||
/// non-terminal server can't be forgotten.
|
||||
#[test]
|
||||
fn m9_1_forget_only_terminal_servers() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = mgr.borrow_mut().spawn(fake_spec("forget")).expect("spawn");
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
// Forgetting an Initialized server must error.
|
||||
assert!(
|
||||
mgr.borrow_mut().forget(sid).is_err(),
|
||||
"forget should reject a non-terminal server"
|
||||
);
|
||||
// After stop, the server transitions through ShuttingDown to
|
||||
// Stopped; once Stopped, forget succeeds.
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| {
|
||||
matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. })
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
mgr.borrow_mut().forget(sid).is_ok(),
|
||||
"forget should succeed once the server is in a terminal state"
|
||||
);
|
||||
assert!(
|
||||
mgr.borrow().state(sid).is_none(),
|
||||
"forgotten server's state must be None"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Pass-2 findings 2 / 3 / 5: lifecycle correctness
|
||||
// ===========================================================================
|
||||
|
||||
/// Pass-2 finding 2: a JSON-RPC error response to `initialize` must
|
||||
/// **not** transition the client to `Initialized`. The fake server's
|
||||
/// `init_error` mode replies with an error code; pmacs should
|
||||
/// surface a `ProtocolError` event and terminate the process rather
|
||||
/// than masquerading as a healthy server.
|
||||
#[test]
|
||||
fn m9_1_initialize_error_does_not_transition_to_initialized() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("init_error");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "init_error".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. }))
|
||||
});
|
||||
assert!(
|
||||
evs.iter()
|
||||
.any(|e| matches!(&e.kind, McpEventKind::ProtocolError { message } if message.contains("refused initialize"))),
|
||||
"must observe ProtocolError citing the initialize refusal; got {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. })),
|
||||
"Initialized must not fire when initialize returned error"
|
||||
);
|
||||
// The state never transitions to Initialized; capabilities()
|
||||
// returns None.
|
||||
assert!(
|
||||
!matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
),
|
||||
"client state must not be Initialized after error response"
|
||||
);
|
||||
assert!(mgr.borrow().capabilities(sid).is_none());
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Pass-2 finding 3: an unsupported `protocolVersion` in the
|
||||
/// `initialize` response must terminate the connection rather than
|
||||
/// silently accepting it. The fake's `bad_version` mode reports
|
||||
/// `1999-01-01`, which is not in pmacs's supported set.
|
||||
#[test]
|
||||
fn m9_1_unsupported_protocol_version_is_rejected() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("bad_version");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "bad_version".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. }))
|
||||
});
|
||||
assert!(
|
||||
evs.iter().any(|e| matches!(
|
||||
&e.kind,
|
||||
McpEventKind::ProtocolError { message }
|
||||
if message.contains("unsupported protocolVersion")
|
||||
)),
|
||||
"must observe ProtocolError citing the unsupported protocolVersion; got {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
),
|
||||
"client must not reach Initialized for an unsupported protocolVersion"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Pass-3 finding 1: MCP stdio shutdown is stdin-EOF + live SIGTERM /
|
||||
/// SIGKILL fallback. The client must
|
||||
/// **not** send protocol-level `shutdown` requests or `exit`
|
||||
/// notifications. The fake's `crash_on_protocol_shutdown` mode
|
||||
/// exits with code 99 if it ever sees those methods on the wire,
|
||||
/// so a Stopped event (clean exit code 0) proves pmacs took the
|
||||
/// EOF path.
|
||||
#[test]
|
||||
fn m9_1_stop_uses_stdio_eof_not_protocol_shutdown() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("polite-stop");
|
||||
spec.env = vec![(
|
||||
"PMACS_FAKE_MCP_MODE".into(),
|
||||
"crash_on_protocol_shutdown".into(),
|
||||
)];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| {
|
||||
matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. })
|
||||
)
|
||||
});
|
||||
let final_state = mgr.borrow().state(sid).cloned();
|
||||
assert!(
|
||||
matches!(final_state, Some(McpClientState::Stopped { .. })),
|
||||
"stop() must transition to Stopped via stdin EOF (clean exit), not Crashed; got {:?}; events: {:?}",
|
||||
final_state,
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Crashed { .. })),
|
||||
"no Crashed event — server must exit cleanly on stdin EOF; got {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Pass-4 finding 1: EOF is the compliant first shutdown signal, but
|
||||
/// a server that ignores EOF must still be stopped while the manager
|
||||
/// is live. This fixture sleeps forever after stdin closes; the
|
||||
/// shortened grace window lets the manager send SIGTERM and observe a
|
||||
/// terminal Stopped state.
|
||||
#[test]
|
||||
fn m9_1_stop_escalates_when_server_ignores_stdin_eof() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
mgr.borrow_mut()
|
||||
.set_shutdown_grace(Duration::from_millis(25));
|
||||
let mut spec = fake_spec("ignore-eof");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "ignore_eof_sleep".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| {
|
||||
matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. })
|
||||
)
|
||||
});
|
||||
let final_state = mgr.borrow().state(sid).cloned();
|
||||
assert!(
|
||||
matches!(final_state, Some(McpClientState::Stopped { .. })),
|
||||
"stop() must keep escalating after stdin EOF until the process exits; got {:?}; events: {:?}",
|
||||
final_state,
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Pass-3 finding 2: an `initialize` response missing the required
|
||||
/// `capabilities` field is a protocol violation. pmacs must surface
|
||||
/// `ProtocolError` and refuse to transition to `Initialized`,
|
||||
/// rather than defaulting capabilities to `Null` and proceeding.
|
||||
#[test]
|
||||
fn m9_1_missing_capabilities_in_initialize_is_rejected() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("missing_caps");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "missing_caps".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. }))
|
||||
});
|
||||
assert!(
|
||||
evs.iter().any(|e| matches!(
|
||||
&e.kind,
|
||||
McpEventKind::ProtocolError { message }
|
||||
if message.contains("missing required capabilities")
|
||||
)),
|
||||
"must observe ProtocolError citing missing capabilities; got {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. })),
|
||||
"Initialized must not fire when capabilities is missing"
|
||||
);
|
||||
assert!(
|
||||
!matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
),
|
||||
"client state must not be Initialized after a malformed result"
|
||||
);
|
||||
assert!(mgr.borrow().capabilities(sid).is_none());
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Pass-2 finding 5: `OnCrash` policy must not restart on a clean
|
||||
/// exit (code 0). The fake's `clean_exit_after_init` mode replies
|
||||
/// to initialize then exits 0 without us asking. The manager
|
||||
/// should observe `Stopped`, not `Crashed`/`Restarting`.
|
||||
#[test]
|
||||
fn m9_1_oncrash_policy_does_not_restart_on_clean_exit() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
mgr.borrow_mut()
|
||||
.set_restart_backoff(Duration::from_millis(50));
|
||||
let mut spec = fake_spec("clean_exit");
|
||||
spec.restart = McpRestartPolicy::OnCrash;
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "clean_exit_after_init".into())];
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
// Drain for a while to give a buggy implementation room to
|
||||
// restart; the assertion is about what didn't happen.
|
||||
let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(2), |evs| {
|
||||
evs.iter().any(|e| matches!(e.kind, McpEventKind::Stopped))
|
||||
});
|
||||
assert!(
|
||||
evs.iter().any(|e| matches!(e.kind, McpEventKind::Stopped)),
|
||||
"clean exit + OnCrash must surface as Stopped, not Crashed/Restarting; got {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Restarting { .. })),
|
||||
"OnCrash must not respawn on clean exit; got {:?}",
|
||||
evs.iter().map(|e| &e.kind).collect::<Vec<_>>()
|
||||
);
|
||||
let started_count = evs
|
||||
.iter()
|
||||
.filter(|e| matches!(e.kind, McpEventKind::Started { .. }))
|
||||
.count();
|
||||
assert_eq!(
|
||||
started_count, 1,
|
||||
"exactly one Started event for a single clean-exit generation; got {started_count}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lua surface
|
||||
// ===========================================================================
|
||||
|
||||
/// `pmacs.mcp.spawn` / `_tick` / `events_take` / `capabilities` agree
|
||||
/// with the Rust-level view. Smoke test of the boundary marshalling.
|
||||
#[test]
|
||||
fn m9_1_lua_surface_drives_mcp_lifecycle() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let mut state = EditorState::new();
|
||||
let fake = fake_mcp_path();
|
||||
let lua = state.lua_host.lua();
|
||||
let sid_raw: u64 = lua
|
||||
.load(format!(
|
||||
"
|
||||
local id = pmacs.mcp.spawn({{
|
||||
label = 'lua-fake',
|
||||
command = '{fake}',
|
||||
restart = 'never',
|
||||
}})
|
||||
return id:raw()
|
||||
",
|
||||
))
|
||||
.eval()
|
||||
.expect("spawn via Lua");
|
||||
assert!(sid_raw > 0);
|
||||
|
||||
// Pump until Initialized.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut initialized = false;
|
||||
while Instant::now() < stop && !initialized {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
let kinds: Vec<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local out = {}
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
out[#out+1] = row.state.kind
|
||||
end
|
||||
return out
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("list");
|
||||
if kinds.iter().any(|k| k == "initialized") {
|
||||
initialized = true;
|
||||
}
|
||||
if !initialized {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(initialized, "lua-spawned MCP must reach Initialized");
|
||||
|
||||
// capabilities() should return a non-nil table.
|
||||
let has_caps: bool = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local rows = pmacs.mcp.list()
|
||||
assert(#rows == 1, 'expected one server')
|
||||
local caps = pmacs.mcp.capabilities(rows[1].id)
|
||||
return caps ~= nil and type(caps) == 'table' and caps.resources ~= nil
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("capabilities");
|
||||
assert!(
|
||||
has_caps,
|
||||
"pmacs.mcp.capabilities should return the server's caps table"
|
||||
);
|
||||
|
||||
// Stop and let it drain.
|
||||
let _ = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
pmacs.mcp.stop(row.id)
|
||||
end
|
||||
",
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
/// Pass-2 finding 1 acceptance: `pmacs.mcp.send_request` returns a
|
||||
/// Handle, and awaiting that handle inside `pmacs.async(...)`
|
||||
/// resumes with the response's `result` table — same dispatch shape
|
||||
/// as `pmacs.workers.compute_sum`, `pmacs.fs.read_dir`, etc. This is
|
||||
/// the shape M9.2/M9.3 will build on; verifying it here means the
|
||||
/// follow-up tasks don't need to introduce a separate async layer.
|
||||
#[test]
|
||||
fn m9_1_lua_send_request_returns_awaitable_handle() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let mut state = EditorState::new();
|
||||
let fake = fake_mcp_path();
|
||||
let lua = state.lua_host.lua();
|
||||
|
||||
// Spawn the server and stash its id in a global the next
|
||||
// `pmacs.async` block reads.
|
||||
lua.load(format!(
|
||||
"
|
||||
_G._mcp_test_server = pmacs.mcp.spawn({{
|
||||
label = 'lua-await',
|
||||
command = '{fake}',
|
||||
restart = 'never',
|
||||
}})
|
||||
",
|
||||
))
|
||||
.exec()
|
||||
.expect("spawn via Lua");
|
||||
|
||||
// Pump until Initialized.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut initialized = false;
|
||||
while Instant::now() < stop && !initialized {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
let kinds: Vec<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local out = {}
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
out[#out+1] = row.state.kind
|
||||
end
|
||||
return out
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("list");
|
||||
if kinds.iter().any(|k| k == "initialized") {
|
||||
initialized = true;
|
||||
}
|
||||
if !initialized {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(initialized, "server must reach Initialized");
|
||||
|
||||
// Spawn the awaiting coroutine. The result lands in a global
|
||||
// when the handle settles, which we observe from Rust below.
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_test_done = false
|
||||
_G._mcp_test_result = nil
|
||||
pmacs.async(function()
|
||||
local result = pmacs.mcp.send_request(_G._mcp_test_server, 'ping', {}):await()
|
||||
_G._mcp_test_result = result
|
||||
_G._mcp_test_done = true
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("dispatch awaiting coroutine");
|
||||
|
||||
// Pump until the coroutine completes.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done = false;
|
||||
while Instant::now() < stop && !done {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_test_done")
|
||||
.eval::<bool>()
|
||||
.unwrap_or(false);
|
||||
if !done {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
done,
|
||||
"awaiting coroutine must complete (Handle.await() didn't resume)"
|
||||
);
|
||||
|
||||
// The fake's `ping` reply is `result: {}`. The Lua side received
|
||||
// an empty table, which we verify by checking the type.
|
||||
let result_type: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return type(_G._mcp_test_result)")
|
||||
.eval()
|
||||
.expect("read result type");
|
||||
assert_eq!(
|
||||
result_type, "table",
|
||||
"ping response should marshal to a Lua table; got {result_type}"
|
||||
);
|
||||
|
||||
// Stop the server.
|
||||
let _ = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.mcp.stop(_G._mcp_test_server)")
|
||||
.exec();
|
||||
}
|
||||
|
|
@ -0,0 +1,735 @@
|
|||
// m9_2_acceptance.rs --- T M9.2 resource fetch + caching acceptance.
|
||||
|
||||
//! Acceptance tests for T M9.2 (`spec/pmacs-tasks.tex:3914`):
|
||||
//!
|
||||
//! 1. Read-resource round-trip works through a real MCP test server.
|
||||
//! 2. Cache hit on repeated read of an unchanged resource.
|
||||
//! 3. Cache invalidation by explicit `invalidate_resource`
|
||||
//! call; subsequent reads refetch.
|
||||
//!
|
||||
//! Plus three architectural-correctness tests called out during the
|
||||
//! M9.2 design review:
|
||||
//!
|
||||
//! 4. Coalescing under load: 10 concurrent `read_resource` calls
|
||||
//! produce 1 wire request and 10 awaiters that all settle with
|
||||
//! the same result.
|
||||
//! 5. In-flight failure: server crashes mid-request; primary +
|
||||
//! attached awaiters all settle with errors; subsequent read
|
||||
//! re-dispatches (cache state is Absent, not stuck in `InFlight`).
|
||||
//! 6. Invalidation during in-flight: invalidate while a request is
|
||||
//! on the wire; the in-flight response settles awaiters but
|
||||
//! doesn't cache; a fresh read after invalidation re-dispatches.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::async_runtime::{AsyncRuntime, JobId, JobOutcome, JobResult, SharedAsyncRuntime};
|
||||
use pmacs::lua_bindings::SharedProcessSupervisor;
|
||||
use pmacs::mcp::{
|
||||
McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
|
||||
McpServerSpec, SharedMcpManager,
|
||||
};
|
||||
use pmacs::process::ProcessSupervisor;
|
||||
|
||||
fn fake_mcp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
|
||||
}
|
||||
|
||||
fn make_test_triple() -> (
|
||||
SharedProcessSupervisor,
|
||||
SharedAsyncRuntime,
|
||||
SharedMcpManager,
|
||||
) {
|
||||
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
||||
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||
let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
||||
(sup, runtime, mgr)
|
||||
}
|
||||
|
||||
fn fake_spec(label: &str) -> McpServerSpec {
|
||||
let mut spec = McpServerSpec::new(label, fake_mcp_path());
|
||||
spec.restart = McpRestartPolicy::Never;
|
||||
spec
|
||||
}
|
||||
|
||||
/// Drain the supervisor + manager + runtime ticks until either the
|
||||
/// predicate is true or the deadline lapses. Used both for waiting
|
||||
/// on lifecycle events and for waiting on runtime jobs to settle.
|
||||
fn pump_until<F: FnMut() -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
deadline: Duration,
|
||||
mut pred: F,
|
||||
) {
|
||||
let stop = Instant::now() + deadline;
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
if pred() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain MCP events for `sid` until `pred` is satisfied.
|
||||
fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
sid: McpServerId,
|
||||
deadline: Duration,
|
||||
pred: F,
|
||||
) -> Vec<McpEvent> {
|
||||
let stop = Instant::now() + deadline;
|
||||
let mut all: Vec<McpEvent> = Vec::new();
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
let mut evs = mgr.borrow_mut().take_events(sid);
|
||||
all.append(&mut evs);
|
||||
if pred(&all) {
|
||||
return all;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// Drive an Initialized server.
|
||||
fn spawn_initialized(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
spec: McpServerSpec,
|
||||
) -> McpServerId {
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
drain_mcp_until(sup, runtime, mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
assert!(matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
));
|
||||
sid
|
||||
}
|
||||
|
||||
fn await_job(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
job_id: JobId,
|
||||
deadline: Duration,
|
||||
) -> JobOutcome {
|
||||
pump_until(sup, runtime, mgr, deadline, || runtime.is_complete(job_id));
|
||||
runtime
|
||||
.take_result(job_id)
|
||||
.unwrap_or_else(|| panic!("job {job_id} did not settle within deadline"))
|
||||
}
|
||||
|
||||
fn unwrap_text(outcome: JobOutcome) -> String {
|
||||
match outcome {
|
||||
JobOutcome::Complete(JobResult::Json(v)) => {
|
||||
let arr = v
|
||||
.get("contents")
|
||||
.and_then(|c| c.as_array())
|
||||
.expect("contents array");
|
||||
assert_eq!(arr.len(), 1, "fake returns one content entry");
|
||||
arr[0]
|
||||
.get("text")
|
||||
.and_then(|t| t.as_str())
|
||||
.expect("text field")
|
||||
.to_owned()
|
||||
}
|
||||
other => panic!("expected Complete(Json(...)), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Spec acceptance bullets
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 1: read-resource round-trip works.
|
||||
#[test]
|
||||
fn m9_2_read_resource_round_trip() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("read"));
|
||||
let job_id = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///foo")
|
||||
.expect("read_resource");
|
||||
let text = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job_id,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
// Fake's response includes a counter and the URI.
|
||||
assert!(
|
||||
text.contains("synthetic-1-for-file:///foo"),
|
||||
"first read should produce synthetic-1; got {text:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Bullet 2: a second read of the same URI is a cache hit. The fake's
|
||||
/// per-process counter increments on every wire `resources/read`, so
|
||||
/// "two reads, same text" proves the second read didn't go on the wire.
|
||||
#[test]
|
||||
fn m9_2_cache_hit_on_repeated_read() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("hit"));
|
||||
let job1 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///x")
|
||||
.expect("read 1");
|
||||
let text1 = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job1,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
let job2 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///x")
|
||||
.expect("read 2");
|
||||
let text2 = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job2,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
assert_eq!(
|
||||
text1, text2,
|
||||
"second read must hit the cache (same counter value); got {text1:?} vs {text2:?}"
|
||||
);
|
||||
assert!(
|
||||
text1.contains("synthetic-1-for-"),
|
||||
"fake's counter should still be 1 after a single wire fetch; got {text1:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Bullet 3: `invalidate_resource` forces a refetch on the next read.
|
||||
#[test]
|
||||
fn m9_2_invalidation_forces_refetch() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("invalidate"));
|
||||
let job1 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///y")
|
||||
.expect("read 1");
|
||||
let text1 = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job1,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
mgr.borrow_mut().invalidate_resource(sid, "file:///y");
|
||||
let job2 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///y")
|
||||
.expect("read after invalidate");
|
||||
let text2 = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job2,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
assert_ne!(
|
||||
text1, text2,
|
||||
"post-invalidation read must refetch (different counter); got {text1:?} both times"
|
||||
);
|
||||
assert!(
|
||||
text1.contains("synthetic-1-for-"),
|
||||
"first counter value should be 1; got {text1:?}"
|
||||
);
|
||||
assert!(
|
||||
text2.contains("synthetic-2-for-"),
|
||||
"post-invalidation counter should be 2 (fresh wire fetch); got {text2:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Architectural-correctness tests
|
||||
// ===========================================================================
|
||||
|
||||
/// (4) M3.5 coalescing: 10 concurrent `read_resource` calls for the
|
||||
/// same URI produce 1 wire request and 10 awaiters that settle with
|
||||
/// the same result. Verifies the `InFlight` state attaches siblings.
|
||||
#[test]
|
||||
fn m9_2_coalescing_under_load() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("coalesce"));
|
||||
let mut jobs: Vec<JobId> = Vec::with_capacity(10);
|
||||
for _ in 0..10 {
|
||||
let j = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///shared")
|
||||
.expect("read");
|
||||
jobs.push(j);
|
||||
}
|
||||
pump_until(&sup, &runtime, &mgr, Duration::from_secs(5), || {
|
||||
jobs.iter().all(|&j| runtime.is_complete(j))
|
||||
});
|
||||
let mut texts: Vec<String> = Vec::with_capacity(jobs.len());
|
||||
for j in jobs {
|
||||
let outcome = runtime.take_result(j).expect("settled");
|
||||
texts.push(unwrap_text(outcome));
|
||||
}
|
||||
let first = &texts[0];
|
||||
for t in &texts[1..] {
|
||||
assert_eq!(
|
||||
first, t,
|
||||
"all coalesced awaiters must settle with the same value"
|
||||
);
|
||||
}
|
||||
// Server-side counter visible in the text proves only one wire
|
||||
// fetch landed despite 10 client-side reads.
|
||||
assert!(
|
||||
first.contains("synthetic-1-for-"),
|
||||
"counter must be 1 for a single wire fetch coalescing 10 reads; got {first:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// (5) In-flight failure: server crashes mid-request; primary +
|
||||
/// attached awaiters all settle with errors; the cache transitions
|
||||
/// `InFlight` → Absent (not stuck), so a subsequent read
|
||||
/// re-dispatches once a fresh server is in place.
|
||||
#[test]
|
||||
fn m9_2_in_flight_failure_settles_all_awaiters_and_clears_cache() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("crash");
|
||||
spec.env = vec![(
|
||||
"PMACS_FAKE_MCP_MODE".into(),
|
||||
"crash_after_first_request".into(),
|
||||
)];
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
|
||||
// 5 concurrent reads coalesce onto one in-flight request that
|
||||
// never completes — the fake exits with code 77 instead.
|
||||
let mut jobs: Vec<JobId> = Vec::with_capacity(5);
|
||||
for _ in 0..5 {
|
||||
let j = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///doomed")
|
||||
.expect("read");
|
||||
jobs.push(j);
|
||||
}
|
||||
pump_until(&sup, &runtime, &mgr, Duration::from_secs(5), || {
|
||||
jobs.iter().all(|&j| runtime.is_complete(j))
|
||||
});
|
||||
for j in jobs {
|
||||
let outcome = runtime.take_result(j).expect("settled");
|
||||
assert!(
|
||||
matches!(outcome, JobOutcome::Cancelled | JobOutcome::Failed(_)),
|
||||
"every awaiter must settle non-Ok after server crash; got {outcome:?}"
|
||||
);
|
||||
}
|
||||
// Server is now Crashed; cache should not be stuck in InFlight.
|
||||
// We can't directly inspect the cache from outside the manager,
|
||||
// but a subsequent read_resource attempt would return an error
|
||||
// (server not Initialized), which is the right negative signal:
|
||||
// the cache didn't pin a stale InFlight entry.
|
||||
let res = mgr.borrow_mut().read_resource(sid, "file:///doomed");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"reads against a crashed server must error (not return a stale handle); got {res:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// (6) Invalidation during in-flight: invalidate while a request is
|
||||
/// on the wire. The in-flight response still settles awaiters with
|
||||
/// the result, but **does not** cache. A fresh read after invalidation
|
||||
/// re-dispatches.
|
||||
#[test]
|
||||
fn m9_2_invalidation_during_in_flight_does_not_cache() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("inv-in-flight"));
|
||||
// Dispatch the first read. Don't drive it to completion yet.
|
||||
let job1 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///z")
|
||||
.expect("read 1");
|
||||
// Invalidate immediately (request is still on the wire).
|
||||
mgr.borrow_mut().invalidate_resource(sid, "file:///z");
|
||||
// Drive job 1 to completion. It should still settle with a
|
||||
// result — invalidation does not abort in-flight requests.
|
||||
let outcome1 = await_job(&sup, &runtime, &mgr, job1, Duration::from_secs(5));
|
||||
let text1 = unwrap_text(outcome1);
|
||||
// Now dispatch a fresh read. With finding-correct behavior,
|
||||
// this re-dispatches (fake counter increments). Without it, a
|
||||
// bug could either return the stale "cached" value (counter=1)
|
||||
// or return whatever the in-flight request stashed.
|
||||
let job2 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///z")
|
||||
.expect("read 2");
|
||||
let text2 = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job2,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
assert!(
|
||||
text1.contains("synthetic-1-for-"),
|
||||
"first read produced counter 1; got {text1:?}"
|
||||
);
|
||||
assert!(
|
||||
text2.contains("synthetic-2-for-"),
|
||||
"post-invalidation read must refetch (counter 2); got {text2:?} (cache-after-invalidation bug?)"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Pass-2 findings: lifecycle + per-sibling cancellation
|
||||
// ===========================================================================
|
||||
|
||||
/// Pass-2 finding 1: a cached resource read against a stopped or
|
||||
/// forgotten server must error rather than return stale cache data.
|
||||
/// `read_resource` checks the server's state before consulting the
|
||||
/// cache, and `on_exit` / `forget` clear the cache for that sid so
|
||||
/// no stale entries can survive.
|
||||
#[test]
|
||||
fn m9_2_cached_read_after_stop_rejects_stale_sid() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("stop-cache"));
|
||||
|
||||
// Populate the cache.
|
||||
let job1 = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///stale")
|
||||
.expect("read 1");
|
||||
let _ = unwrap_text(await_job(
|
||||
&sup,
|
||||
&runtime,
|
||||
&mgr,
|
||||
job1,
|
||||
Duration::from_secs(5),
|
||||
));
|
||||
|
||||
// Stop and pump to terminal.
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| {
|
||||
matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. })
|
||||
)
|
||||
});
|
||||
|
||||
// The post-stop read must error: the server is not Initialized.
|
||||
let res = mgr.borrow_mut().read_resource(sid, "file:///stale");
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"post-stop read_resource must error (not return cached data); got {res:?}"
|
||||
);
|
||||
|
||||
// Forget and try again — same expectation, different code path.
|
||||
mgr.borrow_mut()
|
||||
.forget(sid)
|
||||
.expect("forget should succeed in terminal state");
|
||||
let res2 = mgr.borrow_mut().read_resource(sid, "file:///stale");
|
||||
assert!(
|
||||
res2.is_err(),
|
||||
"post-forget read_resource must error (server unknown); got {res2:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Pass-2 finding 2: per-awaiter cancellation tokens. With three
|
||||
/// awaiters coalesced onto one in-flight request, cancelling one
|
||||
/// must:
|
||||
/// (a) settle that awaiter as Cancelled,
|
||||
/// (b) leave the other two awaiters waiting,
|
||||
/// (c) NOT abort the in-flight wire request,
|
||||
/// (d) deliver the eventual response to the surviving awaiters as Ok.
|
||||
#[test]
|
||||
fn m9_2_per_sibling_cancellation_does_not_disturb_others() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("per-sibling-cancel");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "slow_resources_read".into())];
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
|
||||
|
||||
// Three concurrent reads — coalesce onto one in-flight request.
|
||||
// The fake delays 250ms before responding, so we have a window
|
||||
// to cancel one awaiter before the response arrives.
|
||||
let job_a = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///shared")
|
||||
.expect("read a");
|
||||
let job_b = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///shared")
|
||||
.expect("read b");
|
||||
let job_c = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///shared")
|
||||
.expect("read c");
|
||||
|
||||
// Cancel only b. Use the runtime's cancel API directly (this is
|
||||
// what `pmacs.workers._cancel(id)` ultimately calls).
|
||||
runtime.cancel(job_b);
|
||||
|
||||
// Pump until all three settle.
|
||||
pump_until(&sup, &runtime, &mgr, Duration::from_secs(5), || {
|
||||
runtime.is_complete(job_a) && runtime.is_complete(job_b) && runtime.is_complete(job_c)
|
||||
});
|
||||
|
||||
let outcome_a = runtime.take_result(job_a).expect("a settled");
|
||||
let outcome_b = runtime.take_result(job_b).expect("b settled");
|
||||
let outcome_c = runtime.take_result(job_c).expect("c settled");
|
||||
|
||||
// (a) and (c) must settle Ok.
|
||||
let text_a = unwrap_text(outcome_a);
|
||||
let text_c = unwrap_text(outcome_c);
|
||||
assert_eq!(
|
||||
text_a, text_c,
|
||||
"uncancelled awaiters must settle Ok with the same value"
|
||||
);
|
||||
assert!(
|
||||
text_a.contains("synthetic-1-for-"),
|
||||
"wire fetch should have produced counter 1; got {text_a:?}"
|
||||
);
|
||||
|
||||
// (b) must settle Cancelled.
|
||||
assert!(
|
||||
matches!(outcome_b, JobOutcome::Cancelled),
|
||||
"the cancelled awaiter must settle as Cancelled; got {outcome_b:?}"
|
||||
);
|
||||
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Same contract as the previous test, but with the response already
|
||||
/// queued before the manager observes cancellation. This catches the
|
||||
/// tick-order race where process events were drained before cancelled
|
||||
/// awaiters and a cancelled handle could receive `Ok`.
|
||||
#[test]
|
||||
fn m9_2_cancelled_sibling_wins_over_queued_response() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("cancel-response-race");
|
||||
spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "slow_resources_read".into())];
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
|
||||
|
||||
let job_a = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///race")
|
||||
.expect("read a");
|
||||
let job_b = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///race")
|
||||
.expect("read b");
|
||||
let job_c = mgr
|
||||
.borrow_mut()
|
||||
.read_resource(sid, "file:///race")
|
||||
.expect("read c");
|
||||
|
||||
// Let the fake server finish its delayed response, then harvest
|
||||
// the supervisor event queue without giving McpManager a chance
|
||||
// to process it yet.
|
||||
std::thread::sleep(Duration::from_millis(350));
|
||||
sup.borrow_mut().tick();
|
||||
|
||||
// Cancel only b after the response is queued but before
|
||||
// McpManager::tick drains that response.
|
||||
runtime.cancel(job_b);
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
|
||||
assert!(
|
||||
runtime.is_complete(job_a) && runtime.is_complete(job_b) && runtime.is_complete(job_c),
|
||||
"one manager tick should settle the queued response and cancellation"
|
||||
);
|
||||
|
||||
let outcome_a = runtime.take_result(job_a).expect("a settled");
|
||||
let outcome_b = runtime.take_result(job_b).expect("b settled");
|
||||
let outcome_c = runtime.take_result(job_c).expect("c settled");
|
||||
let text_a = unwrap_text(outcome_a);
|
||||
let text_c = unwrap_text(outcome_c);
|
||||
assert_eq!(text_a, text_c, "surviving awaiters receive the response");
|
||||
assert!(
|
||||
matches!(outcome_b, JobOutcome::Cancelled),
|
||||
"cancelled awaiter must remain Cancelled even when response was queued first; got {outcome_b:?}"
|
||||
);
|
||||
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lua surface
|
||||
// ===========================================================================
|
||||
|
||||
/// `pmacs.mcp.read_resource(server, uri):await()` resolves to the
|
||||
/// response's `result` table. The fake's `resources/read` returns
|
||||
/// `{ contents = [{ uri, mimeType, text }] }`.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "linear pump-coroutine-then-verify pattern; splitting fragments the test's narrative"
|
||||
)]
|
||||
fn m9_2_lua_read_resource_returns_awaitable_handle() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let mut state = EditorState::new();
|
||||
let fake = fake_mcp_path();
|
||||
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
"
|
||||
_G._mcp_test_server = pmacs.mcp.spawn({{
|
||||
label = 'lua-read',
|
||||
command = '{fake}',
|
||||
restart = 'never',
|
||||
}})
|
||||
",
|
||||
))
|
||||
.exec()
|
||||
.expect("spawn via Lua");
|
||||
|
||||
// Pump until Initialized.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut initialized = false;
|
||||
while Instant::now() < stop && !initialized {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
let kinds: Vec<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local out = {}
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
out[#out+1] = row.state.kind
|
||||
end
|
||||
return out
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("list");
|
||||
if kinds.iter().any(|k| k == "initialized") {
|
||||
initialized = true;
|
||||
}
|
||||
if !initialized {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(initialized, "server must reach Initialized");
|
||||
|
||||
// Spawn the awaiting coroutine.
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_test_done = false
|
||||
_G._mcp_test_text = nil
|
||||
pmacs.async(function()
|
||||
local result = pmacs.mcp.read_resource(_G._mcp_test_server, 'file:///lua'):await()
|
||||
_G._mcp_test_text = result.contents[1].text
|
||||
_G._mcp_test_done = true
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("dispatch awaiting coroutine");
|
||||
|
||||
// Pump until the coroutine completes.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done = false;
|
||||
while Instant::now() < stop && !done {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_test_done")
|
||||
.eval::<bool>()
|
||||
.unwrap_or(false);
|
||||
if !done {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(done, "awaiting coroutine must complete");
|
||||
|
||||
let text: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_test_text")
|
||||
.eval()
|
||||
.expect("read result text");
|
||||
assert!(
|
||||
text.contains("file:///lua"),
|
||||
"read_resource result must contain the URI; got {text:?}"
|
||||
);
|
||||
|
||||
// Verify cache hit: invalidate via Lua, then read again, the new
|
||||
// text differs (counter incremented).
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_test_done2 = false
|
||||
_G._mcp_test_text2 = nil
|
||||
pmacs.mcp.invalidate_resource(_G._mcp_test_server, 'file:///lua')
|
||||
pmacs.async(function()
|
||||
local result = pmacs.mcp.read_resource(_G._mcp_test_server, 'file:///lua'):await()
|
||||
_G._mcp_test_text2 = result.contents[1].text
|
||||
_G._mcp_test_done2 = true
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("invalidate + re-read");
|
||||
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done2 = false;
|
||||
while Instant::now() < stop && !done2 {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done2 = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_test_done2")
|
||||
.eval::<bool>()
|
||||
.unwrap_or(false);
|
||||
if !done2 {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(done2, "post-invalidation read must complete");
|
||||
|
||||
let text2: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_test_text2")
|
||||
.eval()
|
||||
.expect("read result text 2");
|
||||
assert_ne!(
|
||||
text, text2,
|
||||
"Lua-side invalidate_resource must force refetch"
|
||||
);
|
||||
|
||||
let _ = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.mcp.stop(_G._mcp_test_server)")
|
||||
.exec();
|
||||
}
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
// m9_3_acceptance.rs --- T M9.3 tool invocation acceptance.
|
||||
|
||||
//! Acceptance tests for T M9.3 (`spec/pmacs-tasks.tex:3931`):
|
||||
//!
|
||||
//! 1. Tool invocation against a real MCP test server returns the
|
||||
//! expected result.
|
||||
//! 2. Tool errors propagate as Lua errors with the server's error
|
||||
//! message attached.
|
||||
//! 3. Cancellation: a tool invocation in flight when the calling
|
||||
//! coroutine is killed releases server-side resources cleanly.
|
||||
//!
|
||||
//! Plus the failure-mode coverage called out during the M9.3 design
|
||||
//! review:
|
||||
//!
|
||||
//! 4. Two distinct error paths converge on the runtime's Failed
|
||||
//! outcome: MCP "tool errored" (isError: true) and JSON-RPC
|
||||
//! "method/error" (unknown tool name).
|
||||
//! 5. Cancellation reaches the server: the fake's sentinel-file
|
||||
//! mechanism proves `notifications/cancelled` arrived.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::async_runtime::{AsyncRuntime, JobId, JobOutcome, JobResult, SharedAsyncRuntime};
|
||||
use pmacs::lua_bindings::SharedProcessSupervisor;
|
||||
use pmacs::mcp::{
|
||||
McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
|
||||
McpServerSpec, SharedMcpManager,
|
||||
};
|
||||
use pmacs::process::ProcessSupervisor;
|
||||
|
||||
fn fake_mcp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
|
||||
}
|
||||
|
||||
fn make_test_triple() -> (
|
||||
SharedProcessSupervisor,
|
||||
SharedAsyncRuntime,
|
||||
SharedMcpManager,
|
||||
) {
|
||||
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
||||
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||
let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
||||
(sup, runtime, mgr)
|
||||
}
|
||||
|
||||
fn fake_spec(label: &str) -> McpServerSpec {
|
||||
let mut spec = McpServerSpec::new(label, fake_mcp_path());
|
||||
spec.restart = McpRestartPolicy::Never;
|
||||
spec
|
||||
}
|
||||
|
||||
fn pump_until<F: FnMut() -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
deadline: Duration,
|
||||
mut pred: F,
|
||||
) {
|
||||
let stop = Instant::now() + deadline;
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
if pred() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
sid: McpServerId,
|
||||
deadline: Duration,
|
||||
pred: F,
|
||||
) -> Vec<McpEvent> {
|
||||
let stop = Instant::now() + deadline;
|
||||
let mut all: Vec<McpEvent> = Vec::new();
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
let mut evs = mgr.borrow_mut().take_events(sid);
|
||||
all.append(&mut evs);
|
||||
if pred(&all) {
|
||||
return all;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn spawn_initialized(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
spec: McpServerSpec,
|
||||
) -> McpServerId {
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
drain_mcp_until(sup, runtime, mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
assert!(matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
));
|
||||
sid
|
||||
}
|
||||
|
||||
fn await_job(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
job_id: JobId,
|
||||
deadline: Duration,
|
||||
) -> JobOutcome {
|
||||
pump_until(sup, runtime, mgr, deadline, || runtime.is_complete(job_id));
|
||||
runtime
|
||||
.take_result(job_id)
|
||||
.unwrap_or_else(|| panic!("job {job_id} did not settle within deadline"))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bullet 1: round-trip
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 1: invoke a tool, get back its result. The fake's `echo`
|
||||
/// tool produces `{ content: [{ type: "text", text: "<echo>: ..." }],
|
||||
/// isError: false }`. The handle settles with that result table.
|
||||
#[test]
|
||||
fn m9_3_invoke_tool_round_trip() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("invoke"));
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.invoke_tool(sid, "echo", serde_json::json!({ "text": "hello mcp" }))
|
||||
.expect("invoke_tool");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let result = match outcome {
|
||||
JobOutcome::Complete(JobResult::Json(v)) => v,
|
||||
other => panic!("expected Complete(Json(...)), got {other:?}"),
|
||||
};
|
||||
let text = result
|
||||
.get("content")
|
||||
.and_then(|c| c.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|p| p.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.expect("content[0].text");
|
||||
assert_eq!(text, "<echo>: hello mcp");
|
||||
assert_eq!(
|
||||
result.get("isError").and_then(serde_json::Value::as_bool),
|
||||
Some(false),
|
||||
"echo path must report isError: false"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bullet 2 + design-review failure-mode coverage:
|
||||
// * MCP isError:true → Failed (with server message)
|
||||
// * JSON-RPC error response → Failed (with server message)
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 2 + design-review path A: a tool that returns
|
||||
/// `isError: true` produces a Lua-visible Failed outcome with the
|
||||
/// server's text-content as the message. The translation is a
|
||||
/// deliberate API choice (see M9.3 audit) — callers don't have to
|
||||
/// inspect `isError` themselves.
|
||||
#[test]
|
||||
fn m9_3_tool_iserror_translates_to_failed() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("iserror"));
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.invoke_tool(sid, "fail", serde_json::json!({}))
|
||||
.expect("invoke_tool");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let msg = match outcome {
|
||||
JobOutcome::Failed(m) => m,
|
||||
other => panic!("expected Failed (isError -> Failed); got {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
msg.contains("synthetic tool failure"),
|
||||
"Failed message must contain the server's tool error text; got {msg:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Multipart-content tool error: text + non-text + text. Verifies
|
||||
/// the message-extraction rules from the M9.3 audit:
|
||||
/// - Order preserved (no reordering).
|
||||
/// - Non-text parts → `[non-text content omitted]` placeholder.
|
||||
/// - Text parts joined with newlines.
|
||||
#[test]
|
||||
fn m9_3_tool_iserror_extracts_multipart_content() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("multipart"));
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.invoke_tool(sid, "multipart_fail", serde_json::json!({}))
|
||||
.expect("invoke_tool");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let msg = match outcome {
|
||||
JobOutcome::Failed(m) => m,
|
||||
other => panic!("expected Failed; got {other:?}"),
|
||||
};
|
||||
// Content order is `[text("Failed: "), image(...), text("see attached")]`.
|
||||
assert_eq!(
|
||||
msg, "Failed: \n[non-text content omitted]\nsee attached",
|
||||
"multipart extraction must preserve order and replace non-text with placeholder; got {msg:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
/// Design-review path B: a tool that the server doesn't know
|
||||
/// produces a JSON-RPC error response. The standard Failed path
|
||||
/// applies — the message includes the server's error code and text.
|
||||
#[test]
|
||||
fn m9_3_unknown_tool_produces_jsonrpc_error() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("unknown"));
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.invoke_tool(sid, "no_such_tool", serde_json::json!({}))
|
||||
.expect("invoke_tool");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let msg = match outcome {
|
||||
JobOutcome::Failed(m) => m,
|
||||
other => panic!("expected Failed; got {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
msg.contains("unknown tool: no_such_tool"),
|
||||
"JSON-RPC error message must contain the server's text; got {msg:?}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("-32602"),
|
||||
"Failed message should include the JSON-RPC error code; got {msg:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bullet 3: cancellation reaches the server
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 3: a tool invocation cancelled while in flight reaches the
|
||||
/// server as `notifications/cancelled`. The fake's sentinel-file
|
||||
/// mechanism (`PMACS_FAKE_MCP_CANCEL_DIR`) writes
|
||||
/// `cancelled-<request_id>` on receipt, which the test polls for.
|
||||
#[test]
|
||||
fn m9_3_cancellation_reaches_server() {
|
||||
let tmp = tempfile::tempdir().expect("tmpdir");
|
||||
let cancel_dir = tmp.path().to_owned();
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("cancellation");
|
||||
spec.env = vec![
|
||||
("PMACS_FAKE_MCP_MODE".into(), "slow_tools_call".into()),
|
||||
(
|
||||
"PMACS_FAKE_MCP_CANCEL_DIR".into(),
|
||||
cancel_dir.to_string_lossy().into_owned(),
|
||||
),
|
||||
];
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
|
||||
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.invoke_tool(sid, "echo", serde_json::json!({ "text": "slow" }))
|
||||
.expect("invoke_tool");
|
||||
|
||||
// Cancel while the request is in flight (fake delays 250ms).
|
||||
runtime.cancel(job);
|
||||
|
||||
// Pump until the handle settles AND the sentinel file appears.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut sentinel_seen = false;
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
// Sentinel file: `cancelled-<request_id>`. We don't know
|
||||
// the request_id from the test — just look for any matching
|
||||
// file.
|
||||
if let Ok(entries) = std::fs::read_dir(&cancel_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if name.starts_with("cancelled-") {
|
||||
sentinel_seen = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if sentinel_seen && runtime.is_complete(job) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
assert!(
|
||||
sentinel_seen,
|
||||
"fake should have written cancelled-<id> sentinel after notifications/cancelled"
|
||||
);
|
||||
assert!(
|
||||
runtime.is_complete(job),
|
||||
"cancelled handle should have settled"
|
||||
);
|
||||
let outcome = runtime.take_result(job).expect("settled");
|
||||
assert!(
|
||||
matches!(outcome, JobOutcome::Cancelled),
|
||||
"cancelled handle must settle as Cancelled; got {outcome:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lua surface
|
||||
// ===========================================================================
|
||||
|
||||
/// `pmacs.mcp.invoke_tool(server, name, args):await()` resolves to
|
||||
/// the response's `result` table on success, or raises a Lua error
|
||||
/// (via the `tag = "failed"` async error path) on tool failure.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "linear pump-coroutine-then-verify pattern; splitting fragments the test's narrative"
|
||||
)]
|
||||
fn m9_3_lua_invoke_tool_returns_awaitable_handle() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let mut state = EditorState::new();
|
||||
let fake = fake_mcp_path();
|
||||
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
"
|
||||
_G._mcp_test_server = pmacs.mcp.spawn({{
|
||||
label = 'lua-tool',
|
||||
command = '{fake}',
|
||||
restart = 'never',
|
||||
}})
|
||||
",
|
||||
))
|
||||
.exec()
|
||||
.expect("spawn via Lua");
|
||||
|
||||
// Pump until Initialized.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut initialized = false;
|
||||
while Instant::now() < stop && !initialized {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
let kinds: Vec<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local out = {}
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
out[#out+1] = row.state.kind
|
||||
end
|
||||
return out
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("list");
|
||||
if kinds.iter().any(|k| k == "initialized") {
|
||||
initialized = true;
|
||||
}
|
||||
if !initialized {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(initialized, "server must reach Initialized");
|
||||
|
||||
// Happy path: invoke `echo`, capture the text in a global.
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_tool_done = false
|
||||
_G._mcp_tool_text = nil
|
||||
_G._mcp_tool_failed = nil
|
||||
pmacs.async(function()
|
||||
local ok, result = pcall(function()
|
||||
return pmacs.mcp.invoke_tool(_G._mcp_test_server, 'echo', { text = 'lua' }):await()
|
||||
end)
|
||||
if ok then
|
||||
_G._mcp_tool_text = result.content[1].text
|
||||
else
|
||||
_G._mcp_tool_failed = result
|
||||
end
|
||||
_G._mcp_tool_done = true
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("dispatch awaiting coroutine");
|
||||
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done = false;
|
||||
while Instant::now() < stop && !done {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_tool_done")
|
||||
.eval::<bool>()
|
||||
.unwrap_or(false);
|
||||
if !done {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(done, "happy-path coroutine must complete");
|
||||
|
||||
let text: Option<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_tool_text")
|
||||
.eval()
|
||||
.expect("read result text");
|
||||
assert_eq!(text.as_deref(), Some("<echo>: lua"));
|
||||
|
||||
// Failure path: invoke `fail`, expect pcall to capture an error.
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_tool_done2 = false
|
||||
_G._mcp_tool_failed2 = nil
|
||||
pmacs.async(function()
|
||||
local ok, err = pcall(function()
|
||||
return pmacs.mcp.invoke_tool(_G._mcp_test_server, 'fail', {}):await()
|
||||
end)
|
||||
_G._mcp_tool_done2 = true
|
||||
_G._mcp_tool_failed2 = (not ok) and err or nil
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("dispatch failure-path coroutine");
|
||||
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done2 = false;
|
||||
while Instant::now() < stop && !done2 {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done2 = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_tool_done2")
|
||||
.eval::<bool>()
|
||||
.unwrap_or(false);
|
||||
if !done2 {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(done2, "failure-path coroutine must complete");
|
||||
|
||||
// Verify the raised error has the expected `tag = "failed"` /
|
||||
// `message` shape from the async runtime, and that the message
|
||||
// contains the server's text content.
|
||||
let message: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local err = _G._mcp_tool_failed2
|
||||
if err == nil then return '<no error>' end
|
||||
if type(err) == 'table' then return tostring(err.message or '<no message>') end
|
||||
return tostring(err)
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("read error message");
|
||||
assert!(
|
||||
message.contains("synthetic tool failure"),
|
||||
"failure-path coroutine must observe the server's tool error text; got {message:?}"
|
||||
);
|
||||
|
||||
let _ = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.mcp.stop(_G._mcp_test_server)")
|
||||
.exec();
|
||||
}
|
||||
|
|
@ -0,0 +1,496 @@
|
|||
// m9_4_acceptance.rs --- T M9.4 prompt resolution acceptance.
|
||||
|
||||
//! Acceptance tests for T M9.4 (`spec/pmacs-tasks.tex:3951`):
|
||||
//!
|
||||
//! 1. Prompt resolution returns the expected template-with-args.
|
||||
//! 2. Required args missing produce a clear error.
|
||||
//! 3. Prompts with no arguments are also supported.
|
||||
//!
|
||||
//! Plus the wire-shape verification called out during the M9.4
|
||||
//! design review:
|
||||
//!
|
||||
//! 4. Empty-args call sends `arguments: {}` on the wire (not
|
||||
//! omitted, not `null`). The fake's prompt-record mechanism
|
||||
//! writes each request's params to disk; the test parses the
|
||||
//! JSON and asserts the shape.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use pmacs::async_runtime::{AsyncRuntime, JobId, JobOutcome, JobResult, SharedAsyncRuntime};
|
||||
use pmacs::lua_bindings::SharedProcessSupervisor;
|
||||
use pmacs::mcp::{
|
||||
McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
|
||||
McpServerSpec, SharedMcpManager,
|
||||
};
|
||||
use pmacs::process::ProcessSupervisor;
|
||||
|
||||
fn fake_mcp_path() -> String {
|
||||
env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
|
||||
}
|
||||
|
||||
fn make_test_triple() -> (
|
||||
SharedProcessSupervisor,
|
||||
SharedAsyncRuntime,
|
||||
SharedMcpManager,
|
||||
) {
|
||||
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
|
||||
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
|
||||
let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
|
||||
(sup, runtime, mgr)
|
||||
}
|
||||
|
||||
fn fake_spec(label: &str) -> McpServerSpec {
|
||||
let mut spec = McpServerSpec::new(label, fake_mcp_path());
|
||||
spec.restart = McpRestartPolicy::Never;
|
||||
spec
|
||||
}
|
||||
|
||||
fn pump_until<F: FnMut() -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
deadline: Duration,
|
||||
mut pred: F,
|
||||
) {
|
||||
let stop = Instant::now() + deadline;
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
if pred() {
|
||||
return;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
sid: McpServerId,
|
||||
deadline: Duration,
|
||||
pred: F,
|
||||
) -> Vec<McpEvent> {
|
||||
let stop = Instant::now() + deadline;
|
||||
let mut all: Vec<McpEvent> = Vec::new();
|
||||
while Instant::now() < stop {
|
||||
sup.borrow_mut().tick();
|
||||
mgr.borrow_mut().tick();
|
||||
runtime.tick();
|
||||
let mut evs = mgr.borrow_mut().take_events(sid);
|
||||
all.append(&mut evs);
|
||||
if pred(&all) {
|
||||
return all;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
fn spawn_initialized(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
spec: McpServerSpec,
|
||||
) -> McpServerId {
|
||||
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
|
||||
drain_mcp_until(sup, runtime, mgr, sid, Duration::from_secs(5), |evs| {
|
||||
evs.iter()
|
||||
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
|
||||
});
|
||||
assert!(matches!(
|
||||
mgr.borrow().state(sid),
|
||||
Some(McpClientState::Initialized { .. })
|
||||
));
|
||||
sid
|
||||
}
|
||||
|
||||
fn await_job(
|
||||
sup: &SharedProcessSupervisor,
|
||||
runtime: &SharedAsyncRuntime,
|
||||
mgr: &SharedMcpManager,
|
||||
job_id: JobId,
|
||||
deadline: Duration,
|
||||
) -> JobOutcome {
|
||||
pump_until(sup, runtime, mgr, deadline, || runtime.is_complete(job_id));
|
||||
runtime
|
||||
.take_result(job_id)
|
||||
.unwrap_or_else(|| panic!("job {job_id} did not settle within deadline"))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bullet 1: round-trip with args
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 1: `get_prompt` with required args returns messages
|
||||
/// referencing those args (the fake threads them through so the
|
||||
/// test can verify they were transmitted intact).
|
||||
#[test]
|
||||
fn m9_4_get_prompt_round_trip_with_args() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-args"));
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.get_prompt(
|
||||
sid,
|
||||
"code_review",
|
||||
serde_json::json!({
|
||||
"language": "rust",
|
||||
"source": "fn main() {}",
|
||||
}),
|
||||
)
|
||||
.expect("get_prompt");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let result = match outcome {
|
||||
JobOutcome::Complete(JobResult::Json(v)) => v,
|
||||
other => panic!("expected Complete(Json(...)), got {other:?}"),
|
||||
};
|
||||
let description = result
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("description field");
|
||||
assert!(
|
||||
description.contains("rust"),
|
||||
"description must contain the language arg; got {description:?}"
|
||||
);
|
||||
let text = result
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.expect("messages[0].content.text");
|
||||
assert!(
|
||||
text.contains("rust") && text.contains("fn main() {}"),
|
||||
"message text must contain both args; got {text:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bullet 2: missing required arg
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 2: `get_prompt` without a required arg produces a Lua
|
||||
/// error via the standard JSON-RPC `Failed` path. The error
|
||||
/// message includes the server's `-32602 missing required argument:
|
||||
/// <name>` text.
|
||||
#[test]
|
||||
fn m9_4_missing_required_arg_produces_jsonrpc_error() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-missing"));
|
||||
// code_review requires {language, source}; omit source.
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.get_prompt(
|
||||
sid,
|
||||
"code_review",
|
||||
serde_json::json!({ "language": "rust" }),
|
||||
)
|
||||
.expect("get_prompt");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let msg = match outcome {
|
||||
JobOutcome::Failed(m) => m,
|
||||
other => panic!("expected Failed; got {other:?}"),
|
||||
};
|
||||
assert!(
|
||||
msg.contains("missing required argument: source"),
|
||||
"Failed message must contain the server's missing-arg text; got {msg:?}"
|
||||
);
|
||||
assert!(
|
||||
msg.contains("-32602"),
|
||||
"Failed message should include the JSON-RPC error code; got {msg:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Bullet 3: no-args prompt
|
||||
// ===========================================================================
|
||||
|
||||
/// Bullet 3: prompts with no required arguments resolve cleanly
|
||||
/// when called with an empty args object.
|
||||
#[test]
|
||||
fn m9_4_no_args_prompt_resolves() {
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-noargs"));
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.get_prompt(sid, "simple", serde_json::json!({}))
|
||||
.expect("get_prompt");
|
||||
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
let result = match outcome {
|
||||
JobOutcome::Complete(JobResult::Json(v)) => v,
|
||||
other => panic!("expected Complete(Json(...)), got {other:?}"),
|
||||
};
|
||||
let text = result
|
||||
.get("messages")
|
||||
.and_then(|m| m.as_array())
|
||||
.and_then(|arr| arr.first())
|
||||
.and_then(|m| m.get("content"))
|
||||
.and_then(|c| c.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.expect("messages[0].content.text");
|
||||
assert_eq!(text, "no-args prompt body");
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Wire-shape verification (design-review addition)
|
||||
// ===========================================================================
|
||||
|
||||
/// The MCP spec requires `arguments` even when there are no
|
||||
/// arguments — sending it as `{}` (empty object), not `null` and
|
||||
/// not omitting the field. This test verifies the wire shape by
|
||||
/// having the fake record each prompts/get's params to disk;
|
||||
/// the test reads the recorded JSON and asserts `arguments` is
|
||||
/// present and an empty object.
|
||||
#[test]
|
||||
fn m9_4_no_args_wire_shape_is_empty_object() {
|
||||
let tmp = tempfile::tempdir().expect("tmpdir");
|
||||
let record_dir = tmp.path().to_owned();
|
||||
let (sup, runtime, mgr) = make_test_triple();
|
||||
let mut spec = fake_spec("prompt-wire");
|
||||
spec.env = vec![(
|
||||
"PMACS_FAKE_MCP_PROMPT_RECORD_DIR".into(),
|
||||
record_dir.to_string_lossy().into_owned(),
|
||||
)];
|
||||
let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
|
||||
// No-args call.
|
||||
let job = mgr
|
||||
.borrow_mut()
|
||||
.get_prompt(sid, "simple", serde_json::json!({}))
|
||||
.expect("get_prompt");
|
||||
let _ = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
|
||||
|
||||
// Find the recorded params file.
|
||||
let mut entries: Vec<_> = std::fs::read_dir(&record_dir)
|
||||
.expect("read record dir")
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.starts_with("prompt-"))
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by_key(std::fs::DirEntry::file_name);
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
1,
|
||||
"fake should have recorded exactly one prompts/get; got {} files",
|
||||
entries.len()
|
||||
);
|
||||
let bytes = std::fs::read(entries[0].path()).expect("read recorded params");
|
||||
let params: serde_json::Value = serde_json::from_slice(&bytes).expect("parse recorded params");
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.expect("params.arguments must be present (not omitted)");
|
||||
assert!(
|
||||
arguments.is_object(),
|
||||
"params.arguments must be an object, not null/array/string; got {arguments:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
arguments.as_object().unwrap().len(),
|
||||
0,
|
||||
"params.arguments must be empty {{}}; got {arguments:?}"
|
||||
);
|
||||
let _ = mgr.borrow_mut().stop(sid);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Lua surface
|
||||
// ===========================================================================
|
||||
|
||||
/// `pmacs.mcp.get_prompt(server, name, args):await()` resolves to
|
||||
/// the response's `result` table. Three call patterns (no third
|
||||
/// arg, nil third arg, explicit empty table) all produce the same
|
||||
/// wire request and identical result.
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
reason = "linear pump-coroutine-then-verify pattern; splitting fragments the test's narrative"
|
||||
)]
|
||||
fn m9_4_lua_get_prompt_returns_awaitable_handle() {
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
let mut state = EditorState::new();
|
||||
let fake = fake_mcp_path();
|
||||
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
"
|
||||
_G._mcp_test_server = pmacs.mcp.spawn({{
|
||||
label = 'lua-prompt',
|
||||
command = '{fake}',
|
||||
restart = 'never',
|
||||
}})
|
||||
",
|
||||
))
|
||||
.exec()
|
||||
.expect("spawn via Lua");
|
||||
|
||||
// Pump until Initialized.
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut initialized = false;
|
||||
while Instant::now() < stop && !initialized {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
let kinds: Vec<String> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local out = {}
|
||||
for _, row in ipairs(pmacs.mcp.list()) do
|
||||
out[#out+1] = row.state.kind
|
||||
end
|
||||
return out
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("list");
|
||||
if kinds.iter().any(|k| k == "initialized") {
|
||||
initialized = true;
|
||||
}
|
||||
if !initialized {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(initialized, "server must reach Initialized");
|
||||
|
||||
// Three call forms, captured into separate globals. All three
|
||||
// should produce identical result tables.
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_prompt_done = 0
|
||||
_G._mcp_prompt_text_a = nil
|
||||
_G._mcp_prompt_text_b = nil
|
||||
_G._mcp_prompt_text_c = nil
|
||||
pmacs.async(function()
|
||||
local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple'):await()
|
||||
_G._mcp_prompt_text_a = r.messages[1].content.text
|
||||
_G._mcp_prompt_done = _G._mcp_prompt_done + 1
|
||||
end)
|
||||
pmacs.async(function()
|
||||
local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple', nil):await()
|
||||
_G._mcp_prompt_text_b = r.messages[1].content.text
|
||||
_G._mcp_prompt_done = _G._mcp_prompt_done + 1
|
||||
end)
|
||||
pmacs.async(function()
|
||||
local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple', {}):await()
|
||||
_G._mcp_prompt_text_c = r.messages[1].content.text
|
||||
_G._mcp_prompt_done = _G._mcp_prompt_done + 1
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("dispatch coroutines");
|
||||
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done = 0i64;
|
||||
while Instant::now() < stop && done < 3 {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_prompt_done")
|
||||
.eval::<i64>()
|
||||
.unwrap_or(0);
|
||||
if done < 3 {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert_eq!(done, 3, "all three coroutines must complete");
|
||||
|
||||
let texts: Vec<Option<String>> = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return { _G._mcp_prompt_text_a, _G._mcp_prompt_text_b, _G._mcp_prompt_text_c }")
|
||||
.eval()
|
||||
.expect("read texts");
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec![
|
||||
Some("no-args prompt body".to_owned()),
|
||||
Some("no-args prompt body".to_owned()),
|
||||
Some("no-args prompt body".to_owned())
|
||||
],
|
||||
"all three call forms must produce the same result"
|
||||
);
|
||||
|
||||
// Failure path: missing required arg must raise a Lua error
|
||||
// visible through pcall.
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
_G._mcp_prompt_done2 = false
|
||||
_G._mcp_prompt_failed2 = nil
|
||||
pmacs.async(function()
|
||||
local ok, err = pcall(function()
|
||||
return pmacs.mcp.get_prompt(_G._mcp_test_server, 'code_review',
|
||||
{ language = 'rust' }):await()
|
||||
end)
|
||||
_G._mcp_prompt_done2 = true
|
||||
_G._mcp_prompt_failed2 = (not ok) and err or nil
|
||||
end)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("dispatch failure-path coroutine");
|
||||
|
||||
let stop = Instant::now() + Duration::from_secs(5);
|
||||
let mut done2 = false;
|
||||
while Instant::now() < stop && !done2 {
|
||||
state.tick_processes();
|
||||
state.tick_mcp();
|
||||
state.tick_async();
|
||||
done2 = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("return _G._mcp_prompt_done2")
|
||||
.eval::<bool>()
|
||||
.unwrap_or(false);
|
||||
if !done2 {
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
}
|
||||
assert!(done2, "failure-path coroutine must complete");
|
||||
|
||||
let message: String = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
"
|
||||
local err = _G._mcp_prompt_failed2
|
||||
if err == nil then return '<no error>' end
|
||||
if type(err) == 'table' then return tostring(err.message or '<no message>') end
|
||||
return tostring(err)
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("read error message");
|
||||
assert!(
|
||||
message.contains("missing required argument: source"),
|
||||
"failure-path coroutine must observe the server's missing-arg text; got {message:?}"
|
||||
);
|
||||
|
||||
let _ = state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.mcp.stop(_G._mcp_test_server)")
|
||||
.exec();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue