# 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 `