mcp-loader: add /mcp slash command for runtime status + toggle

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.
This commit is contained in:
2026-05-08 21:05:09 +02:00
parent 141bf64d81
commit 7eec49b9b8
3 changed files with 252 additions and 8 deletions
+241 -7
View File
@@ -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<string, ServerRuntimeState>();
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<string, ServerRuntimeState>) {
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<string, boolean>();
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<void>((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();
},
};
});
},
});
}