# mcpctl × pi — native integration (no MCP, no Claude) ## Motivation With Claude Code, `mcpctl config claude --project X` wires a project's MCP servers into the agent two ways: 1. a `.mcp.json` entry running `mcpctl mcp -p X` (an MCP **stdio bridge**), and 2. a `SessionStart` hook + `mcpctl skills sync` that materialises server-side **skills** under `~/.claude/skills/`. Claude Code supports MCP natively, so tools travel over MCP. **pi does not support MCP.** But pi has two native mechanisms that cover the same ground: - **Extensions** — TypeScript modules that can register tools, commands, and custom UI, and can talk HTTP directly. - **Skills** — pi implements the [Agent Skills standard](https://agentskills.io/specification) and loads `SKILL.md` trees (the exact format mcpctl already syncs). This addon exploits both so mcpctl works with **pi only**, with **no MCP** and **no dependency on `~/.claude/`** (so it keeps working if you drop Claude). ## How it works The MCP layer is not magic. `mcpctl mcp` is just a JSON-RPC ↔ stdio bridge that forwards to mcplocal's Streamable-HTTP endpoint: ``` POST {mcplocalUrl}/projects//mcp Accepts: application/json, text/event-stream Authorization: Bearer ``` The protocol is JSON-RPC over HTTP: - `initialize` → returns an `mcp-session-id` header - `notifications/initialized` - `tools/list` → `{ tools: [{ name, description, inputSchema }] }` - `tools/call` → `{ name, arguments }` → `{ content, isError }` The pi extension is a **direct JSON-RPC client** to that same endpoint. It replaces the MCP transport with a plain function call, so pi never needs an MCP client library. ### Gating Some projects are *gated*: until `begin_session` is called, `tools/list` returns only `begin_session`. After it is called the full tool set appears. **Critical subtlety:** mcplocal's gate state is bound to a `mcp-session-id`. A fresh session is gated again even after another session was ungated — so the extension keeps **one persistent `McpHttpSession` per active project** and routes every `tools/call` through it. After each call it re-runs `tools/list` and reconciles the active pi tool set, so calling `begin_session` automatically opens the gate and reveals the rest — no push listener required (mcplocal's HTTP responses are request/response, not a persistent push stream). ## Deliverables | Artifact | Purpose | |----------|---------| | `src/pi-ext/mcpctl-pi.ts` | Self-contained pi extension (load via `pi -e` or settings `extensions`) | | `mcpctl config pi` | CLI wiring: registers the extension with pi + initial skill sync | | `docs/pi-extension.md` | This document | ## Project switching The active project is persisted in `~/.mcpctl/pi-state.json` (settable, immune to your shell's cwd) and is also inferred from a `.mcpctl-project` marker walk-up. `/mcpctl` opens an interactive menu; **switch project** lists projects via `ctx.ui.select()` and reloads the tool set for the new project, namespaced so projects never collide. ### Tool naming To allow multiple projects and avoid schema collisions, registered pi tools are namespaced: `mc__`. Only the **current project's** tools are in the active set at any time. ## Skills Skills already sync in the Agent Skills `SKILL.md` format. `mcpctl config pi` (when given a project) writes them under pi's own directory `~/.pi/agent/skills/` and registers that directory in `~/.pi/agent/settings.json` — no Claude involvement. Re-sync later with: ```bash mcpctl skills sync --agent pi --project ``` `--agent pi` is what keeps skills out of `~/.claude/skills`; the default target is still Claude Code. The `/mcpctl` menu's **sync skills** action runs exactly this command. Sync state is tracked separately per agent (`~/.mcpctl/skills-state-pi.json`), so pi and Claude never fight over the same bookkeeping. ## Layout ``` src/pi-ext/ mcpctl-pi.ts # the extension (self-contained) mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps) ``` The extension imports only from pi-bundled packages (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-ai`, `typebox`), so it loads standalone. ## Typechecking The extension ships as *source*: it is embedded into the CLI (`src/cli/src/config/pi-extension.ts`, generated by `scripts/generate-pi-extension.ts`) and written verbatim into `~/.pi/agent/extensions/mcpctl/`. The CLI's own build never compiles it, so without a dedicated project nothing would check it against pi's API — which is how a `ctx.ui.select()` call with the wrong option shape shipped in the first place. `src/pi-ext/tsconfig.json` closes that gap, checking against the **real** published `@earendil-works/pi-coding-agent` types (a dev dependency, not a hand-written shim — a shim that drifts from the published API is the exact failure this guards against): ```bash pnpm run typecheck # includes typecheck:pi-ext ``` After editing `src/pi-ext/*.ts`, regenerate the embedded copy or the CLI will keep installing the old sources: ```bash npx tsx scripts/generate-pi-extension.ts ``` A test (`tests/config/pi-extension-embed.test.ts`) fails if you forget. ## Authentication caveat The extension sends the mcpd session token from `~/.mcpctl/credentials` as its bearer. A locally running `mcpctl-local` daemon does not authenticate `/projects/*`, so this works — the header is simply ignored. `mcplocal serve` is different: it registers a token-auth preHandler that accepts **only** `mcpctl_pat_` bearers (project mcptokens), and rejects a session token with `401 Only mcpctl_pat_ bearers are accepted on this endpoint`. Pointing the extension at an authenticated `mcplocal serve` therefore needs a project mcptoken (`mcpctl create mcptoken --project `, printed once). Wiring that token through to the extension is not yet implemented — the `config prime-agent` path does the equivalent by storing it in `~/.prime/agent/auth.json`.