fix: discover MCP servers from project-root .mcp.json (#692)

The mcporter extension only discovered servers that the mcporter CLI
itself knew about (via .vscode/mcp.json, Claude Desktop config, etc.).
Servers configured in the standard .mcp.json at the project root —
used by Claude Code, Cursor, and other AI coding tools — were invisible.

Changes:

1. mcporter extension (index.ts):
   - Add readProjectMcpJson() that reads .mcp.json from cwd and returns
     servers not already discovered by mcporter
   - Merge .mcp.json servers into getServerList() results
   - Add getMcpJsonServerUrl() to resolve HTTP URLs for .mcp.json servers
   - Update getServerDetail() to pass HTTP URLs directly to mcporter
     for servers only known via .mcp.json
   - Update mcp_call to use HTTP URL as server reference for .mcp.json
     servers

2. discover_configs scanner (scanners.ts):
   - Add .mcp.json to the project-level MCP config scan path alongside
     .claude/.mcp.json and .claude/mcp.json

Closes #692
This commit is contained in:
Tom Boucher 2026-03-16 16:15:02 -04:00
parent a90aa0c8d6
commit 7b11faa150
2 changed files with 75 additions and 4 deletions

View file

@ -26,6 +26,8 @@ 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);
@ -99,6 +101,37 @@ async function runMcporter(
return stdout;
}
/**
* Read .mcp.json from the project root (cwd) and return servers not already
* discovered by mcporter. This bridges the gap where mcporter doesn't scan
* the standard .mcp.json config used by Claude Code, Cursor, etc.
*/
function readProjectMcpJson(knownNames: Set<string>): McpServer[] {
const servers: McpServer[] = [];
try {
const mcpJsonPath = join(process.cwd(), ".mcp.json");
if (!existsSync(mcpJsonPath)) return servers;
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") return servers;
for (const [name, config] of Object.entries(mcpServers)) {
if (knownNames.has(name)) continue; // Already discovered by mcporter
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 — .mcp.json may not exist or be malformed
}
return servers;
}
async function getServerList(signal?: AbortSignal): Promise<McpServer[]> {
if (serverListCache) return serverListCache;
@ -112,6 +145,14 @@ async function getServerList(signal?: AbortSignal): Promise<McpServer[]> {
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;
}
@ -122,12 +163,39 @@ async function getServerDetail(
): Promise<McpServerDetail> {
if (serverDetailCache.has(serverName)) return serverDetailCache.get(serverName)!;
const raw = await runMcporter(["list", serverName, "--schema", "--json"], signal);
// 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.";
@ -328,7 +396,10 @@ export default function (pi: ExtensionAPI) {
async execute(_id, params, signal) {
// Build mcporter call command: mcporter call server.tool key:value ...
const callTarget = `${params.server}.${params.tool}`;
// 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) {

View file

@ -166,8 +166,8 @@ async function scanClaude(projectRoot: string, home: string, tool: ToolInfo): Pr
}
}
// Project-level MCP: .claude/.mcp.json or .claude/mcp.json
for (const relPath of [".claude/.mcp.json", ".claude/mcp.json"]) {
// Project-level MCP: .mcp.json (standard), .claude/.mcp.json, or .claude/mcp.json
for (const relPath of [".mcp.json", ".claude/.mcp.json", ".claude/mcp.json"]) {
const fullPath = join(projectRoot, relPath);
const content = await readTextFile(fullPath);
if (content) {