/** * MCP configuration loader. * * Uses the capability system to load MCP servers from multiple sources. */ import { getMCPConfigPath } from "@oh-my-pi/pi-utils"; import { mcpCapability } from "../capability/mcp"; import type { SourceMeta } from "../capability/types"; import type { MCPServer } from "../discovery"; import { loadCapability } from "../discovery"; import { readDisabledServers } from "./config-writer"; import type { MCPServerConfig } from "./types"; /** Options for loading MCP configs */ export interface LoadMCPConfigsOptions { /** Whether to load project-level config (default: true) */ enableProjectConfig?: boolean; /** Whether to filter out Exa MCP servers (default: true) */ filterExa?: boolean; /** Whether to filter out browser MCP servers when builtin browser tool is enabled (default: false) */ filterBrowser?: boolean; } /** Result of loading MCP configs */ export interface LoadMCPConfigsResult { /** Loaded server configs */ configs: Record; /** Extracted Exa API keys (if any were filtered) */ exaApiKeys: string[]; /** Source metadata for each server */ sources: Record; } /** * Convert canonical MCPServer to legacy MCPServerConfig. */ function convertToLegacyConfig(server: MCPServer): MCPServerConfig { // Determine transport type const transport = server.transport ?? (server.command ? "stdio" : server.url ? "http" : "stdio"); const shared = { enabled: server.enabled, timeout: server.timeout, auth: server.auth, oauth: server.oauth, }; if (transport === "stdio") { const config: MCPServerConfig = { ...shared, type: "stdio" as const, command: server.command ?? "", }; if (server.args) config.args = server.args; if (server.env) config.env = server.env; if (server.cwd) config.cwd = server.cwd; return config; } if (transport === "http") { const config: MCPServerConfig = { ...shared, type: "http" as const, url: server.url ?? "", }; if (server.headers) config.headers = server.headers; return config; } if (transport === "sse") { const config: MCPServerConfig = { ...shared, type: "sse" as const, url: server.url ?? "", }; if (server.headers) config.headers = server.headers; return config; } // Fallback to stdio return { ...shared, type: "stdio" as const, command: server.command ?? "", }; } /** * Load all MCP server configs from standard locations. * Uses the capability system for multi-source discovery. * * @param cwd Working directory (project root) * @param options Load options */ export async function loadAllMCPConfigs(cwd: string, options?: LoadMCPConfigsOptions): Promise { const enableProjectConfig = options?.enableProjectConfig ?? true; const filterExa = options?.filterExa ?? true; const filterBrowser = options?.filterBrowser ?? false; // Load MCP servers via capability system const result = await loadCapability(mcpCapability.id, { cwd }); // Filter out project-level configs if disabled const servers = enableProjectConfig ? result.items : result.items.filter(server => server._source.level !== "project"); // Load user-level disabled servers list const disabledServers = new Set(await readDisabledServers(getMCPConfigPath("user", cwd))); // Convert to legacy format and preserve source metadata let configs: Record = {}; let sources: Record = {}; for (const server of servers) { const config = convertToLegacyConfig(server); if (config.enabled === false || disabledServers.has(server.name)) { continue; } configs[server.name] = config; sources[server.name] = server._source; } let exaApiKeys: string[] = []; if (filterExa) { const exaResult = filterExaMCPServers(configs, sources); configs = exaResult.configs; sources = exaResult.sources; exaApiKeys = exaResult.exaApiKeys; } if (filterBrowser) { const browserResult = filterBrowserMCPServers(configs, sources); configs = browserResult.configs; sources = browserResult.sources; } return { configs, exaApiKeys, sources }; } /** Pattern to match Exa MCP servers */ const EXA_MCP_URL_PATTERN = /mcp\.exa\.ai/i; const EXA_API_KEY_PATTERN = /exaApiKey=([^&\s]+)/i; /** * Check if a server config is an Exa MCP server. */ export function isExaMCPServer(name: string, config: MCPServerConfig): boolean { // Check by server name if (name.toLowerCase() === "exa") { return true; } // Check by URL for HTTP/SSE servers if (config.type === "http" || config.type === "sse") { const httpConfig = config as { url?: string }; if (httpConfig.url && EXA_MCP_URL_PATTERN.test(httpConfig.url)) { return true; } } // Check by args for stdio servers (e.g., mcp-remote to exa) if (!config.type || config.type === "stdio") { const stdioConfig = config as { args?: string[] }; if (stdioConfig.args?.some(arg => EXA_MCP_URL_PATTERN.test(arg))) { return true; } } return false; } /** * Extract Exa API key from an MCP server config. */ export function extractExaApiKey(config: MCPServerConfig): string | undefined { // Check URL for HTTP/SSE servers if (config.type === "http" || config.type === "sse") { const httpConfig = config as { url?: string }; if (httpConfig.url) { const match = EXA_API_KEY_PATTERN.exec(httpConfig.url); if (match) return match[1]; } } // Check args for stdio servers if (!config.type || config.type === "stdio") { const stdioConfig = config as { args?: string[] }; if (stdioConfig.args) { for (const arg of stdioConfig.args) { const match = EXA_API_KEY_PATTERN.exec(arg); if (match) return match[1]; } } } // Check env vars if ("env" in config && config.env) { const envConfig = config as { env: Record }; if (envConfig.env.EXA_API_KEY) { return envConfig.env.EXA_API_KEY; } } return undefined; } /** Result of filtering Exa MCP servers */ export interface ExaFilterResult { /** Configs with Exa servers removed */ configs: Record; /** Extracted Exa API keys (if any) */ exaApiKeys: string[]; /** Source metadata for remaining servers */ sources: Record; } /** * Filter out Exa MCP servers and extract their API keys. * Since we have native Exa integration, we don't need the MCP server. */ export function filterExaMCPServers( configs: Record, sources: Record, ): ExaFilterResult { const filtered: Record = {}; const filteredSources: Record = {}; const exaApiKeys: string[] = []; for (const [name, config] of Object.entries(configs)) { if (isExaMCPServer(name, config)) { // Extract API key before filtering const apiKey = extractExaApiKey(config); if (apiKey) { exaApiKeys.push(apiKey); } } else { filtered[name] = config; if (sources[name]) { filteredSources[name] = sources[name]; } } } return { configs: filtered, exaApiKeys, sources: filteredSources }; } /** * Validate server config has required fields. */ export function validateServerConfig(name: string, config: MCPServerConfig): string[] { const errors: string[] = []; const serverType = config.type ?? "stdio"; // Check for conflicting transport fields const hasCommand = "command" in config && config.command; const hasUrl = "url" in config && (config as { url?: string }).url; if (hasCommand && hasUrl) { errors.push( `Server "${name}": both "command" and "url" are set - server should be either stdio (command) OR http/sse (url), not both`, ); } if (serverType === "stdio") { const stdioConfig = config as { command?: string }; if (!stdioConfig.command) { errors.push(`Server "${name}": stdio server requires "command" field`); } } else if (serverType === "http" || serverType === "sse") { const httpConfig = config as { url?: string }; if (!httpConfig.url) { errors.push(`Server "${name}": ${serverType} server requires "url" field`); } } else { errors.push(`Server "${name}": unknown server type "${serverType}"`); } return errors; } /** Known browser automation MCP server names (lowercase) */ const BROWSER_MCP_NAMES = new Set([ "puppeteer", "playwright", "browserbase", "browser-tools", "browser-use", "browser", ]); /** Patterns matching browser MCP package names in command/args */ const BROWSER_MCP_PKG_PATTERN = // Official packages // - @modelcontextprotocol/server-puppeteer // - @playwright/mcp // - @browserbasehq/mcp-server-browserbase // - @agentdeskai/browser-tools-mcp // - @agent-infra/mcp-server-browser // Community packages: puppeteer-mcp-server, playwright-mcp, pptr-mcp, etc. /(?:@modelcontextprotocol\/server-puppeteer|@playwright\/mcp|@browserbasehq\/mcp-server-browserbase|@agentdeskai\/browser-tools-mcp|@agent-infra\/mcp-server-browser|puppeteer-mcp|playwright-mcp|pptr-mcp|browser-use-mcp|mcp-browser-use)/i; /** URL patterns for hosted browser MCP services */ const BROWSER_MCP_URL_PATTERN = /browserbase\.com|browser-use\.com/i; /** * Check if a server config is a browser automation MCP server. */ export function isBrowserMCPServer(name: string, config: MCPServerConfig): boolean { // Check by server name if (BROWSER_MCP_NAMES.has(name.toLowerCase())) { return true; } // Check by URL for HTTP/SSE servers if (config.type === "http" || config.type === "sse") { const httpConfig = config as { url?: string }; if (httpConfig.url && BROWSER_MCP_URL_PATTERN.test(httpConfig.url)) { return true; } } // Check by command/args for stdio servers if (!config.type || config.type === "stdio") { const stdioConfig = config as { command?: string; args?: string[] }; if (stdioConfig.command && BROWSER_MCP_PKG_PATTERN.test(stdioConfig.command)) { return true; } if (stdioConfig.args?.some(arg => BROWSER_MCP_PKG_PATTERN.test(arg))) { return true; } } return false; } /** Result of filtering browser MCP servers */ export interface BrowserFilterResult { /** Configs with browser servers removed */ configs: Record; /** Source metadata for remaining servers */ sources: Record; } /** * Filter out browser automation MCP servers. * Since we have a native browser tool, we don't need these MCP servers. */ export function filterBrowserMCPServers( configs: Record, sources: Record, ): BrowserFilterResult { const filtered: Record = {}; const filteredSources: Record = {}; for (const [name, config] of Object.entries(configs)) { if (!isBrowserMCPServer(name, config)) { filtered[name] = config; if (sources[name]) { filteredSources[name] = sources[name]; } } } return { configs: filtered, sources: filteredSources }; }