The pi extension shipped in `src/pi-ext/` was covered by no tsconfig and no
eslint config, so nothing ever checked it against pi's API. Pointing tsc at
the published @earendil-works/pi-coding-agent types found the command surface
to be inert.
Fixes:
- `/mcpctl` did nothing. `ctx.ui.select` takes `string[]` and returns the
chosen string; it was called with `{value,label}` objects, so the menu
rendered five `[object Object]` rows and `choice === "status"` never
matched any branch. Labels are now plain strings mapped back to actions.
- The headless branch returned a status string from a handler typed
`Promise<void>`; pi drops it. Reports via notify instead.
- "Sync skills" omitted `--agent pi`, writing into ~/.claude/skills — in an
integration whose stated purpose is to not depend on ~/.claude — and said
so in its own success message. It also ran execSync with `stdio: "inherit"`,
painting raw output over pi's TUI, and interpolated the project name into a
shell string. Now execFile with `--agent pi` and captured output.
- Tool results typed `content[].type` as `string`; pi's AgentToolResult wants
the `"text"` literal.
- `callTool` asserted `Promise<unknown>` to `ToolCallResult`.
- Sanitising MCP tool names to `[a-z0-9_]` can collide (`docs.search` vs
`docs-search`). The colliding tool was silently never registered but still
reported active, so its calls were forwarded to the first tool. Names are
now disambiguated and tracked with the MCP tool they forward to.
- `registerWithPi` rewrote settings.json even when nothing changed. Since
parsing strips `//` comments, a no-op run destroyed them.
Guards, so this class of bug can't return:
- `src/pi-ext/tsconfig.json` checks the extension against the real published
pi types (dev dependency, not a shim — a shim drifting from the published
API is the exact failure being guarded). Wired into `pnpm typecheck`.
- eslint now covers `src/pi-ext/*.ts` like every other source file.
- A test fails if the embedded copy in `config/pi-extension.ts` is stale;
editing the sources without regenerating silently shipped old code.
Also: the branch added `config pi` without regenerating shell completions
(the committed-completions test was failing), and the doc advertised
`mcpctl pi sync-skills`, which does not exist. Both corrected, plus a note
on the session-token vs `mcpctl_pat_` bearer difference that would bite
against an authenticated `mcplocal serve`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
5.9 KiB
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:
- a
.mcp.jsonentry runningmcpctl mcp -p X(an MCP stdio bridge), and - a
SessionStarthook +mcpctl skills syncthat 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
and loads
SKILL.mdtrees (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/<project>/mcp
Accepts: application/json, text/event-stream
Authorization: Bearer <token>
The protocol is JSON-RPC over HTTP:
initialize→ returns anmcp-session-idheadernotifications/initializedtools/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_<project-slug>_<mcp-tool-name>. 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:
mcpctl skills sync --agent pi --project <name>
--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):
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:
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 <token-name> --project <name>, 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.