From 7eec49b9b88c82f9f7a4cc49a9360884996e4b6a Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Fri, 8 May 2026 21:05:09 +0200 Subject: [PATCH] mcp-loader: add /mcp slash command for runtime status + toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors /ext UX (space=stage, enter=apply+reload, esc=cancel) but for MCP servers in the settings.json `mcp` block. Tracks per-server runtime state captured at extension load time so users can see at a glance which servers are running / failed / disabled / remote-skipped / invalid, with tool counts for the running ones. Toggling writes back to settings.json — disabling sets enabled:false, re-enabling removes the explicit key (default is true) to keep the file tidy. Then ctx.reload() picks up the change. Closes the visibility gap surfaced by 'searxng_search isn't in /ext': MCP-provided tools are runtime-spawned, not file-based extensions, so they need their own list view. /mcp fills that hole. --- AGENTS.md | 2 +- README.md | 10 ++ extensions/mcp-loader.ts | 248 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 252 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea27b06..899cef0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ extensions/ notify.ts # Native terminal notification when agent finishes ext-toggle.ts # /ext slash command — list & toggle extensions at runtime todo.ts # `todo` tool for the agent + /todos for the user (copy of upstream example) - mcp-loader.ts # Generic MCP server loader — reads `mcp` block from settings.json + mcp-loader.ts # Generic MCP server loader + /mcp slash command install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/ package.json # pi package manifest — enables `pi install /path` as an alternative README.md # User-facing docs. diff --git a/README.md b/README.md index 2a51df5..2793cea 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,16 @@ Generic MCP server loader. Reads an `mcp` block from `~/.pi/agent/settings.json` **Debug:** set `PI_MCP_LOADER_DEBUG=1` in the environment to surface per-server stderr and connection logs. +**Slash command:** `/mcp` opens a multi-toggle overlay listing every server in the `mcp` block with its runtime status: + +- `running · N tools` — connected, tools registered +- `failed: ` — start handshake threw +- `disabled in settings` — `enabled: false` +- `remote (skipped — v1 stdio only)` — type `remote`, awaiting v2 +- `invalid: ` — malformed config (read-only row) + +UX matches `/ext`: **space** stages a toggle, **enter** writes back to `settings.json` and reloads pi, **esc** cancels. Toggling re-enables a previously-disabled server by removing the explicit `enabled` key (the default is `true`). + Each extension is a TypeScript module loaded by [jiti](https://github.com/unjs/jiti) — no compilation step. See the [pi extensions docs](https://github.com/mariozechner/pi-coding-agent/blob/main/docs/extensions.md) and the [built-in examples](https://github.com/mariozechner/pi-coding-agent/tree/main/examples/extensions) for the API surface. --- diff --git a/extensions/mcp-loader.ts b/extensions/mcp-loader.ts index 9170560..49e7b05 100644 --- a/extensions/mcp-loader.ts +++ b/extensions/mcp-loader.ts @@ -72,13 +72,30 @@ * bespoke extension in place. If you also list mempalace under the `mcp` * block in settings.json the loader will register a parallel set of * tools, which is harmless but redundant. Don't. + * + * Slash command + * + * `/mcp` lists configured MCP servers with their runtime status + * (running / failed / disabled / remote-skipped / invalid) and lets + * you toggle the `enabled` flag in settings.json. Same UX as `/ext`: + * space stages, enter applies + reloads, esc cancels. */ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + type ExtensionAPI, + getSettingsListTheme, +} from "@mariozechner/pi-coding-agent"; +import { + Container, + Key, + matchesKey, + type SettingItem, + SettingsList, +} from "@mariozechner/pi-tui"; import { Type } from "typebox"; // ── MCP types ──────────────────────────────────────────────────────────────── @@ -271,21 +288,57 @@ function namespacedToolName(serverName: string, toolName: string): string { return `${prefix}${toolName}`; } +// ── Settings.json writer ───────────────────────────────────────────────────── + +/** + * Flip the `enabled` flag for a server in settings.json and write back. + * Preserves all other keys verbatim by round-tripping through JSON with + * 2-space indent (matches the file's existing style). + */ +function setServerEnabled(serverName: string, enabled: boolean): void { + const raw = fs.readFileSync(SETTINGS_PATH, "utf8"); + const parsed = JSON.parse(raw); + if (!parsed.mcp || !parsed.mcp[serverName]) { + throw new Error(`server '${serverName}' not in settings.json`); + } + if (enabled) { + // Default is true — clear the explicit flag if present, keep the file tidy. + delete parsed.mcp[serverName].enabled; + } else { + parsed.mcp[serverName].enabled = false; + } + fs.writeFileSync(SETTINGS_PATH, `${JSON.stringify(parsed, null, 2)}\n`, "utf8"); +} + +// ── Per-server runtime state (for /mcp display) ────────────────────────────── + +type ServerRuntimeState = + | { kind: "running"; toolCount: number } + | { kind: "failed"; message: string } + | { kind: "disabled" } + | { kind: "remote-skipped" } + | { kind: "invalid"; message: string }; + // ── Extension entry ────────────────────────────────────────────────────────── export default async function mcpLoaderExtension(pi: ExtensionAPI) { const servers = readMcpServers(); - const serverNames = Object.keys(servers); - if (serverNames.length === 0) { - dlog("no MCP servers configured — nothing to do"); + const runtime = new Map(); + const clients: StdioMcpClient[] = []; + + // Always register /mcp — even when no servers are configured, so users + // discover the surface. Handler reads settings.json fresh on each invoke. + registerMcpCommand(pi, runtime); + + if (Object.keys(servers).length === 0) { + dlog("no MCP servers configured — nothing to load"); return; } - const clients: StdioMcpClient[] = []; - for (const [name, cfg] of Object.entries(servers)) { if (cfg.enabled === false) { dlog(`${name}: disabled in settings.json, skipping`); + runtime.set(name, { kind: "disabled" }); continue; } @@ -293,12 +346,14 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) { warn( `${name}: remote MCP transport not yet supported by mcp-loader (v1 is stdio-only); skipping`, ); + runtime.set(name, { kind: "remote-skipped" }); continue; } const local = cfg as LocalServerConfig; if (!Array.isArray(local.command) || local.command.length === 0) { warn(`${name}: invalid \`command\` (must be non-empty array), skipping`); + runtime.set(name, { kind: "invalid", message: "command must be non-empty array" }); continue; } const [bin, ...args] = local.command; @@ -307,12 +362,15 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) { try { await client.start(bin, args, local.env); } catch (err) { - warn(`${name}: failed to start — ${(err as Error).message}`); + const message = (err as Error).message; + warn(`${name}: failed to start — ${message}`); + runtime.set(name, { kind: "failed", message }); client.stop(); continue; } dlog(`${name}: connected, ${client.tools.length} tools`); + runtime.set(name, { kind: "running", toolCount: client.tools.length }); clients.push(client); // Register each MCP tool as a pi tool. @@ -360,3 +418,179 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) { for (const c of clients) c.stop(); }); } + +// ── /mcp slash command ────────────────────────────────────────────────────── + +const VAL_ON = "● enabled"; +const VAL_OFF = "○ disabled"; +const VAL_RO = "[read-only]"; + +function statusLabel(state: ServerRuntimeState | undefined, configEnabled: boolean): string { + if (!state) return configEnabled ? "unknown" : "disabled"; + switch (state.kind) { + case "running": + return `running · ${state.toolCount} tool${state.toolCount === 1 ? "" : "s"}`; + case "failed": + return `failed: ${state.message}`; + case "disabled": + return "disabled in settings"; + case "remote-skipped": + return "remote (skipped — v1 stdio only)"; + case "invalid": + return `invalid: ${state.message}`; + } +} + +function registerMcpCommand(pi: ExtensionAPI, runtime: Map) { + pi.registerCommand("mcp", { + description: "List and toggle MCP servers configured in ~/.pi/agent/settings.json", + handler: async (_args, ctx) => { + const servers = readMcpServers(); + const names = Object.keys(servers); + if (names.length === 0) { + ctx.ui.notify( + `No MCP servers configured. Add an \`mcp\` block to ${SETTINGS_PATH}.`, + "info", + ); + return; + } + + // Snapshot config-level enabled state (default true if unset). + const configEnabled = new Map(); + for (const [n, cfg] of Object.entries(servers)) { + configEnabled.set(n, cfg.enabled !== false); + } + + // Staged state: name → enabled (initialised from configEnabled). + const staged = new Map(configEnabled); + + await ctx.ui.custom((tui, theme, _kb, done) => { + let statusText = ""; + + const items: SettingItem[] = names.map((n) => { + const cfg = servers[n]; + const cfgEn = configEnabled.get(n) ?? true; + const state = runtime.get(n); + const isRemote = cfg.type === "remote"; + const isInvalid = state?.kind === "invalid"; + const transport = isRemote ? "remote" : "local"; + const desc = `${transport} · ${statusLabel(state, cfgEn)}`; + + if (isInvalid) { + return { + id: n, + label: n, + currentValue: VAL_RO, + description: desc, + }; + } + return { + id: n, + label: n, + currentValue: cfgEn ? VAL_ON : VAL_OFF, + values: [VAL_ON, VAL_OFF], + description: desc, + }; + }); + + const container = new Container(); + + container.addChild({ + render(_w: number) { + return [ + theme.fg("accent", theme.bold("MCP servers — settings.json `mcp` block")), + theme.fg( + "muted", + " space: toggle (stage) · enter: apply + reload · esc: cancel", + ), + "", + ]; + }, + invalidate() {}, + }); + + const settingsList = new SettingsList( + items, + Math.min(items.length + 2, 20), + getSettingsListTheme(), + (id, newValue) => { + const isInvalid = runtime.get(id)?.kind === "invalid"; + if (isInvalid) { + settingsList.updateValue(id, VAL_RO); + statusText = `⊘ ${id}: invalid config — fix in settings.json`; + tui.requestRender(); + return; + } + const stagingEnabled = newValue === VAL_ON; + staged.set(id, stagingEnabled); + + const drift: string[] = []; + for (const n of names) { + const s = staged.get(n); + const c = configEnabled.get(n); + if (s !== undefined && s !== c) drift.push(`${n}→${s ? "on" : "off"}`); + } + statusText = drift.length ? `pending: ${drift.join(", ")}` : ""; + tui.requestRender(); + }, + () => { + done(undefined); + }, + ); + + container.addChild(settingsList); + + container.addChild({ + render(_w: number) { + if (!statusText) return [""]; + const colored = statusText.startsWith("⊘") + ? theme.fg("warning", statusText) + : theme.fg("muted", statusText); + return ["", colored]; + }, + invalidate() {}, + }); + + return { + render(width: number) { + return container.render(width); + }, + invalidate() { + container.invalidate(); + }, + handleInput(data: string) { + if (matchesKey(data, Key.enter)) { + const errors: string[] = []; + let applied = 0; + for (const n of names) { + const s = staged.get(n); + const c = configEnabled.get(n); + if (s === undefined || s === c) continue; + try { + setServerEnabled(n, s); + applied++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`${n}: ${msg}`); + } + } + done(undefined); + if (errors.length) { + ctx.ui.notify(`mcp: ${errors.join(" | ")}`, "error"); + } else if (applied > 0) { + ctx.ui.notify(`mcp: applied ${applied} change(s); reloading…`, "info"); + ctx.reload().catch((err) => { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`reload failed: ${msg}`, "error"); + }); + } + return; + } + settingsList.handleInput?.(data); + tui.requestRender(); + }, + }; + }); + }, + }); +}