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:
@@ -23,7 +23,7 @@ extensions/
|
|||||||
notify.ts # Native terminal notification when agent finishes
|
notify.ts # Native terminal notification when agent finishes
|
||||||
ext-toggle.ts # /ext slash command — list & toggle extensions at runtime
|
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)
|
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/
|
install.sh # Idempotent installer — symlinks extensions/ into ~/.pi/agent/extensions/
|
||||||
package.json # pi package manifest — enables `pi install /path` as an alternative
|
package.json # pi package manifest — enables `pi install /path` as an alternative
|
||||||
README.md # User-facing docs.
|
README.md # User-facing docs.
|
||||||
|
|||||||
@@ -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.
|
**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: <message>` — start handshake threw
|
||||||
|
- `disabled in settings` — `enabled: false`
|
||||||
|
- `remote (skipped — v1 stdio only)` — type `remote`, awaiting v2
|
||||||
|
- `invalid: <message>` — 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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+241
-7
@@ -72,13 +72,30 @@
|
|||||||
* bespoke extension in place. If you also list mempalace under the `mcp`
|
* bespoke extension in place. If you also list mempalace under the `mcp`
|
||||||
* block in settings.json the loader will register a parallel set of
|
* block in settings.json the loader will register a parallel set of
|
||||||
* tools, which is harmless but redundant. Don't.
|
* 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 { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
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";
|
import { Type } from "typebox";
|
||||||
|
|
||||||
// ── MCP types ────────────────────────────────────────────────────────────────
|
// ── MCP types ────────────────────────────────────────────────────────────────
|
||||||
@@ -271,21 +288,57 @@ function namespacedToolName(serverName: string, toolName: string): string {
|
|||||||
return `${prefix}${toolName}`;
|
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 ──────────────────────────────────────────────────────────
|
// ── Extension entry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
||||||
const servers = readMcpServers();
|
const servers = readMcpServers();
|
||||||
const serverNames = Object.keys(servers);
|
const runtime = new Map<string, ServerRuntimeState>();
|
||||||
if (serverNames.length === 0) {
|
const clients: StdioMcpClient[] = [];
|
||||||
dlog("no MCP servers configured — nothing to do");
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients: StdioMcpClient[] = [];
|
|
||||||
|
|
||||||
for (const [name, cfg] of Object.entries(servers)) {
|
for (const [name, cfg] of Object.entries(servers)) {
|
||||||
if (cfg.enabled === false) {
|
if (cfg.enabled === false) {
|
||||||
dlog(`${name}: disabled in settings.json, skipping`);
|
dlog(`${name}: disabled in settings.json, skipping`);
|
||||||
|
runtime.set(name, { kind: "disabled" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,12 +346,14 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
|||||||
warn(
|
warn(
|
||||||
`${name}: remote MCP transport not yet supported by mcp-loader (v1 is stdio-only); skipping`,
|
`${name}: remote MCP transport not yet supported by mcp-loader (v1 is stdio-only); skipping`,
|
||||||
);
|
);
|
||||||
|
runtime.set(name, { kind: "remote-skipped" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const local = cfg as LocalServerConfig;
|
const local = cfg as LocalServerConfig;
|
||||||
if (!Array.isArray(local.command) || local.command.length === 0) {
|
if (!Array.isArray(local.command) || local.command.length === 0) {
|
||||||
warn(`${name}: invalid \`command\` (must be non-empty array), skipping`);
|
warn(`${name}: invalid \`command\` (must be non-empty array), skipping`);
|
||||||
|
runtime.set(name, { kind: "invalid", message: "command must be non-empty array" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const [bin, ...args] = local.command;
|
const [bin, ...args] = local.command;
|
||||||
@@ -307,12 +362,15 @@ export default async function mcpLoaderExtension(pi: ExtensionAPI) {
|
|||||||
try {
|
try {
|
||||||
await client.start(bin, args, local.env);
|
await client.start(bin, args, local.env);
|
||||||
} catch (err) {
|
} 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();
|
client.stop();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
dlog(`${name}: connected, ${client.tools.length} tools`);
|
dlog(`${name}: connected, ${client.tools.length} tools`);
|
||||||
|
runtime.set(name, { kind: "running", toolCount: client.tools.length });
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
|
|
||||||
// Register each MCP tool as a pi tool.
|
// 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();
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user