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:
TÂCHES 2026-03-18 12:26:16 -06:00 committed by GitHub
parent d3780c9bdb
commit 28c741c196
5 changed files with 474 additions and 527 deletions

View file

@ -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 |

View file

@ -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 |

View file

@ -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"),

View 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;
});
}

View file

@ -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",
);
}
}
});
}