66c80fd613
fixes #462
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
/**
|
|
* 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<string, MCPServerConfig>;
|
|
/** Extracted Exa API keys (if any were filtered) */
|
|
exaApiKeys: string[];
|
|
/** Source metadata for each server */
|
|
sources: Record<string, SourceMeta>;
|
|
}
|
|
|
|
/**
|
|
* 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<LoadMCPConfigsResult> {
|
|
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<MCPServer>(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<string, MCPServerConfig> = {};
|
|
let sources: Record<string, SourceMeta> = {};
|
|
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<string, string> };
|
|
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<string, MCPServerConfig>;
|
|
/** Extracted Exa API keys (if any) */
|
|
exaApiKeys: string[];
|
|
/** Source metadata for remaining servers */
|
|
sources: Record<string, SourceMeta>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, MCPServerConfig>,
|
|
sources: Record<string, SourceMeta>,
|
|
): ExaFilterResult {
|
|
const filtered: Record<string, MCPServerConfig> = {};
|
|
const filteredSources: Record<string, SourceMeta> = {};
|
|
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<string, MCPServerConfig>;
|
|
/** Source metadata for remaining servers */
|
|
sources: Record<string, SourceMeta>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, MCPServerConfig>,
|
|
sources: Record<string, SourceMeta>,
|
|
): BrowserFilterResult {
|
|
const filtered: Record<string, MCPServerConfig> = {};
|
|
const filteredSources: Record<string, SourceMeta> = {};
|
|
|
|
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 };
|
|
}
|