2026-08-08 16:23:02 +01:00
|
|
|
|
# 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/<project>/mcp
|
|
|
|
|
|
Accepts: application/json, text/event-stream
|
|
|
|
|
|
Authorization: Bearer <token>
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
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_<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`
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
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
2026-08-08 17:30:42 +01:00
|
|
|
|
— no Claude involvement. Re-sync later with:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
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.
|
2026-08-08 16:23:02 +01:00
|
|
|
|
|
|
|
|
|
|
## Layout
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
src/pi-ext/
|
|
|
|
|
|
mcpctl-pi.ts # the extension (self-contained)
|
|
|
|
|
|
mcp-http.ts # vendored Streamable-HTTP JSON-RPC client (no deps)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
fix(pi-ext): stop importing @earendil-works/pi-ai at runtime
The extension failed to load outright on older pi installs:
Failed to load extension ".../mcpctl-pi.ts":
Cannot find module '@earendil-works/pi-ai'
pi doesn't resolve an extension's bare specifiers the ordinary way — it
hands jiti a hard-coded alias table built from its own dependencies, and
that table differs between pi distributions. `@earendil-works/pi-coding-
agent` (0.84.1) aliases both the `@earendil-works/*` and legacy
`@mariozechner/*` names; `@mariozechner/pi-coding-agent` (0.73.1) aliases
only the old ones. Neither resolves the other's namespace, so a single
import outside the intersection takes the whole extension down: every
tool, the /mcpctl command, and the status line, all gone.
The only thing we used from pi-ai was `StringEnum`, a six-line wrapper
over `Type.Unsafe`. Inlined as a local `stringEnum` with byte-identical
output, so `typebox` — aliased by every published pi — is now the sole
bare runtime import. The call site also passes `description` through,
which the pi-ai version was silently dropping.
Guarded in tests/config/pi-extension-embed.test.ts: any runtime import in
the embedded sources that isn't `node:`, relative, or typebox now fails.
Verified against both installs with the same active project: 0.73.1
reproduced the error verbatim before the change and loads cleanly after,
and 0.84.1 keeps registering the gate tool exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tsRTqhEC7YYYYaP3cBqo8
2026-08-10 00:15:04 +01:00
|
|
|
|
The extension imports only from pi-bundled packages, so it loads standalone.
|
|
|
|
|
|
|
|
|
|
|
|
### `typebox` is the only bare runtime import
|
|
|
|
|
|
|
|
|
|
|
|
pi does not let an extension resolve modules the ordinary way: it hands jiti a
|
|
|
|
|
|
hard-coded alias table built from its *own* dependencies, and that table is not
|
|
|
|
|
|
the same across pi distributions. The newer `@earendil-works/pi-coding-agent`
|
|
|
|
|
|
aliases both the `@earendil-works/*` and the legacy `@mariozechner/*` names;
|
|
|
|
|
|
older `@mariozechner/pi-coding-agent` installs (0.73.x and earlier) alias only
|
|
|
|
|
|
the `@mariozechner/*` ones. Neither resolves the other's namespace.
|
|
|
|
|
|
|
|
|
|
|
|
So an import of anything outside the intersection kills the *whole* extension on
|
|
|
|
|
|
someone else's pi — every tool, the `/mcpctl` command, the status line — with:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Failed to load extension ".../mcpctl-pi.ts": Cannot find module '@earendil-works/pi-ai'
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
which is exactly what `import { StringEnum } from "@earendil-works/pi-ai"` did.
|
|
|
|
|
|
`typebox` is aliased by every published pi, so it is the only bare specifier
|
|
|
|
|
|
allowed at runtime. Everything else must be a `node:` builtin, a relative path,
|
|
|
|
|
|
an `import type` (erased before jiti resolves anything), or inlined — pi-ai's
|
|
|
|
|
|
`StringEnum` is now a six-line local `stringEnum`. The
|
|
|
|
|
|
`tests/config/pi-extension-embed.test.ts` guard fails the build on a reintroduced
|
|
|
|
|
|
runtime import.
|
|
|
|
|
|
|
|
|
|
|
|
If a user does hit this error, check `type -a pi`: two installs on `$PATH` is the
|
|
|
|
|
|
usual cause, and the extension has to load under whichever one wins.
|
fix(pi): repair the /mcpctl menu, skills target, and typecheck the extension
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
2026-08-08 17:30:42 +01:00
|
|
|
|
|
|
|
|
|
|
## 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 <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`.
|