From 28c741c19607f50997b5ce58237e59368fe79065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 18 Mar 2026 12:26:16 -0600 Subject: [PATCH] refactor: replace MCPorter with native MCP client (#1210) * refactor: replace MCPorter CLI with native MCP client using @modelcontextprotocol/sdk MCPorter is a third-party global CLI that fails to install on many systems, producing error noise on every startup. Replace it with a native extension that uses the already-bundled @modelcontextprotocol/sdk Client class directly. Co-Authored-By: Claude Opus 4.6 (1M context) * docs: update README extension table from MCPorter to MCP Client Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add .js suffix to MCP SDK subpath imports for NodeNext resolution The SDK wildcard export (./*) requires .js suffix for TypeScript NodeNext module resolution. Also add .js-suffixed virtual module keys so jiti resolves them correctly in compiled Bun binaries. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- README.md | 2 +- docs/architecture.md | 2 +- .../src/core/extensions/loader.ts | 13 + src/resources/extensions/mcp-client/index.ts | 459 +++++++++++++++ src/resources/extensions/mcporter/index.ts | 525 ------------------ 5 files changed, 474 insertions(+), 527 deletions(-) create mode 100644 src/resources/extensions/mcp-client/index.ts delete mode 100644 src/resources/extensions/mcporter/index.ts diff --git a/README.md b/README.md index 730290dd8..581defc80 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,7 @@ GSD ships with 14 extensions, all loaded automatically: | **Background Shell** | Long-running process management with readiness detection | | **Subagent** | Delegated tasks with isolated context windows | | **Mac Tools** | macOS native app automation via Accessibility APIs | -| **MCPorter** | Lazy on-demand MCP server integration | +| **MCP Client** | Native MCP server integration via @modelcontextprotocol/sdk | | **Voice** | Real-time speech-to-text transcription (macOS, Linux — Ubuntu 22.04+) | | **Slash Commands** | Custom command creation | | **LSP** | Language Server Protocol integration — diagnostics, go-to-definition, references, hover, symbols, rename, code actions | diff --git a/docs/architecture.md b/docs/architecture.md index 752ab56d5..e46bcc014 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,7 +63,7 @@ Every dispatch creates a new agent session. The LLM starts with a clean context | **Background Shell** | Long-running process management with readiness detection | | **Subagent** | Delegated tasks with isolated context windows | | **Mac Tools** | macOS native app automation via Accessibility APIs | -| **MCPorter** | Lazy on-demand MCP server integration | +| **MCP Client** | Native MCP server integration via @modelcontextprotocol/sdk | | **Voice** | Real-time speech-to-text (macOS, Linux) | | **Slash Commands** | Custom command creation | | **LSP** | Language Server Protocol — diagnostics, definitions, references, hover, rename | diff --git a/packages/pi-coding-agent/src/core/extensions/loader.ts b/packages/pi-coding-agent/src/core/extensions/loader.ts index c3c4f2a1b..58c35931c 100644 --- a/packages/pi-coding-agent/src/core/extensions/loader.ts +++ b/packages/pi-coding-agent/src/core/extensions/loader.ts @@ -20,6 +20,9 @@ import * as _bundledPiTui from "@gsd/pi-tui"; // The virtualModules option then makes them available to extensions. import * as _bundledTypebox from "@sinclair/typebox"; import * as _bundledYaml from "yaml"; +import * as _bundledMcpClient from "@modelcontextprotocol/sdk/client"; +import * as _bundledMcpStdio from "@modelcontextprotocol/sdk/client/stdio.js"; +import * as _bundledMcpStreamableHttp from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { getAgentDir, isBunBinary } from "../../config.js"; // NOTE: This import works because loader.ts exports are NOT re-exported from index.ts, // avoiding a circular dependency. Extensions can import from @gsd/pi-coding-agent. @@ -50,6 +53,11 @@ const VIRTUAL_MODULES: Record = { "@gsd/pi-ai/oauth": _bundledPiAiOauth, "@gsd/pi-coding-agent": _bundledPiCodingAgent, "yaml": _bundledYaml, + "@modelcontextprotocol/sdk/client": _bundledMcpClient, + "@modelcontextprotocol/sdk/client/stdio": _bundledMcpStdio, + "@modelcontextprotocol/sdk/client/stdio.js": _bundledMcpStdio, + "@modelcontextprotocol/sdk/client/streamableHttp": _bundledMcpStreamableHttp, + "@modelcontextprotocol/sdk/client/streamableHttp.js": _bundledMcpStreamableHttp, // Aliases for external PI ecosystem packages that import from the original scope "@mariozechner/pi-agent-core": _bundledPiAgentCore, "@mariozechner/pi-tui": _bundledPiTui, @@ -94,6 +102,11 @@ function getAliases(): Record { "@gsd/pi-ai/oauth": resolveWorkspaceOrImport("ai/dist/oauth.js", "@gsd/pi-ai/oauth"), "@sinclair/typebox": typeboxRoot, "yaml": yamlRoot, + "@modelcontextprotocol/sdk/client": require.resolve("@modelcontextprotocol/sdk/client"), + "@modelcontextprotocol/sdk/client/stdio": require.resolve("@modelcontextprotocol/sdk/client/stdio.js"), + "@modelcontextprotocol/sdk/client/stdio.js": require.resolve("@modelcontextprotocol/sdk/client/stdio.js"), + "@modelcontextprotocol/sdk/client/streamableHttp": require.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"), + "@modelcontextprotocol/sdk/client/streamableHttp.js": require.resolve("@modelcontextprotocol/sdk/client/streamableHttp.js"), // Aliases for external PI ecosystem packages that import from the original scope "@mariozechner/pi-coding-agent": packageIndex, "@mariozechner/pi-agent-core": resolveWorkspaceOrImport("agent/dist/index.js", "@gsd/pi-agent-core"), diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts new file mode 100644 index 000000000..8d4eae552 --- /dev/null +++ b/src/resources/extensions/mcp-client/index.ts @@ -0,0 +1,459 @@ +/** + * MCP Client Extension — Native MCP server integration for pi + * + * Provides on-demand access to MCP servers configured in project files + * (.mcp.json, .gsd/mcp.json) using the @modelcontextprotocol/sdk Client + * directly — no external CLI dependency required. + * + * Three tools: + * mcp_servers — List available MCP servers from config files + * mcp_discover — Get tool signatures for a specific server (lazy connect) + * mcp_call — Call a tool on an MCP server (lazy connect) + */ + +import type { ExtensionAPI } from "@gsd/pi-coding-agent"; +import { + truncateHead, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, +} from "@gsd/pi-coding-agent"; +import { Text } from "@gsd/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { Client } from "@modelcontextprotocol/sdk/client"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface McpServerConfig { + name: string; + transport: "stdio" | "http" | "unknown"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + cwd?: string; +} + +interface McpToolSchema { + name: string; + description: string; + inputSchema?: Record; +} + +interface ManagedConnection { + client: Client; + transport: StdioClientTransport | StreamableHTTPClientTransport; +} + +// ─── Connection Manager ─────────────────────────────────────────────────────── + +const connections = new Map(); +let configCache: McpServerConfig[] | null = null; +const toolCache = new Map(); + +function readConfigs(): McpServerConfig[] { + if (configCache) return configCache; + + const servers: McpServerConfig[] = []; + const seen = new Set(); + const configPaths = [ + join(process.cwd(), ".mcp.json"), + join(process.cwd(), ".gsd", "mcp.json"), + ]; + + for (const configPath of configPaths) { + try { + if (!existsSync(configPath)) continue; + const raw = readFileSync(configPath, "utf-8"); + const data = JSON.parse(raw) as Record; + const mcpServers = (data.mcpServers ?? data.servers) as + | Record> + | undefined; + if (!mcpServers || typeof mcpServers !== "object") continue; + + for (const [name, config] of Object.entries(mcpServers)) { + if (seen.has(name)) continue; + seen.add(name); + + const hasCommand = typeof config.command === "string"; + const hasUrl = typeof config.url === "string"; + const transport: McpServerConfig["transport"] = hasCommand + ? "stdio" + : hasUrl + ? "http" + : "unknown"; + + servers.push({ + name, + transport, + ...(hasCommand && { + command: config.command as string, + args: Array.isArray(config.args) ? (config.args as string[]) : undefined, + env: config.env && typeof config.env === "object" + ? (config.env as Record) + : undefined, + cwd: typeof config.cwd === "string" ? config.cwd : undefined, + }), + ...(hasUrl && { url: config.url as string }), + }); + } + } catch { + // Non-fatal — config file may not exist or be malformed + } + } + + configCache = servers; + return servers; +} + +function getServerConfig(name: string): McpServerConfig | undefined { + return readConfigs().find((s) => s.name === name); +} + +async function getOrConnect(name: string, signal?: AbortSignal): Promise { + const existing = connections.get(name); + if (existing) return existing.client; + + const config = getServerConfig(name); + if (!config) throw new Error(`Unknown MCP server: "${name}". Use mcp_servers to list available servers.`); + + const client = new Client({ name: "gsd", version: "1.0.0" }); + let transport: StdioClientTransport | StreamableHTTPClientTransport; + + if (config.transport === "stdio" && config.command) { + transport = new StdioClientTransport({ + command: config.command, + args: config.args, + env: config.env ? { ...process.env, ...config.env } as Record : undefined, + cwd: config.cwd, + stderr: "pipe", + }); + } else if (config.transport === "http" && config.url) { + transport = new StreamableHTTPClientTransport(new URL(config.url)); + } else { + throw new Error(`Server "${name}" has unsupported transport: ${config.transport}`); + } + + await client.connect(transport, { signal, timeout: 30000 }); + connections.set(name, { client, transport }); + return client; +} + +async function closeAll(): Promise { + const closing = Array.from(connections.entries()).map(async ([name, conn]) => { + try { + await conn.client.close(); + } catch { + // Best-effort cleanup + } + connections.delete(name); + }); + await Promise.allSettled(closing); + toolCache.clear(); +} + +// ─── Formatters ─────────────────────────────────────────────────────────────── + +function formatServerList(servers: McpServerConfig[]): string { + if (servers.length === 0) return "No MCP servers configured. Add servers to .mcp.json or .gsd/mcp.json."; + + const lines: string[] = [`${servers.length} MCP servers configured:\n`]; + + for (const s of servers) { + const connected = connections.has(s.name) ? "✓" : "○"; + const cached = toolCache.get(s.name); + const toolCount = cached ? ` — ${cached.length} tools` : ""; + lines.push(`${connected} ${s.name} (${s.transport})${toolCount}`); + } + + lines.push("\nUse mcp_discover to see full tool schemas for a specific server."); + lines.push("Use mcp_call to invoke a tool: mcp_call(server, tool, args)."); + return lines.join("\n"); +} + +function formatToolList(serverName: string, tools: McpToolSchema[]): string { + const lines: string[] = [`${serverName} — ${tools.length} tools:\n`]; + + for (const tool of tools) { + lines.push(`## ${tool.name}`); + if (tool.description) lines.push(tool.description); + if (tool.inputSchema) { + lines.push("```json"); + lines.push(JSON.stringify(tool.inputSchema, null, 2)); + lines.push("```"); + } + lines.push(""); + } + + lines.push(`Call with: mcp_call(server="${serverName}", tool="", args={...})`); + return lines.join("\n"); +} + +// ─── Extension ──────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + // ── mcp_servers ────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "mcp_servers", + label: "MCP Servers", + description: + "List all available MCP servers configured in project files (.mcp.json, .gsd/mcp.json). " + + "Shows server names, transport type, and connection status. Use mcp_discover to get full tool schemas for a server.", + promptSnippet: + "List available MCP servers from project configuration", + promptGuidelines: [ + "Call mcp_servers to see what MCP servers are available before trying to use one.", + "MCP servers provide external integrations (Twitter, Linear, Railway, etc.) via the Model Context Protocol.", + "After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.", + ], + parameters: Type.Object({ + refresh: Type.Optional( + Type.Boolean({ description: "Force refresh the server list (default: use cache)" }), + ), + }), + + async execute(_id, params) { + if (params.refresh) configCache = null; + + const servers = readConfigs(); + return { + content: [{ type: "text", text: formatServerList(servers) }], + details: { + serverCount: servers.length, + cached: !params.refresh && configCache !== null, + }, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("mcp_servers")); + if (args.refresh) text += theme.fg("warning", " (refresh)"); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Reading MCP config..."), 0, 0); + const d = result.details as { serverCount: number } | undefined; + return new Text( + theme.fg("success", `${d?.serverCount ?? 0} servers configured`), + 0, + 0, + ); + }, + }); + + // ── mcp_discover ───────────────────────────────────────────────────────── + + pi.registerTool({ + name: "mcp_discover", + label: "MCP Discover", + description: + "Get detailed tool signatures and JSON schemas for a specific MCP server. " + + "Connects to the server on first call (lazy connection). " + + "Use this to understand what tools a server provides and what arguments they accept " + + "before calling them with mcp_call.", + promptSnippet: + "Get tool schemas for a specific MCP server before calling its tools", + promptGuidelines: [ + "Call mcp_discover with a server name to see the full tool signatures before calling mcp_call.", + "The schemas show required and optional parameters with types and descriptions.", + ], + parameters: Type.Object({ + server: Type.String({ + description: + "MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'", + }), + }), + + async execute(_id, params, signal) { + try { + // Return cached tools if available + const cached = toolCache.get(params.server); + if (cached) { + const text = formatToolList(params.server, cached); + const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let finalText = truncation.content; + if (truncation.truncated) { + finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + return { + content: [{ type: "text", text: finalText }], + details: { server: params.server, toolCount: cached.length, cached: true }, + }; + } + + const client = await getOrConnect(params.server, signal); + const result = await client.listTools(undefined, { signal, timeout: 30000 }); + const tools: McpToolSchema[] = (result.tools ?? []).map((t) => ({ + name: t.name, + description: t.description ?? "", + inputSchema: t.inputSchema as Record | undefined, + })); + toolCache.set(params.server, tools); + + const text = formatToolList(params.server, tools); + const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let finalText = truncation.content; + if (truncation.truncated) { + finalText += `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + + return { + content: [{ type: "text", text: finalText }], + details: { server: params.server, toolCount: tools.length, cached: false }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to discover tools for "${params.server}": ${msg}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("mcp_discover ")); + text += theme.fg("accent", args.server); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme) { + if (isPartial) + return new Text(theme.fg("warning", "Discovering tools..."), 0, 0); + const d = result.details as { server: string; toolCount: number } | undefined; + return new Text( + theme.fg("success", `${d?.toolCount ?? 0} tools`) + + theme.fg("dim", ` · ${d?.server}`), + 0, + 0, + ); + }, + }); + + // ── mcp_call ───────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "mcp_call", + label: "MCP Call", + description: + "Call a tool on an MCP server. Provide the server name, tool name, and arguments. " + + "Connects to the server on first call (lazy connection). " + + "Use mcp_discover first to see available tools and their required arguments.", + promptSnippet: "Call a tool on an MCP server", + promptGuidelines: [ + "Always use mcp_discover first to understand the tool's parameters before calling mcp_call.", + "Arguments are passed as a JSON object matching the tool's input schema.", + ], + parameters: Type.Object({ + server: Type.String({ + description: "MCP server name, e.g. 'railway', 'twitter-mcp'", + }), + tool: Type.String({ + description: "Tool name on that server, e.g. 'railway_list_projects'", + }), + args: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: + "Tool arguments as key-value pairs matching the tool's input schema", + }), + ), + }), + + async execute(_id, params, signal) { + try { + const client = await getOrConnect(params.server, signal); + const result = await client.callTool( + { name: params.tool, arguments: params.args ?? {} }, + undefined, + { signal, timeout: 60000 }, + ); + + // Serialize result content to text + const contentItems = result.content as Array<{ type: string; text?: string }>; + const raw = contentItems + .map((c) => (c.type === "text" ? c.text ?? "" : JSON.stringify(c))) + .join("\n"); + + const truncation = truncateHead(raw, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); + let finalText = truncation.content; + if (truncation.truncated) { + finalText += `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + + return { + content: [{ type: "text", text: finalText }], + details: { + server: params.server, + tool: params.tool, + charCount: finalText.length, + truncated: truncation.truncated, + }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`MCP call failed: ${params.server}.${params.tool}\n${msg}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("mcp_call ")); + text += theme.fg("accent", `${args.server}.${args.tool}`); + if (args.args && Object.keys(args.args).length > 0) { + const preview = Object.entries(args.args) + .slice(0, 3) + .map(([k, v]) => { + const val = typeof v === "string" ? v : JSON.stringify(v); + return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`; + }) + .join(" "); + text += " " + theme.fg("muted", preview); + } + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial, expanded }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0); + + const d = result.details as { + server: string; + tool: string; + charCount: number; + truncated: boolean; + } | undefined; + + let text = theme.fg("success", `✓ ${d?.server}.${d?.tool}`); + text += theme.fg("dim", ` · ${(d?.charCount ?? 0).toLocaleString()} chars`); + if (d?.truncated) text += theme.fg("warning", " · truncated"); + + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + const preview = content.text.split("\n").slice(0, 15).join("\n"); + text += "\n\n" + theme.fg("dim", preview); + } + } + + return new Text(text, 0, 0); + }, + }); + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + const servers = readConfigs(); + if (servers.length > 0) { + ctx.ui.notify(`MCP client ready — ${servers.length} server(s) configured`, "info"); + } + }); + + pi.on("session_shutdown", async () => { + await closeAll(); + }); + + pi.on("session_switch", async () => { + await closeAll(); + configCache = null; + }); +} diff --git a/src/resources/extensions/mcporter/index.ts b/src/resources/extensions/mcporter/index.ts deleted file mode 100644 index 58edce7cd..000000000 --- a/src/resources/extensions/mcporter/index.ts +++ /dev/null @@ -1,525 +0,0 @@ -/** - * MCPorter Extension — Lazy MCP server integration for pi - * - * Provides on-demand access to all MCP servers configured on the system - * (via Claude Desktop, Cursor, VS Code, mcporter config, etc.) without - * registering every tool upfront. This keeps token usage near-zero until - * the agent actually needs an MCP tool. - * - * Three tools: - * mcp_servers — List available MCP servers (cached after first call) - * mcp_discover — Get tool signatures for a specific server - * mcp_call — Call a tool on an MCP server - * - * Requirements: - * - mcporter installed globally: npm i -g mcporter - */ - -import type { ExtensionAPI } from "@gsd/pi-coding-agent"; -import { - truncateHead, - DEFAULT_MAX_BYTES, - DEFAULT_MAX_LINES, - formatSize, -} from "@gsd/pi-coding-agent"; -import { Text } from "@gsd/pi-tui"; -import { Type } from "@sinclair/typebox"; -import { execFile, exec } from "node:child_process"; -import { promisify } from "node:util"; -import { readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; - -const execFileAsync = promisify(execFile); -const execAsync = promisify(exec); - -// ─── Types ──────────────────────────────────────────────────────────────────── - -interface McpServer { - name: string; - status: string; - transport?: string; - tools: { name: string; description: string }[]; -} - -interface McpListResponse { - mode: string; - counts: { ok: number; auth: number; offline: number; http: number; error: number }; - servers: McpServer[]; -} - -interface McpToolSchema { - name: string; - description: string; - inputSchema?: Record; -} - -interface McpServerDetail { - name: string; - status: string; - tools: McpToolSchema[]; -} - -// ─── Cache ──────────────────────────────────────────────────────────────────── - -let serverListCache: McpServer[] | null = null; -const serverDetailCache = new Map(); - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function escapeShellArg(arg: string): string { - if (process.platform === "win32") { - return `"${arg.replace(/"/g, '""')}"`; - } - return `'${arg.replace(/'/g, "'\\''")}'`; -} - -async function runMcporter( - args: string[], - signal?: AbortSignal, - timeoutMs = 30000, -): Promise { - // Cross-platform: use execFile on Windows to avoid quote handling issues - // On Windows, cmd.exe doesn't strip single quotes like Unix shells do - if (process.platform === "win32") { - const { stdout } = await execFileAsync("mcporter", args, { - timeout: timeoutMs, - maxBuffer: 1024 * 1024, - signal, - env: { ...process.env }, - shell: true, - }); - return stdout; - } - // Use shell exec so PATH resolution works on Unix - const escaped = args.map((a) => escapeShellArg(a)).join(" "); - const { stdout } = await execAsync(`mcporter ${escaped}`, { - timeout: timeoutMs, - maxBuffer: 1024 * 1024, - signal, - env: { ...process.env }, - }); - return stdout; -} - -/** - * Read MCP server configs from project-level files (.mcp.json and .gsd/mcp.json) - * and return servers not already discovered by mcporter. - * - * Search order: - * 1. .mcp.json (Claude Code / Cursor standard) - * 2. .gsd/mcp.json (GSD-specific per-project config, #716) - * - * Project config overrides global — servers found here take precedence over - * identically-named servers from mcporter's global discovery. - */ -function readProjectMcpJson(knownNames: Set): McpServer[] { - const servers: McpServer[] = []; - const configPaths = [ - join(process.cwd(), ".mcp.json"), - join(process.cwd(), ".gsd", "mcp.json"), - ]; - - for (const mcpJsonPath of configPaths) { - try { - if (!existsSync(mcpJsonPath)) continue; - const raw = readFileSync(mcpJsonPath, "utf-8"); - const data = JSON.parse(raw) as Record; - const mcpServers = (data.mcpServers ?? data.servers) as Record> | undefined; - if (!mcpServers || typeof mcpServers !== "object") continue; - - for (const [name, config] of Object.entries(mcpServers)) { - if (knownNames.has(name)) continue; // Already discovered - knownNames.add(name); // Prevent duplicates across config files - const transport = (config.type as string) ?? (config.command ? "stdio" : config.url ? "http" : "unknown"); - servers.push({ - name, - status: "ok", - transport, - tools: [], // Tools unknown until mcp_discover is called - }); - } - } catch { - // Non-fatal — config file may not exist or be malformed - } - } - return servers; -} - -async function getServerList(signal?: AbortSignal): Promise { - if (serverListCache) return serverListCache; - - const raw = await runMcporter(["list", "--json"], signal, 60000); - let data: McpListResponse; - try { - data = JSON.parse(raw) as McpListResponse; - } catch (e) { - throw new Error(`Failed to parse mcporter output: ${raw.slice(0, 300)}`); - } - if (!Array.isArray(data.servers)) { - throw new Error(`Unexpected mcporter response shape: ${JSON.stringify(Object.keys(data))}`); - } - - // Merge servers from project-root .mcp.json that mcporter didn't discover - const knownNames = new Set(data.servers.map((s) => s.name)); - const projectServers = readProjectMcpJson(knownNames); - if (projectServers.length > 0) { - data.servers.push(...projectServers); - } - - serverListCache = data.servers; - return serverListCache; -} - -async function getServerDetail( - serverName: string, - signal?: AbortSignal, -): Promise { - if (serverDetailCache.has(serverName)) return serverDetailCache.get(serverName)!; - - // Check if this server came from .mcp.json (not known to mcporter natively) - const mcpJsonUrl = getMcpJsonServerUrl(serverName); - const args = mcpJsonUrl - ? ["list", mcpJsonUrl, "--schema", "--json"] - : ["list", serverName, "--schema", "--json"]; - - const raw = await runMcporter(args, signal); - const data = JSON.parse(raw) as McpServerDetail; - // Preserve the user-facing name from .mcp.json - if (mcpJsonUrl) data.name = serverName; - serverDetailCache.set(serverName, data); - return data; -} - -/** - * Look up a server's URL from .mcp.json if it's an HTTP server not known to mcporter. - */ -function getMcpJsonServerUrl(serverName: string): string | null { - try { - const mcpJsonPath = join(process.cwd(), ".mcp.json"); - if (!existsSync(mcpJsonPath)) return null; - const raw = readFileSync(mcpJsonPath, "utf-8"); - const data = JSON.parse(raw) as Record; - const mcpServers = (data.mcpServers ?? data.servers) as Record> | undefined; - if (!mcpServers?.[serverName]) return null; - const config = mcpServers[serverName]; - if (config.type === "http" && typeof config.url === "string") return config.url; - return null; - } catch { - return null; - } -} - -function formatServerList(servers: McpServer[]): string { - if (servers.length === 0) return "No MCP servers found."; - - const lines: string[] = [`${servers.length} MCP servers available:\n`]; - - for (const s of servers) { - const tools = s.tools ?? []; - const status = s.status === "ok" ? "✓" : s.status === "auth" ? "🔑" : "✗"; - lines.push(`${status} ${s.name} — ${tools.length} tools (${s.status})`); - for (const t of tools) { - lines.push(` ${t.name}: ${t.description?.slice(0, 100) ?? ""}`); - } - } - - lines.push("\nUse mcp_discover to see full tool schemas for a specific server."); - lines.push("Use mcp_call to invoke a tool: mcp_call(server, tool, args)."); - return lines.join("\n"); -} - -function formatServerDetail(detail: McpServerDetail): string { - const lines: string[] = [`${detail.name} — ${detail.tools.length} tools:\n`]; - - for (const tool of detail.tools) { - lines.push(`## ${tool.name}`); - if (tool.description) lines.push(tool.description); - if (tool.inputSchema) { - lines.push("```json"); - lines.push(JSON.stringify(tool.inputSchema, null, 2)); - lines.push("```"); - } - lines.push(""); - } - - lines.push(`Call with: mcp_call(server="${detail.name}", tool="", args={...})`); - return lines.join("\n"); -} - -// ─── Extension ──────────────────────────────────────────────────────────────── - -export default function (pi: ExtensionAPI) { - // ── mcp_servers ────────────────────────────────────────────────────────── - - pi.registerTool({ - name: "mcp_servers", - label: "MCP Servers", - description: - "List all available MCP servers discovered from your system (Claude Desktop, Cursor, VS Code, mcporter config). " + - "Shows server names, status, and tool counts. Use mcp_discover to get full tool schemas for a server.", - promptSnippet: - "List available MCP servers and their tools (lazy discovery via mcporter)", - promptGuidelines: [ - "Call mcp_servers to see what MCP servers are available before trying to use one.", - "MCP servers provide external integrations (Twitter, Linear, Railway, etc.) via the Model Context Protocol.", - "After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.", - ], - parameters: Type.Object({ - refresh: Type.Optional( - Type.Boolean({ description: "Force refresh the server list (default: use cache)" }), - ), - }), - - async execute(_id, params, signal) { - if (params.refresh) serverListCache = null; - - try { - const servers = await getServerList(signal); - return { - content: [{ type: "text", text: formatServerList(servers) }], - details: { - serverCount: servers.length, - cached: !params.refresh && serverListCache !== null, - }, - }; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `Failed to list MCP servers. Is mcporter installed? (npm i -g mcporter)\n${msg}`, - ); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("mcp_servers")); - if (args.refresh) text += theme.fg("warning", " (refresh)"); - return new Text(text, 0, 0); - }, - - renderResult(result, { isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Discovering MCP servers..."), 0, 0); - const d = result.details as { serverCount: number } | undefined; - return new Text( - theme.fg("success", `${d?.serverCount ?? 0} servers found`), - 0, - 0, - ); - }, - }); - - // ── mcp_discover ───────────────────────────────────────────────────────── - - pi.registerTool({ - name: "mcp_discover", - label: "MCP Discover", - description: - "Get detailed tool signatures and JSON schemas for a specific MCP server. " + - "Use this to understand what tools a server provides and what arguments they accept " + - "before calling them with mcp_call.", - promptSnippet: - "Get tool schemas for a specific MCP server before calling its tools", - promptGuidelines: [ - "Call mcp_discover with a server name to see the full tool signatures before calling mcp_call.", - "The schemas show required and optional parameters with types and descriptions.", - ], - parameters: Type.Object({ - server: Type.String({ - description: - "MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'", - }), - }), - - async execute(_id, params, signal) { - try { - const detail = await getServerDetail(params.server, signal); - const text = formatServerDetail(detail); - - // Truncation guard - const truncation = truncateHead(text, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - let finalText = truncation.content; - if (truncation.truncated) { - finalText += - `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines ` + - `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; - } - - return { - content: [{ type: "text", text: finalText }], - details: { - server: params.server, - toolCount: detail.tools.length, - cached: serverDetailCache.has(params.server), - }, - }; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error(`Failed to discover tools for "${params.server}": ${msg}`); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("mcp_discover ")); - text += theme.fg("accent", args.server); - return new Text(text, 0, 0); - }, - - renderResult(result, { isPartial }, theme) { - if (isPartial) - return new Text(theme.fg("warning", "Discovering tools..."), 0, 0); - const d = result.details as { server: string; toolCount: number } | undefined; - return new Text( - theme.fg("success", `${d?.toolCount ?? 0} tools`) + - theme.fg("dim", ` · ${d?.server}`), - 0, - 0, - ); - }, - }); - - // ── mcp_call ───────────────────────────────────────────────────────────── - - pi.registerTool({ - name: "mcp_call", - label: "MCP Call", - description: - "Call a tool on an MCP server. Provide the server name, tool name, and arguments. " + - "Use mcp_discover first to see available tools and their required arguments.", - promptSnippet: "Call a tool on an MCP server via mcporter", - promptGuidelines: [ - "Always use mcp_discover first to understand the tool's parameters before calling mcp_call.", - "Arguments are passed as a JSON object matching the tool's input schema.", - ], - parameters: Type.Object({ - server: Type.String({ - description: "MCP server name, e.g. 'railway', 'twitter-mcp'", - }), - tool: Type.String({ - description: "Tool name on that server, e.g. 'railway_list_projects'", - }), - args: Type.Optional( - Type.Record(Type.String(), Type.Unknown(), { - description: - "Tool arguments as key-value pairs matching the tool's input schema", - }), - ), - }), - - async execute(_id, params, signal) { - // Build mcporter call command: mcporter call server.tool key:value ... - // For HTTP servers from .mcp.json, use the URL directly as the server identifier - const mcpJsonUrl = getMcpJsonServerUrl(params.server); - const serverRef = mcpJsonUrl ?? params.server; - const callTarget = `${serverRef}.${params.tool}`; - const cliArgs = ["call", callTarget, "--output", "raw"]; - - if (params.args && Object.keys(params.args).length > 0) { - for (const [key, value] of Object.entries(params.args)) { - const strVal = - typeof value === "string" ? value : JSON.stringify(value); - cliArgs.push(`${key}:${strVal}`); - } - } - - try { - const raw = await runMcporter(cliArgs, signal, 60000); - - // Truncation guard - const truncation = truncateHead(raw, { - maxLines: DEFAULT_MAX_LINES, - maxBytes: DEFAULT_MAX_BYTES, - }); - let finalText = truncation.content; - if (truncation.truncated) { - finalText += - `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines ` + - `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; - } - - return { - content: [{ type: "text", text: finalText }], - details: { - server: params.server, - tool: params.tool, - charCount: finalText.length, - truncated: truncation.truncated, - }, - }; - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - throw new Error( - `MCP call failed: ${params.server}.${params.tool}\n${msg}`, - ); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("mcp_call ")); - text += theme.fg("accent", `${args.server}.${args.tool}`); - if (args.args && Object.keys(args.args).length > 0) { - const preview = Object.entries(args.args) - .slice(0, 3) - .map(([k, v]) => { - const val = typeof v === "string" ? v : JSON.stringify(v); - return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`; - }) - .join(" "); - text += " " + theme.fg("muted", preview); - } - return new Text(text, 0, 0); - }, - - renderResult(result, { isPartial, expanded }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0); - - const d = result.details as { - server: string; - tool: string; - charCount: number; - truncated: boolean; - } | undefined; - - let text = theme.fg("success", `✓ ${d?.server}.${d?.tool}`); - text += theme.fg("dim", ` · ${(d?.charCount ?? 0).toLocaleString()} chars`); - if (d?.truncated) text += theme.fg("warning", " · truncated"); - - if (expanded) { - const content = result.content[0]; - if (content?.type === "text") { - const preview = content.text.split("\n").slice(0, 15).join("\n"); - text += "\n\n" + theme.fg("dim", preview); - } - } - - return new Text(text, 0, 0); - }, - }); - - // ── Verify mcporter is available ───────────────────────────────────────── - - pi.on("session_start", async (_event, ctx) => { - try { - const ver = (await runMcporter(["--version"], undefined, 5000)).trim(); - ctx.ui.notify(`MCPorter ${ver} ready`, "info"); - } catch { - ctx.ui.notify("MCPorter not found — attempting auto-install…", "warning"); - try { - await new Promise((resolve, reject) => { - exec("npm install -g mcporter", { timeout: 60000 }, (err) => { - if (err) reject(err); - else resolve(); - }); - }); - const ver = (await runMcporter(["--version"], undefined, 5000)).trim(); - ctx.ui.notify(`MCPorter ${ver} auto-installed ✓`, "info"); - } catch { - ctx.ui.notify( - "MCPorter auto-install failed. Install manually: npm i -g mcporter", - "error", - ); - } - } - }); -}