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