From 587a2a15dee62b1ec638308abe9ec53c7343056a Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 9 May 2026 15:04:23 -0400 Subject: [PATCH] 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) --- Cargo.lock | 11 + Cargo.toml | 9 + builtin/commands/default.lua | 80 + builtin/runtime/mcp.lua | 232 ++ docs/mcp-for-package-authors.md | 594 +++++ src/async_runtime.rs | 88 + src/bin/pmacs_fake_mcp.rs | 1830 +++++++++++++ src/editor.rs | 41 + src/highlight.rs | 4 + src/lib.rs | 1 + src/lua_bindings.rs | 690 ++++- src/mcp.rs | 2363 +++++++++++++++++ src/process.rs | 25 + src/syntax.rs | 19 + src/view.rs | 11 + src/window.rs | 9 + src/workers_buffer.rs | 1 + tests/fixtures/pmacs-mcp-ai/init.lua | 461 ++++ tests/fixtures/pmacs-mcp-ai/pmacs.toml | 19 + tests/fixtures/pmacs-mcp-prompts/init.lua | 807 ++++++ tests/fixtures/pmacs-mcp-prompts/pmacs.toml | 6 + tests/fixtures/pmacs-mcp-resources/init.lua | 444 ++++ tests/fixtures/pmacs-mcp-resources/pmacs.toml | 6 + tests/fixtures/pmacs-mcp-resources/view.lua | 248 ++ tests/fixtures/pmacs-mcp-tools/init.lua | 725 +++++ tests/fixtures/pmacs-mcp-tools/pmacs.toml | 6 + tests/m9_1_acceptance.rs | 926 +++++++ tests/m9_2_acceptance.rs | 735 +++++ tests/m9_3_acceptance.rs | 498 ++++ tests/m9_4_acceptance.rs | 496 ++++ tests/m9_5_acceptance.rs | 1277 +++++++++ tests/m9_6_acceptance.rs | 1528 +++++++++++ tests/m9_7_acceptance.rs | 1388 ++++++++++ tests/m9_8_acceptance.rs | 1124 ++++++++ 34 files changed, 16697 insertions(+), 5 deletions(-) create mode 100644 builtin/runtime/mcp.lua create mode 100644 docs/mcp-for-package-authors.md create mode 100644 src/bin/pmacs_fake_mcp.rs create mode 100644 src/mcp.rs create mode 100644 tests/fixtures/pmacs-mcp-ai/init.lua create mode 100644 tests/fixtures/pmacs-mcp-ai/pmacs.toml create mode 100644 tests/fixtures/pmacs-mcp-prompts/init.lua create mode 100644 tests/fixtures/pmacs-mcp-prompts/pmacs.toml create mode 100644 tests/fixtures/pmacs-mcp-resources/init.lua create mode 100644 tests/fixtures/pmacs-mcp-resources/pmacs.toml create mode 100644 tests/fixtures/pmacs-mcp-resources/view.lua create mode 100644 tests/fixtures/pmacs-mcp-tools/init.lua create mode 100644 tests/fixtures/pmacs-mcp-tools/pmacs.toml create mode 100644 tests/m9_1_acceptance.rs create mode 100644 tests/m9_2_acceptance.rs create mode 100644 tests/m9_3_acceptance.rs create mode 100644 tests/m9_4_acceptance.rs create mode 100644 tests/m9_5_acceptance.rs create mode 100644 tests/m9_6_acceptance.rs create mode 100644 tests/m9_7_acceptance.rs create mode 100644 tests/m9_8_acceptance.rs diff --git a/Cargo.lock b/Cargo.lock index ec055db..bbc7f72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 3972503..b9cc3b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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). diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 405a590..68872c3 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -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 } diff --git a/builtin/runtime/mcp.lua b/builtin/runtime/mcp.lua new file mode 100644 index 0000000..ee29096 --- /dev/null +++ b/builtin/runtime/mcp.lua @@ -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 = , fn = }`. 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/ 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 diff --git a/docs/mcp-for-package-authors.md b/docs/mcp-for-package-authors.md new file mode 100644 index 0000000..931798a --- /dev/null +++ b/docs/mcp-for-package-authors.md @@ -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 `