20 KiB
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. 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/— resourcespmacs-mcp-tools/— tools-as-commandspmacs-mcp-prompts/— prompts-as-result-bufferspmacs-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:
- 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.
- 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.
- 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
commandand optionalenv.
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.
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:
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:
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:
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:
- Success:
isError = false, content has the result. - Semantic failure:
isError = true, content describes the failure ("file not found", "permission denied"). The tool ran; the operation failed. - 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:
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.
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:
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:
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:
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):
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:
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:
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/markdownformats) - 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:
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:
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:
-- 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:
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:
-
Sending requests before
state.kind == "initialized".pmacs.mcp.spawnreturns before the handshake completes. Gate on the roster row's state.kind, or use the wait-and-poll pattern fromtests/m9_8_acceptance.rs'sspawn_initialized_server. -
Not distinguishing
isErrorfrom JSON-RPC error. A tool reportingisError = trueis a normal result. Don'tpcallthe await and treat all failures equally — the user wants different UX for "the tool said no" vs "the server fell over." -
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.
-
Subscribing per-call instead of per-server. If your command subscribes to
list_changedevery invocation, you'll accumulate subscriptions. Subscribe atregistertime, unsubscribe atunregistertime, refcount across servers. -
Reaching past
pmacs.mcp.*to a transport detail. If you find yourself wantingpmacs.mcp._send_raw_jsonrpcor 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— general package mechanics (manifest, addresses, lockfiles, audit lint)TRANSITION-M9.md— M9 milestone summary, deferred items, audit-finding accumulationspec/pmacs-spec.tex§sec:m9-ai — the architectural claim AI is a transport binding, not a featuretests/fixtures/pmacs-mcp-{resources,tools,prompts,ai}/init.lua— the four worked examples