/** * 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(path: string): Promise { 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 { return (await readJson(PiStatePath)) ?? {}; } async function writePiState(state: PiState): Promise { 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 { 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 { const url = `${mcplocalUrl}/api/v1/projects`; const headers: Record = { 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; required?: string[]; items?: unknown; enum?: unknown[]; }; const required = new Set(Array.isArray(s.required) ? s.required : []); const props = s.properties ?? {}; const fields: Record = {}; 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; }; 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; /** Pi tool names currently registered for the active project. */ const projectToolNames = new Set(); 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 { 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 { 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 = piToolName(project, t.name); if (!projectToolNames.has(piName)) { pi.registerTool(createTool(piName, t, project)); projectToolNames.add(piName); } 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 }; } 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) { 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 { content: [{ type: "text", 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 ── pi.registerCommand("mcpctl", { description: "mcpctl integration: status, switch project, refresh tools", handler: async (_args, ctx) => { if (!ctx.hasUI) { return `mcpctl: active project = ${activeProject ?? "none"}; mcplocal = ${mcplocalUrl}`; } const choice = await ctx.ui.select("mcpctl", [ { value: "status", label: "Status" }, { value: "switch", label: "Switch project" }, { value: "refresh", label: "Refresh tools" }, { value: "sync skills", label: "Sync skills (mcpctl skills sync)" }, { value: "close", label: "Close" }, ]); if (!choice) 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 skills") { try { const { execSync } = await import("node:child_process"); execSync(`mcpctl skills sync${activeProject ? ` -p ${activeProject}` : ""} --quiet`, { stdio: "inherit", cwd: process.cwd(), }); ctx.ui.notify("mcpctl skills synced (into ~/.claude/skills)", "info"); } catch (e) { ctx.ui.notify(`skills sync failed: ${(e as Error).message}`, "error"); } } }, }); async function switchProject(ctx: ExtensionContext): Promise { 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(); }); }