Files
pi-kit/extensions/mcp-bridge.ts
Aleksey Shakhmatov 2995823a0f feat(mcp): scaffold Jira/Confluence/GitLab MCP bridge (disabled by default)
Pi has no built-in MCP; this adds a bridge extension + declarative registry.
- config/mcp.json: jira/confluence/gitlab servers, all enabled:false; URLs from
  company.json ({config:...}), tokens from env ({env:...}) — nothing hardcoded
- extensions/mcp-bridge.ts: resolves env templates, and for enabled servers
  connects via MCP SDK (dynamic import) and registers their tools; /mcp-status
  command; graceful no-op when disabled or SDK absent
- docs/mcp.md: how to enable, prerequisites (VPN, tokens, uvx/npx, SDK), security
- package.json: @modelcontextprotocol/sdk as optionalDependency; ignore package-lock

Verified offline: extension loads, /mcp-status lists 3 servers (disabled), env
resolver builds gitlab API URL from company.json gitHost + GITLAB_TOKEN. Live
connect needs VPN + a token + authorization to run the server package.
2026-07-16 14:33:21 +03:00

157 lines
5.9 KiB
TypeScript

/**
* 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<string, string>;
}
type Status = "disabled" | "connected" | "error" | "no-sdk" | "missing-env";
interface ServerState {
description: string;
status: Status;
tools: string[];
detail?: string;
}
function readJson(file: string): Record<string, unknown> {
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<string, string>,
company: Record<string, unknown>,
): Record<string, string> | null {
const out: Record<string, string> = {};
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<string, ServerSpec>;
const state = new Map<string, ServerState>();
const closers: Array<() => Promise<void> | 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<string, string>,
});
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<string, unknown> });
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();
});
}