/** * pi-mcp-market — MCP server marketplace for the pi coding agent. * * Entry point: * - `/mcp-market [query]` — always-registered command. * * When pi-mcp-adapter is installed, its `/mcp` command handles `/mcp` * sub-commands; this extension only registers `/mcp-market` so the user * always has a reliable way to open the market panel. * * The panel writes to the global `/mcp.json` — the exact file * pi-mcp-adapter reads — with atomic writes, corruption backup, and * install/uninstall that never touches unrelated entries. */ import type { ExtensionAPI, ExtensionCommandContext, } from "@earendil-works/pi-coding-agent"; import { buildCatalog, loadRemoteRegistry } from "./src/catalog.ts"; import { loadMcpConfig, installMcpServer, uninstallMcpServer, loadRegistries } from "./src/config.ts"; import { isAdapterInstalled } from "./src/registry-detect.ts"; import { createMarketPanel } from "./src/market-panel.ts"; import type { MarketCallbacks, MarketPanelResult, MarketServer, ServerEntry } from "./src/types.ts"; import { t, ti, setLang, getLang } from "./src/i18n.ts"; import { loadLanguage, saveLanguage } from "./src/lang-config.ts"; interface MarketUiContext { ui: { custom( factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (result: T) => void) => unknown, options?: { overlay?: boolean; overlayOptions?: unknown }, ): Promise; }; mode?: string; hasUI?: boolean; reload?: () => Promise; } /** * Open the market panel. Returns true when the user changed mcp.json so the * caller can trigger a reload. */ export async function openMarket(pi: ExtensionAPI, ctx: MarketUiContext, initialQuery?: string): Promise { const hasUI = ctx.hasUI ?? true; if (!hasUI || ctx.mode === "rpc" || ctx.mode === "json" || ctx.mode === "print") { // Headless modes: summarize availability on stdout instead of a panel. const bundle = await buildCatalog(); const names = bundle.servers.map((s) => `${s.name} [${s.source}]`).join(", "); pi.sendMessage({ customType: "mcp-market", content: ti("headless.available", { n: bundle.servers.length, names }), display: true, }); return false; } // Start loading — panel renders immediately, sources load incrementally. const { registries } = loadRegistries(); const sourceNames = registries.map((r) => r.id); const callbacks: MarketCallbacks = { isInstalled: (name) => { try { return name in loadMcpConfig().mcpServers; } catch { return false; } }, getInstalledEntry: (name) => { try { return loadMcpConfig().mcpServers[name]; } catch { return undefined; } }, installServer: async (name: string, entry: ServerEntry) => { installMcpServer(name, entry); }, uninstallServer: async (name: string) => { uninstallMcpServer(name); }, installAdapter: async () => { try { const result = await pi.exec("pi", ["install", "npm:pi-mcp-adapter"]); return result.code === 0; } catch { return false; } }, }; let configChanged = false; await ctx.ui.custom( (tui, _theme, keybindings, done) => { const panel = createMarketPanel( [], callbacks, tui as { requestRender(): void }, (result) => { if (result.action === "installed" || result.action === "uninstalled" || result.action === "adapter-installed") { configChanged = true; } done(result); }, { noticeLines: [], adapterMissing: !isAdapterInstalled(), sourcesConfigured: registries.length > 0, sourceNames, keybindings: keybindings as never, }, ); if (initialQuery) { for (const ch of initialQuery) panel.handleInput(ch); } // Dispatch each registry independently — panel updates as each completes. for (const registry of registries) { void loadRemoteRegistry(registry).then((result) => { panel.applyRegistryResult(registry.id, result.servers, result.failed); }); } // Also show installed servers grouped by config status try { const cfg = loadMcpConfig(); const installed: Array<{ installName: string; entry: ServerEntry; missingEnv: string[] }> = []; for (const [name, entry] of Object.entries(cfg.mcpServers)) { const missing: string[] = []; // Check bearerTokenEnv: set in entry but bearerToken not provided if (entry.bearerTokenEnv && entry.bearerTokenEnv in (entry.env ?? {})) { // has env value -> ok } else if (entry.bearerTokenEnv && !entry.bearerToken) { missing.push(entry.bearerTokenEnv); } installed.push({ installName: name, entry, missingEnv: missing }); } if (installed.length > 0) { panel.applyInstalledServers(installed); } } catch {} return panel; }, { overlay: true, overlayOptions: { anchor: "center", width: 92 } }, ); // After a successful install/uninstall, refresh registered tools when the // context supports it (command handler contexts have reload()). if (configChanged && typeof ctx.reload === "function") { try { await ctx.reload(); } catch { // reload failure is non-fatal; user can run /reload manually } } return configChanged; } export default function (pi: ExtensionAPI): void { if (!isAdapterInstalled()) { pi.sendMessage({ customType: "mcp-market", content: t("adapter.missing"), display: true, }); } // Initialize language from persisted config const savedLang = loadLanguage(); setLang(savedLang); // Register language-switch command (/mcp-market lang zh|en) pi.registerCommand("mcp-market-lang", { description: "Switch market UI language (zh/en)", handler: async (args: string, _ctx) => { const lang = args.trim().toLowerCase(); if (lang === "zh" || lang === "en") { setLang(lang); saveLanguage(lang); pi.sendMessage({ customType: "mcp-market", content: ti("lang.switchedTo", { lang: lang === "zh" ? t("lang.displayName_zh") : t("lang.displayName_en"), }), display: true, }); } else if (lang === "") { pi.sendMessage({ customType: "mcp-market", content: ti("lang.current", { lang: t(`lang.displayName_${getLang()}`) }), display: true, }); } else { pi.sendMessage({ customType: "mcp-market", content: ti("lang.unknown", { lang }), display: true, }); } }, }); // Always-available fallback command. pi.registerCommand("mcp-market", { description: t("market.title") + " — browse, search, install/uninstall MCP servers to global mcp.json", getArgumentCompletions: (prefix: string) => { const q = prefix.trim().toLowerCase(); return [ { value: "install adapter", label: "install adapter", description: "Install pi-mcp-adapter (required to connect MCP servers)" }, { value: "list", label: "list", description: "List available servers (headless mode)" }, { value: "lang", label: "lang [zh|en]", description: "Switch display language" }, ].filter((c) => c.value.toLowerCase().startsWith(q)); }, handler: async (args: string, ctx: ExtensionCommandContext) => { const trimmed = args.trim(); // Route sub-commands if (trimmed.startsWith("lang ") || trimmed === "lang") { const parts = trimmed.split(/\s+/); const langArg = parts[1] ?? ""; const lang = langArg.toLowerCase(); if (lang === "zh" || lang === "en") { setLang(lang); saveLanguage(lang); pi.sendMessage({ customType: "mcp-market", content: ti("lang.switchedTo", { lang: lang === "zh" ? t("lang.displayName_zh") : t("lang.displayName_en"), }), display: true, }); } else { pi.sendMessage({ customType: "mcp-market", content: t("lang.invalid"), display: true, }); } return; } await openMarket(pi, ctx as MarketUiContext, trimmed || undefined); }, }); }