Files
oh-my-pi/packages/coding-agent/src/mcp/config.ts
T
can1357 f399b8e323 fix(coding-agent): fixes for PRs 80, 82, 85
- Parse --full as exact token in /changelog command
- Scope MCP disabled list to discovered servers only
- Guard malformed disabledServers config field
- Parse regex literal /pattern/flags syntax in secret entries
- Prevent infinite loop on zero-length regex matches
- Replace longer plain secrets before shorter prefixes
- Default secrets.enabled to false
2026-02-16 19:23:37 +01:00

267 lines
7.6 KiB
TypeScript

/**
* MCP configuration loader.
*
* Uses the capability system to load MCP servers from multiple sources.
*/
import { getMCPConfigPath } from "@oh-my-pi/pi-utils/dirs";
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;
}
/** 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,
};
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;
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;
// 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
const configs: Record<string, MCPServerConfig> = {};
const sources: Record<string, SourceMeta> = {};
for (const server of servers) {
const config = convertToLegacyConfig(server);
if (config.enabled === false || (server._source.level !== "user" && disabledServers.has(server.name))) {
continue;
}
configs[server.name] = config;
sources[server.name] = server._source;
}
const exaApiKeys: string[] = [];
if (filterExa) {
const filterResult = filterExaMCPServers(configs, sources);
return { configs: filterResult.configs, exaApiKeys: filterResult.exaApiKeys, sources: filterResult.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;
}