Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m19s
CI/CD / test (pull_request) Successful in 1m23s
CI/CD / lint (pull_request) Successful in 3m10s
CI/CD / smoke (pull_request) Failing after 10m42s
CI/CD / build (pull_request) Failing after 13m32s
CI/CD / publish (pull_request) Has been skipped
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
175 lines
7.2 KiB
Markdown
175 lines
7.2 KiB
Markdown
# 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`
|
||
— 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.
|
||
|
||
## 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, 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.
|
||
|
||
## 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`.
|