Files
oh-my-pi/packages/coding-agent/src/mcp/config.ts
T
2026-08-16 02:13:38 +02:00

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