Files
mcpctl/src/pi-ext/mcpctl-pi.ts

466 lines
17 KiB
TypeScript
Raw Normal View History

/**
* 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.
*/
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
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 ────────────────────────────────────────────────────
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[]);
}
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();
}
}
/** 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;
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
/** 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) {
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
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);
}
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
// 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 };
}
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
/**
* 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 {
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
// `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 ──
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
/**
* 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) {
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
// 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;
}
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
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;
}
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
if (choice === "sync") {
try {
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
// `--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);
});
});
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
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");
}
}
},
});
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 ctx.ui.select("Switch to project", 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);
ctx.ui.notify(`Switched to '${picked}': ${r.tools.length} tool(s) ready`, "info");
setStatus();
} catch (e) {
ctx.ui.notify(`Could not load tools for '${picked}': ${(e as Error).message}`, "error");
}
}
// ── 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();
});
}