Files
mcpctl/src/pi-ext/mcpctl-pi.ts
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

614 lines
24 KiB
TypeScript

/**
* mcpctl pi extension — native integration (no MCP client, no Claude).
*
* Talks JSON-RPC directly to mcplocal's project MCP endpoint and registers
* each project tool as a native pi tool.
*
* KEY ARCHITECTURAL POINT: mcplocal's project *gate* state is bound to a
* `mcp-session-id`. A gated project only exposes `begin_session` until it is
* called, after which the full tool set becomes callable — but only within
* THAT session. So we keep ONE persistent `McpHttpSession` per active project
* and route every tools/call through it, re-listing after each call to pick up
* the post-begin_session tool additions.
*
* Features:
* - project gating auto-ungates when begin_session is called
* - switching the active project from the pi TUI (/mcpctl)
* - a status line
*
* Load standalone: pi -e ./src/pi-ext/mcpctl-pi.ts
* or via settings: "extensions": ["/abs/path/to/mcpctl-pi.ts"]
*
* Only imports pi-bundled packages — no @mcpctl/*, no ~/.claude.
*
* RUNTIME IMPORTS ARE LOAD-BEARING: pi resolves an extension's bare specifiers
* through a fixed alias table in its own loader, and that table differs between
* pi distributions — `@earendil-works/*` exists only in the newer packages,
* while `@mariozechner/*` installs alias only the old names. `typebox` is the
* one specifier every published pi aliases, so it is the ONLY runtime import
* allowed here. Anything else must be `import type` (erased before jiti runs)
* or inlined — see `stringEnum` below.
*/
import { Type, type TSchema } from "typebox";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import {
McpHttpSession,
type ToolInfo,
type McpHttpOptions,
} from "./mcp-http.js";
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join, dirname } from "node:path";
import { homedir } from "node:os";
// ── config / state ───────────────────────────────────────────────────────────
const McpctlDir = join(homedir(), ".mcpctl");
const ConfigPath = join(McpctlDir, "config.json");
const CredPath = join(McpctlDir, "credentials");
const PiStatePath = join(McpctlDir, "pi-state.json");
const ProjectMarker = ".mcpctl-project";
interface PiState {
project?: string;
}
async function readJson<T>(path: string): Promise<T | null> {
try {
return JSON.parse(await readFile(path, "utf-8")) as T;
} catch {
return null;
}
}
async function readConfig(): Promise<{ mcplocalUrl: string }> {
const cfg = await readJson<{ mcplocalUrl?: string }>(ConfigPath);
return { mcplocalUrl: cfg?.mcplocalUrl ?? "http://localhost:3200" };
}
async function readCreds(): Promise<{ token?: string; mcpdUrl?: string } | null> {
try {
const raw = await readFile(CredPath, "utf-8");
const parsed = JSON.parse(raw) as { token?: string; mcpdUrl?: string };
if (!parsed.token) return null;
return parsed;
} catch {
return null;
}
}
async function readPiState(): Promise<PiState> {
return (await readJson<PiState>(PiStatePath)) ?? {};
}
async function writePiState(state: PiState): Promise<void> {
await mkdir(McpctlDir, { recursive: true });
await writeFile(PiStatePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
}
/** Walk up directories looking for a `.mcpctl-project` marker. */
async function findProjectMarker(start = process.cwd()): Promise<string | null> {
const home = homedir();
let dir = start;
for (let i = 0; i < 50; i++) {
try {
const raw = await readFile(join(dir, ProjectMarker), "utf-8");
const project = raw.split("\n")[0]?.trim() ?? "";
if (project) return project;
} catch {
// continue walking
}
if (dir === home || dir === "/") break;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
/** Request the list of projects from mcpd (via mcplocal API passthrough). */
async function listProjects(mcplocalUrl: string, token?: string): Promise<string[]> {
const url = `${mcplocalUrl}/api/v1/projects`;
const headers: Record<string, string> = { Accept: "application/json" };
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`list projects: HTTP ${res.status}`);
const body = (await res.json()) as Array<{ name: string }> | { data?: Array<{ name: string }> };
const arr = Array.isArray(body) ? body : (body.data ?? []);
return arr.map((p) => p.name);
}
// ── JSON Schema → TypeBox ────────────────────────────────────────────────────
/**
* `{ type: "string", enum: [...] }` rather than a union of literals: Google's
* API (and other providers that reject anyOf/const) only accept the flat form.
*
* Inlined from pi-ai's `StringEnum` on purpose — importing it dragged in
* `@earendil-works/pi-ai`, which older pi installs cannot resolve, and the
* whole extension then failed to load. See the import note at the top.
*/
function stringEnum(values: string[], description?: string): TSchema {
return Type.Unsafe<string>({
type: "string",
enum: values,
...(description ? { description } : {}),
});
}
function convertSchema(inputSchema: unknown): TSchema {
if (!inputSchema || typeof inputSchema !== "object") {
return Type.Object({});
}
const s = inputSchema as {
type?: string;
properties?: Record<string, unknown>;
required?: string[];
items?: unknown;
enum?: unknown[];
};
const required = new Set(Array.isArray(s.required) ? s.required : []);
const props = s.properties ?? {};
const fields: Record<string, TSchema> = {};
for (const [name, raw] of Object.entries(props)) {
const schema = convertProp(raw);
fields[name] = required.has(name) ? schema : Type.Optional(schema);
}
return Type.Object(fields);
}
function convertProp(raw: unknown): TSchema {
if (!raw || typeof raw !== "object") return Type.Unknown();
const s = raw as {
type?: string | string[];
enum?: unknown[];
description?: string;
items?: unknown;
properties?: Record<string, unknown>;
};
const desc = typeof s.description === "string" ? s.description : undefined;
const enumVals = Array.isArray(s.enum) && s.enum.length > 0 ? s.enum : undefined;
if (enumVals && enumVals.every((v) => typeof v === "string")) {
return stringEnum(enumVals as string[], desc);
}
if (enumVals && enumVals.every((v) => typeof v === "number")) {
const literals = enumVals.map((v) => Type.Literal(v));
return desc ? Type.Union(literals, { description: desc }) : Type.Union(literals);
}
let type = s.type;
if (Array.isArray(type)) type = type.find((t) => t !== "null");
type = type ?? (s.properties ? "object" : s.items ? "array" : "string");
if (type === "integer") type = "number";
switch (type) {
case "string":
return desc ? Type.String({ description: desc }) : Type.String();
case "number":
return desc ? Type.Number({ description: desc }) : Type.Number();
case "boolean":
return desc ? Type.Boolean({ description: desc }) : Type.Boolean();
case "array": {
const inner = s.items ? convertProp(s.items) : Type.Unknown();
return desc ? Type.Array(inner, { description: desc }) : Type.Array(inner);
}
case "object":
return convertSchema(s as unknown);
default:
return desc ? Type.Unknown({ description: desc }) : Type.Unknown();
}
}
/**
* Order and filter the project list for the picker.
*
* Ordering: the active project first (most likely pick), then alphabetical.
* Filtering: space-separated terms, ALL of which must appear as
* case-insensitive substrings — so `home auto` finds `homeautomation`. A blank
* query keeps everything.
*
* Exported so the behaviour is unit-tested rather than eyeballed through a TUI.
*/
export function filterProjects(projects: string[], query: string, active: string | null): string[] {
const ordered = [...projects].sort((a, b) => {
if (a === active) return -1;
if (b === active) return 1;
return a.localeCompare(b);
});
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length > 0);
if (terms.length === 0) return ordered;
return ordered.filter((p) => {
const name = p.toLowerCase();
return terms.every((t) => name.includes(t));
});
}
/**
* The message injected into the conversation after a project switch, telling
* the model which mcpctl tools are live now.
*
* Exported so its wording is unit-tested; it is the only thing standing between
* the model and a stale tool list it will otherwise keep calling.
*/
export function toolChangeAnnouncement(project: string, tools: string[]): string {
const gate = tools.find((n) => n.endsWith("_begin_session"));
const parts = [
`[mcpctl] The active project is now '${project}'.`,
"mcpctl tool names listed earlier in this conversation belong to the previously active project and are no longer callable — ignore them.",
tools.length > 0
? `Currently available mcpctl tools: ${tools.join(", ")}.`
: "No mcpctl tools are currently available for this project.",
];
if (gate !== undefined) {
parts.push(`This project is gated: call ${gate} first and its remaining tools become available.`);
}
return parts.join(" ");
}
/** Sanitize a name for use as a pi tool name segment ([a-z0-9_]). */
function safeSegment(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || "x";
}
function slugify(name: string): string {
return safeSegment(name);
}
/** pi tool name for a project + MCP tool (e.g. `mc_docmost_docmost_create_page`). */
function piToolName(project: string, mcpTool: string): string {
const proj = slugify(project);
const tool = mcpTool.replace(/^[a-z0-9_-]+\//i, (m) => slugify(m.slice(0, -1)) + "_") || mcpTool;
return `mc_${proj}_${safeSegment(tool)}`;
}
// ── the extension ────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
let mcplocalUrl = "http://localhost:3200";
let token: string | undefined;
let activeProject: string | null = null;
/** Persistent session for the active project — MUST be reused to keep gate state. */
let session: McpHttpSession | null = null;
/** Registered pi tool name → the MCP tool it forwards to, for the active project. */
const projectToolNames = new Map<string, string>();
let activeCtx: ExtensionContext | null = null;
function endpointUrl(): string {
return `${mcplocalUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(activeProject ?? "")}/mcp`;
}
/** Build (or reuse) the persistent session for the active project. */
async function ensureSession(): Promise<McpHttpSession> {
if (session) return session;
const opts: McpHttpOptions = { bearer: token, timeoutMs: 120_000 };
const sess = new McpHttpSession(endpointUrl(), opts);
await sess.initialize();
await sess.sendNotification("notifications/initialized");
session = sess;
return sess;
}
async function closeSession(): Promise<void> {
const s = session;
session = null;
if (s) await s.close();
}
/**
* (Re)discover the active project's current tool set and register any new pi
* tools. Called on load and after every tool call (to pick up ungating).
*/
async function reconcileTools(project: string): Promise<{ tools: string[] }> {
const sess = await ensureSession();
const { tools } = await sess.listTools();
const names: string[] = [];
for (const t of tools) {
const piName = uniqueToolName(project, t.name);
if (piName === null) continue; // already registered for this same MCP tool
pi.registerTool(createTool(piName, t, project));
projectToolNames.set(piName, t.name);
names.push(piName);
}
// Names already registered for tools still in the list stay active.
for (const [piName, mcpName] of projectToolNames) {
if (!names.includes(piName) && tools.some((t) => t.name === mcpName)) names.push(piName);
}
// Activate built-ins/other extensions + all tools discovered in this project.
const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_"));
pi.setActiveTools([...new Set([...active, ...names])]);
return { tools: names };
}
/**
* The pi tool name to register `mcpTool` under, or null if it is already
* registered. Sanitising MCP names down to `[a-z0-9_]` can collide (e.g.
* `docs.search` and `docs-search`); without a suffix the second tool would
* silently never be registered while still being reported as available, and
* its calls would be forwarded to the first tool instead.
*/
function uniqueToolName(project: string, mcpTool: string): string | null {
const base = piToolName(project, mcpTool);
if (projectToolNames.get(base) === mcpTool) return null;
if (!projectToolNames.has(base)) return base;
for (let i = 2; i < 100; i++) {
const candidate = `${base}_${String(i)}`;
if (projectToolNames.get(candidate) === mcpTool) return null;
if (!projectToolNames.has(candidate)) return candidate;
}
return null;
}
function createTool(piName: string, t: ToolInfo, project: string) {
const mcpName = t.name;
return {
name: piName,
label: `${project} :: ${mcpName}`,
description: t.description ?? `mcpctl tool ${mcpName} on project ${project}`,
parameters: convertSchema(t.inputSchema),
async execute(_toolCallId: string, params: Record<string, unknown>) {
const sess = await ensureSession();
const res = await sess.callTool(mcpName, params ?? {});
// Re-list after every call — picks up post-begin_session tool additions.
try {
await reconcileTools(project);
} catch {
// non-fatal
}
const text = (res.content ?? [])
.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("\n");
if (res.isError) {
throw new Error(text || `${mcpName} returned an error`);
}
return {
// `type` must be the literal "text" — pi's AgentToolResult content is
// a TextContent | ImageContent union, not { type: string }.
content: [{ type: "text" as const, text: text || "(no text content)" }],
details: { mcpTool: mcpName, project, raw: res },
};
},
};
}
function setStatus(msg?: string): void {
try {
activeCtx?.ui?.setStatus?.("mcpctl", msg ?? (activeProject ? `project: ${activeProject}` : "no project"));
} catch {
/* non-fatal */
}
}
// ── /mcpctl command ──
/**
* Menu labels → actions. `ctx.ui.select` takes plain strings and returns the
* chosen string, so the mapping has to live here rather than in the options.
*/
const MENU: ReadonlyArray<readonly [label: string, action: string]> = [
["Status", "status"],
["Switch project", "switch"],
["Refresh tools", "refresh"],
["Sync skills into pi's skills dir", "sync"],
["Close", "close"],
];
pi.registerCommand("mcpctl", {
description: "mcpctl integration: status, switch project, refresh tools",
handler: async (_args, ctx) => {
if (!ctx.hasUI) {
// A command handler resolves to void — pi ignores a returned string, so
// headless status has to go through notify.
ctx.ui.notify(
`mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`,
"info",
);
return;
}
const picked = await ctx.ui.select("mcpctl", MENU.map(([label]) => label));
if (!picked) return;
const choice = MENU.find(([label]) => label === picked)?.[1];
if (!choice || choice === "close") return;
if (choice === "status") {
const cs = await readConfig();
const creds = await readCreds();
const info =
`mcplocal: ${cs.mcplocalUrl}\n` +
`user: ${creds?.mcpdUrl ?? "not logged in"}\n` +
`active project: ${activeProject ?? "none"}\n` +
`tools registered: ${projectToolNames.size}\n` +
` ${[...projectToolNames].join("\n ")}\n`;
await ctx.ui.editor("mcpctl status", info);
return;
}
if (choice === "switch") {
await switchProject(ctx);
return;
}
if (choice === "refresh") {
if (!activeProject) {
ctx.ui.notify("No active project — switch project first", "warning");
return;
}
try {
const r = await reconcileTools(activeProject);
ctx.ui.notify(`Refreshed: ${r.tools.length} tools for '${activeProject}'`, "info");
setStatus();
} catch (e) {
ctx.ui.notify(`Refresh failed: ${(e as Error).message}`, "error");
}
return;
}
if (choice === "sync") {
try {
// `--agent pi` is what keeps skills in ~/.pi/agent/skills; without it
// this syncs into ~/.claude/skills, which the whole point of this
// integration is to avoid depending on.
const args = ["skills", "sync", "--agent", "pi", "--quiet"];
if (activeProject) args.push("-p", activeProject);
// execFile, not execSync+stdio:"inherit": a shell would need the
// project name quoted, and inherited stdio writes raw output over
// pi's TUI. Capture instead and report through notify.
const { execFile } = await import("node:child_process");
const stderr = await new Promise<string>((resolve, reject) => {
execFile("mcpctl", args, { timeout: 120_000 }, (err, _stdout, errOut) => {
if (err) reject(new Error((errOut || err.message).trim()));
else resolve(errOut);
});
});
const detail = stderr.trim().split("\n").pop();
ctx.ui.notify(
`mcpctl skills synced into pi's skills dir${detail ? `${detail}` : ""}. Run /reload to pick them up.`,
"info",
);
} catch (e) {
ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error");
}
}
},
});
/** Above this many projects, arrowing through the list stops being usable. */
const FILTER_THRESHOLD = 20;
/**
* Hard cap on rendered rows. pi's ExtensionSelectorComponent.updateList()
* renders *every* option with no windowing, so an uncapped list scrolls the
* whole screen away. prime-agent's selector windows to ~20 itself; this
* matches that height so both hosts feel the same.
*/
const MAX_SHOWN = 20;
/**
* Choose a project, asking for a filter first when the list is long.
*
* pi's selector is a plain arrow-key list — `ExtensionUIDialogOptions` has no
* search and `ExtensionSelectorComponent` ignores typed characters — so the
* filtering has to happen before the list is handed over. Real installs run
* to hundreds of projects (smoke-test leftovers included), where scrolling is
* hopeless.
*
* Terms are space-separated and ALL must match, case-insensitively, as
* substrings: `home auto` finds `homeautomation`. Blank shows everything.
*/
async function pickProject(ctx: ExtensionContext, projects: string[]): Promise<string | undefined> {
let candidates = filterProjects(projects, "", activeProject);
if (candidates.length > FILTER_THRESHOLD) {
const query = await ctx.ui.input(
`Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,
"e.g. home auto",
);
if (query === undefined) return undefined; // cancelled
candidates = filterProjects(projects, query, activeProject);
if (candidates.length === 0) {
ctx.ui.notify(`No project matches '${query}'`, "warning");
return undefined;
}
}
const truncated = candidates.length > MAX_SHOWN;
const shown = truncated ? candidates.slice(0, MAX_SHOWN) : candidates;
const title = truncated
// Say what was dropped: a silently capped list reads as "that's all of them".
? `Switch to project (${String(MAX_SHOWN)} of ${String(candidates.length)} matches — narrow the filter)`
: `Switch to project (${String(shown.length)})`;
return ctx.ui.select(title, shown);
}
async function switchProject(ctx: ExtensionContext): Promise<void> {
let projects: string[] = [];
try {
projects = await listProjects(mcplocalUrl, token);
} catch (e) {
ctx.ui.notify(`Failed to list projects: ${(e as Error).message}`, "error");
return;
}
if (projects.length === 0) {
ctx.ui.notify("No projects returned by mcpd", "warning");
return;
}
const picked = await pickProject(ctx, projects);
if (!picked) return;
// Tear down old project's session + active tools.
await closeSession();
const active = pi.getActiveTools().filter((n) => !n.startsWith("mc_"));
pi.setActiveTools([...active]);
projectToolNames.clear();
activeProject = picked;
await writePiState({ project: picked });
try {
const r = await reconcileTools(picked);
const gate = r.tools.find((n) => n.endsWith("_begin_session"));
ctx.ui.notify(
`Switched to '${picked}': ${String(r.tools.length)} tool(s) ready${gate ? ` — gated, call ${gate} to unlock the rest` : ""}`,
"info",
);
setStatus();
announceToolChange(picked, r.tools);
} catch (e) {
ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error");
}
}
/**
* Tell the *model* that the tool set changed.
*
* pi cannot unregister a tool — `registerTool` only ever adds to the
* extension's tool map — so the previous project's `mc_*` tools stay
* registered and merely go inactive. Meanwhile the conversation still
* contains the old project's tool list, so the model keeps calling names that
* now answer "Tool ... not found" and concludes it has lost MCP access
* entirely, which is what happens in practice on the second switch.
*
* A custom message is converted to a user-role message and does reach the
* LLM (unlike `appendEntry`, which is explicitly excluded from context), so
* this corrects the stale context instead of leaving the model to guess.
* `display: false` keeps it out of the transcript — the notify above is what
* the human reads.
*/
function announceToolChange(project: string, tools: string[]): void {
try {
pi.sendMessage(
{ customType: "mcpctl-project-switch", content: toolChangeAnnouncement(project, tools), display: false },
{ triggerTurn: false, deliverAs: "nextTurn" },
);
} catch {
// Older pi without sendMessage — the notify above still informs the user.
}
}
// ── session lifecycle ──
pi.on("session_start", async (_event, ctx) => {
activeCtx = ctx;
try { ctx.ui?.setStatus?.("mcpctl", "connecting..."); } catch { /* ignore */ }
const cfg = await readConfig();
mcplocalUrl = cfg.mcplocalUrl;
const creds = await readCreds();
token = creds?.token ?? undefined;
const piState = await readPiState();
const project = piState.project ?? (await findProjectMarker());
if (!project) {
try { ctx.ui?.setStatus?.("mcpctl", "no project (use /mcpctl)"); } catch { /* ignore */ }
return;
}
activeProject = project;
try {
const r = await reconcileTools(project);
if (ctx.hasUI) ctx.ui.notify(`mcpctl: project '${project}' — ${r.tools.length} tool(s) ready`, "info");
setStatus();
} catch (e) {
if (ctx.hasUI) ctx.ui.notify(`mcpctl: could not load tools — ${(e as Error).message}`, "error");
}
});
pi.on("session_shutdown", () => {
void closeSession();
});
}