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.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
package-lock.json
|
||||
|
||||
35
config/mcp.json
Normal file
35
config/mcp.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"//": "MCP server registry consumed by extensions/mcp-bridge.ts. All servers are DISABLED by default — enable one, set its token env var, then restart pi. URLs are pulled from config/company.json (single source of truth); tokens from env. Templates: {config:KEY} → company.json value, {env:VAR} → environment variable. Requires VPN/creds and the launcher (uvx/npx) to actually connect.",
|
||||
"servers": {
|
||||
"jira": {
|
||||
"enabled": false,
|
||||
"description": "Jira (Server/DC) через mcp-atlassian (Python, запускается uvx)",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-atlassian"],
|
||||
"env": {
|
||||
"JIRA_URL": "{config:trackerUrl}",
|
||||
"JIRA_PERSONAL_TOKEN": "{env:JIRA_TOKEN}"
|
||||
}
|
||||
},
|
||||
"confluence": {
|
||||
"enabled": false,
|
||||
"description": "Confluence (Server/DC) через mcp-atlassian",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-atlassian"],
|
||||
"env": {
|
||||
"CONFLUENCE_URL": "{config:docsUrl}",
|
||||
"CONFLUENCE_PERSONAL_TOKEN": "{env:CONFLUENCE_TOKEN}"
|
||||
}
|
||||
},
|
||||
"gitlab": {
|
||||
"enabled": false,
|
||||
"description": "GitLab (self-hosted) через @zereight/mcp-gitlab (запускается npx)",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@zereight/mcp-gitlab"],
|
||||
"env": {
|
||||
"GITLAB_API_URL": "https://{config:gitHost}/api/v4",
|
||||
"GITLAB_PERSONAL_ACCESS_TOKEN": "{env:GITLAB_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
docs/mcp.md
Normal file
47
docs/mcp.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# MCP-интеграции (Jira / Confluence / GitLab)
|
||||
|
||||
Pi не имеет встроенного MCP — его добавляет расширение `extensions/mcp-bridge.ts`. Оно читает
|
||||
`config/mcp.json`, поднимает **включённые** серверы (stdio) и регистрирует их инструменты в pi.
|
||||
**По умолчанию все серверы выключены.** Статус — команда `/mcp-status`.
|
||||
|
||||
## Как устроено
|
||||
|
||||
- Серверы объявлены в `config/mcp.json`. URL берутся из `config/company.json` (`{config:trackerUrl}`,
|
||||
`{config:docsUrl}`, `{config:gitHost}`), токены — из окружения (`{env:JIRA_TOKEN}` и т.д.).
|
||||
В коде и конфиге нет захардкоженных адресов и секретов.
|
||||
- Bridge подставляет значения при запуске сервера. Если URL/токен не заданы — сервер помечается
|
||||
`missing-env` и пропускается.
|
||||
|
||||
## Предпосылки (нужны только при включении)
|
||||
|
||||
- **VPN** и доступ к `jira.mvideo.ru` / `wiki.mvideo.ru` / `gitlab.tech.mvideo.ru`.
|
||||
- MCP SDK: `npm i @modelcontextprotocol/sdk` в пакете (в `optionalDependencies`; ставится только
|
||||
при включении MCP).
|
||||
- Лаунчеры серверов: `uvx` (для `mcp-atlassian`) и/или `npx` (для `@zereight/mcp-gitlab`).
|
||||
- Токены в окружении (см. `~/.config/pi-kit/env.sh`).
|
||||
|
||||
## Включение
|
||||
|
||||
1. В `config/mcp.json` поставь `"enabled": true` нужному серверу.
|
||||
2. Задай токен в окружении:
|
||||
- Jira: `JIRA_TOKEN` (PAT Server/DC).
|
||||
- Confluence: `CONFLUENCE_TOKEN` (PAT Server/DC).
|
||||
- GitLab: `GITLAB_TOKEN` (scope `api`).
|
||||
3. Установи SDK (если ещё нет): `npm i @modelcontextprotocol/sdk`.
|
||||
4. Перезапусти `pi`. Проверь: `/mcp-status` → `connected` и число tools.
|
||||
|
||||
## Серверы
|
||||
|
||||
| Сервер | Пакет | URL из | Токен |
|
||||
|---|---|---|---|
|
||||
| `jira` | `mcp-atlassian` (uvx) | `trackerUrl` | `JIRA_TOKEN` |
|
||||
| `confluence` | `mcp-atlassian` (uvx) | `docsUrl` | `CONFLUENCE_TOKEN` |
|
||||
| `gitlab` | `@zereight/mcp-gitlab` (npx) | `gitHost` → `/api/v4` | `GITLAB_TOKEN` |
|
||||
|
||||
<!-- TODO: сверить с корпоративной политикой — какие MCP-серверы одобрены, разрешён ли uvx/npx
|
||||
на машинах, или нужен предустановленный/зеркалированный образ. -->
|
||||
|
||||
## Безопасность
|
||||
|
||||
MCP-серверы — сторонние процессы с полным доступом к твоим токенам. Включай только одобренные
|
||||
образы. Инструменты MCP проходят через guardrails pi (permission-gate/audit) как обычные вызовы.
|
||||
156
extensions/mcp-bridge.ts
Normal file
156
extensions/mcp-bridge.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
}
|
||||
@@ -30,12 +30,15 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-ai": "*",
|
||||
"@earendil-works/pi-coding-agent": "*",
|
||||
"@earendil-works/pi-tui": "*",
|
||||
"typebox": "*"
|
||||
},
|
||||
"dependencies": {},
|
||||
"optionalDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user