414 lines
15 KiB
TypeScript
414 lines
15 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.
|
||
|
|
*/
|
||
|
|
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;
|
||
|
|
/** Pi tool names currently registered for the active project. */
|
||
|
|
const projectToolNames = new Set<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 = 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<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 {
|
||
|
|
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<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();
|
||
|
|
});
|
||
|
|
}
|