/** * MCP Bridge Extension * * WHAT: Pi has no built-in MCP. This bridge reads config/mcp.json, and for each * ENABLED server launches it (stdio) via the MCP SDK and registers its * tools into pi. URLs come from config/company.json ({config:KEY}), tokens * from the environment ({env:VAR}) — nothing hardcoded. All servers are * disabled by default; `/mcp-status` shows their state. * * ENABLE: set a server's "enabled": true in config/mcp.json, provide its token * env var, install the MCP SDK once (`npm i @modelcontextprotocol/sdk` in * the package, or globally) and the launcher (uvx/npx), then restart pi. * Requires network access to the corporate services (VPN). * * DISABLE: keep servers disabled, or remove/rename this file. */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const MCP_CONFIG = path.join(HERE, "..", "config", "mcp.json"); const COMPANY_CONFIG = path.join(HERE, "..", "config", "company.json"); const CONNECT_TIMEOUT_MS = 8000; interface ServerSpec { enabled?: boolean; description?: string; command: string; args?: string[]; env?: Record; } type Status = "disabled" | "connected" | "error" | "no-sdk" | "missing-env"; interface ServerState { description: string; status: Status; tools: string[]; detail?: string; } function readJson(file: string): Record { try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return {}; } } /** Replace {config:KEY} and {env:VAR} tokens; returns null if any resolve empty. */ function resolveEnv( env: Record, company: Record, ): Record | null { const out: Record = {}; for (const [key, template] of Object.entries(env)) { let missing = false; const value = template.replace(/\{(config|env):([^}]+)\}/g, (_m, kind, name) => { const v = kind === "config" ? company[name] : process.env[name]; if (typeof v !== "string" || v === "" || v === "TODO") missing = true; return typeof v === "string" ? v : ""; }); if (missing) return null; out[key] = value; } return out; } export default async function mcpBridgeExtension(pi: ExtensionAPI) { const mcpCfg = readJson(MCP_CONFIG); const company = readJson(COMPANY_CONFIG); const servers = (mcpCfg.servers ?? {}) as Record; const state = new Map(); const closers: Array<() => Promise | void> = []; // Seed status for every configured server. for (const [key, spec] of Object.entries(servers)) { state.set(key, { description: spec.description ?? key, status: spec.enabled ? "error" : "disabled", tools: [] }); } pi.registerCommand("mcp-status", { description: "Показать сконфигурированные MCP-серверы (Jira/Confluence/GitLab) и их статус", handler: async (_args, ctx) => { const lines: string[] = []; for (const [key, s] of state) { const t = s.tools.length ? ` — ${s.tools.length} tools` : ""; lines.push(`${key}: ${s.status}${t}${s.detail ? ` (${s.detail})` : ""}`); lines.push(` ${s.description}`); } if (lines.length === 0) lines.push("MCP-серверы не сконфигурированы (config/mcp.json)."); lines.push("", "Включить: enabled:true в config/mcp.json + токен в env + перезапуск pi."); if (ctx.hasUI) await ctx.ui.select("MCP-серверы", lines); else ctx.ui.notify(lines.join("\n"), "info"); }, }); // Connect enabled servers. Nothing runs here when all are disabled (default). for (const [key, spec] of Object.entries(servers)) { if (!spec.enabled) continue; const st = state.get(key)!; const env = resolveEnv(spec.env ?? {}, company); if (env === null) { st.status = "missing-env"; st.detail = "нет URL/токена (config/env)"; continue; } try { // Dynamically import so a missing SDK never breaks loading for others. const { Client } = await import("@modelcontextprotocol/sdk/client/index.js"); const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js"); const transport = new StdioClientTransport({ command: spec.command, args: spec.args ?? [], env: { ...process.env, ...env } as Record, }); const client = new Client({ name: `pi-kit-${key}`, version: "0.1.0" }, { capabilities: {} }); await Promise.race([ client.connect(transport), new Promise((_r, reject) => setTimeout(() => reject(new Error("timeout")), CONNECT_TIMEOUT_MS)), ]); closers.push(() => client.close().catch(() => {})); const { tools } = await client.listTools(); for (const tool of tools ?? []) { const toolName = `${key}_${tool.name}`; pi.registerTool({ name: toolName, label: toolName, description: tool.description ?? `${key} MCP tool ${tool.name}`, // MCP inputSchema is JSON Schema; pi accepts a JSON-Schema-shaped object. parameters: (tool.inputSchema as unknown) ?? { type: "object", properties: {} }, async execute(_id: string, params: unknown) { const res: any = await client.callTool({ name: tool.name, arguments: (params ?? {}) as Record }); return { content: res.content ?? [{ type: "text", text: JSON.stringify(res) }], details: {} }; }, }); st.tools.push(toolName); } st.status = "connected"; // Connection status is visible via /mcp-status (no ctx in the factory). } catch (err) { const msg = err instanceof Error ? err.message : String(err); st.status = /Cannot find( package)?|ERR_MODULE_NOT_FOUND|@modelcontextprotocol/.test(msg) ? "no-sdk" : "error"; st.detail = st.status === "no-sdk" ? "установи @modelcontextprotocol/sdk" : msg.slice(0, 80); } } pi.on("session_shutdown", async () => { for (const close of closers) await close(); }); }