430 lines
13 KiB
TypeScript
430 lines
13 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, readEnabledServers } 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,
|
|
requestIdFormat: server.requestIdFormat,
|
|
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.envPolicy) config.envPolicy = server.envPolicy;
|
|
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;
|
|
if (server.headerPolicy) config.headerPolicy = server.headerPolicy;
|
|
return config;
|
|
}
|
|
|
|
if (transport === "sse") {
|
|
const config: MCPServerConfig = {
|
|
...shared,
|
|
type: "sse" as const,
|
|
url: server.url ?? "",
|
|
};
|
|
if (server.headers) config.headers = server.headers;
|
|
if (server.headerPolicy) config.headerPolicy = server.headerPolicy;
|
|
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 user-level disable/force-enable lists. The denylist always wins; the
|
|
// allowlist overrides a non-writable source config's `enabled: false`.
|
|
const userPath = getMCPConfigPath("user", cwd);
|
|
const [disabledServers, forcedEnabled] = await Promise.all([
|
|
readDisabledServers(userPath).then(list => new Set(list)),
|
|
readEnabledServers(userPath).then(list => new Set(list)),
|
|
]);
|
|
|
|
// Scope exclusions drop entries entirely BEFORE deduplication: with project
|
|
// config disabled, a project entry must not shadow anything.
|
|
const includeServer = (server: MCPServer & { _source: SourceMeta }): boolean =>
|
|
enableProjectConfig || server._source.level !== "project";
|
|
|
|
// Disabled servers are suppressed rather than dropped: they still own their
|
|
// name at key-level dedupe (a disabled project `foo` keeps a same-named,
|
|
// lower-priority user `foo` disabled), but never equivalence-shadow a
|
|
// differently-named enabled server — otherwise the disabled alias would be
|
|
// removed downstream and starve the surviving connection.
|
|
const suppressServer = (server: MCPServer & { _source: SourceMeta }): boolean => {
|
|
if (disabledServers.has(server.name)) return true;
|
|
if (server.enabled === false && !forcedEnabled.has(server.name)) return true;
|
|
return false;
|
|
};
|
|
|
|
const result = await loadCapability<MCPServer>(mcpCapability.id, {
|
|
cwd,
|
|
filter: includeServer,
|
|
suppress: suppressServer,
|
|
});
|
|
|
|
// Convert to legacy format and preserve source metadata.
|
|
let configs: Record<string, MCPServerConfig> = {};
|
|
let sources: Record<string, SourceMeta> = {};
|
|
for (const server of result.items) {
|
|
configs[server.name] = convertToLegacyConfig(server);
|
|
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;
|
|
}
|
|
|
|
/** Exa MCP tools already covered by the native Exa integration. */
|
|
const NATIVE_EXA_MCP_TOOLS: Record<string, true> = { web_search_exa: true };
|
|
|
|
/**
|
|
* Parse the comma-separated `tools` restriction from an Exa MCP config.
|
|
* Returns `null` when the config does not restrict its tool set.
|
|
*/
|
|
function getRequestedExaMcpTools(config: MCPServerConfig): string[] | null {
|
|
const raw = (() => {
|
|
if (config.type === "http" || config.type === "sse") {
|
|
const httpConfig = config as { url?: string };
|
|
if (!httpConfig.url) return undefined;
|
|
try {
|
|
return new URL(httpConfig.url).searchParams.get("tools") ?? undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
if (!config.type || config.type === "stdio") {
|
|
const stdioConfig = config as { args?: string[] };
|
|
const args = stdioConfig.args ?? [];
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (/^--?tools$/i.test(args[i])) return args[i + 1];
|
|
const match = args[i].match(/(?:^|[\s?&])tools=([^&\s]+)/i) ?? args[i].match(/--?tools[=\s]([^\s]+)/i);
|
|
if (match) return match[1];
|
|
}
|
|
}
|
|
return undefined;
|
|
})();
|
|
if (!raw) return null;
|
|
const tools = raw
|
|
.split(",")
|
|
.map(tool => tool.trim())
|
|
.filter(tool => tool.length > 0);
|
|
return tools.length > 0 ? tools : null;
|
|
}
|
|
|
|
/** 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 —
|
|
* unless the config explicitly requests Exa tools the native integration
|
|
* does not provide (e.g. `web_fetch_exa`, `web_search_advanced_exa`).
|
|
*/
|
|
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 for the native Exa integration even when the MCP
|
|
// server is kept below for its extra tools.
|
|
const apiKey = extractExaApiKey(config);
|
|
if (apiKey) {
|
|
exaApiKeys.push(apiKey);
|
|
}
|
|
const requested = getRequestedExaMcpTools(config);
|
|
const hasExtraTools = requested?.some(tool => !NATIVE_EXA_MCP_TOOLS[tool.toLowerCase()]) ?? false;
|
|
if (!hasExtraTools) {
|
|
continue;
|
|
}
|
|
}
|
|
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 };
|
|
}
|