pmacs/docs/mcp-for-package-authors.md

595 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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