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) <noreply@anthropic.com> * docs: update README extension table from MCPorter to MCP Client Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d3780c9bdb
commit
28c741c196
5 changed files with 474 additions and 527 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {
|
|||
"@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<string, string> {
|
|||
"@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"),
|
||||
|
|
|
|||
459
src/resources/extensions/mcp-client/index.ts
Normal file
459
src/resources/extensions/mcp-client/index.ts
Normal file
|
|
@ -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<string, string>;
|
||||
url?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
interface McpToolSchema {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ManagedConnection {
|
||||
client: Client;
|
||||
transport: StdioClientTransport | StreamableHTTPClientTransport;
|
||||
}
|
||||
|
||||
// ─── Connection Manager ───────────────────────────────────────────────────────
|
||||
|
||||
const connections = new Map<string, ManagedConnection>();
|
||||
let configCache: McpServerConfig[] | null = null;
|
||||
const toolCache = new Map<string, McpToolSchema[]>();
|
||||
|
||||
function readConfigs(): McpServerConfig[] {
|
||||
if (configCache) return configCache;
|
||||
|
||||
const servers: McpServerConfig[] = [];
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>;
|
||||
const mcpServers = (data.mcpServers ?? data.servers) as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| 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<string, string>)
|
||||
: 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<Client> {
|
||||
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<string, string> : 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<void> {
|
||||
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="<tool_name>", 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<string, unknown> | 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
interface McpServerDetail {
|
||||
name: string;
|
||||
status: string;
|
||||
tools: McpToolSchema[];
|
||||
}
|
||||
|
||||
// ─── Cache ────────────────────────────────────────────────────────────────────
|
||||
|
||||
let serverListCache: McpServer[] | null = null;
|
||||
const serverDetailCache = new Map<string, McpServerDetail>();
|
||||
|
||||
// ─── 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<string> {
|
||||
// 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<string>): 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<string, unknown>;
|
||||
const mcpServers = (data.mcpServers ?? data.servers) as Record<string, Record<string, unknown>> | 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<McpServer[]> {
|
||||
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<McpServerDetail> {
|
||||
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<string, unknown>;
|
||||
const mcpServers = (data.mcpServers ?? data.servers) as Record<string, Record<string, unknown>> | 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="<tool_name>", 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<void>((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",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue