Files
mcpctl/docs/pi-extension.md
Michal be7fabd467
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
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

7.2 KiB
Raw Blame History

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 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:

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):

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.