diff --git a/docs/mcp-runtime-lifecycle.md b/docs/mcp-runtime-lifecycle.md index 45b74dc42..e4005d296 100644 --- a/docs/mcp-runtime-lifecycle.md +++ b/docs/mcp-runtime-lifecycle.md @@ -42,7 +42,7 @@ Filtering behavior: - `enableProjectConfig: false` removes project-level entries (`_source.level === "project"`). - `enabled: false` entries are suppressed unless the active-profile user `enabledServers` allowlist names them; the user `disabledServers` denylist always suppresses a same-named entry. -- Exa servers are filtered out by default and API keys are extracted for native Exa tool integration; browser automation MCP servers are filtered when `filterBrowser` is true. +- Exa servers are filtered out by default and API keys are extracted for native Exa tool integration, unless the config explicitly requests Exa tools the native integration does not provide (`web_fetch_exa`, `web_search_advanced_exa`); browser automation MCP servers are filtered when `filterBrowser` is true. Result includes both `configs` and `sources` (metadata used later for provider labeling). diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f6108547..4efb4893c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Kept Exa MCP servers mounted when their config explicitly requests tools the native Exa integration does not provide (`web_fetch_exa`, `web_search_advanced_exa`), instead of filtering them out so `/mcp reconnect exa` could never connect. + ## [17.3.3] - 2026-08-14 ### Fixed diff --git a/packages/coding-agent/src/mcp/config.ts b/packages/coding-agent/src/mcp/config.ts index b7fe093f2..a23eee852 100644 --- a/packages/coding-agent/src/mcp/config.ts +++ b/packages/coding-agent/src/mcp/config.ts @@ -224,6 +224,41 @@ export function extractExaApiKey(config: MCPServerConfig): string | undefined { return undefined; } +/** Exa MCP tools already covered by the native Exa integration. */ +const NATIVE_EXA_MCP_TOOLS: Record = { 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[] }; + for (const arg of stdioConfig.args ?? []) { + const match = arg.match(/(?:^|[\s?&])tools=([^&\s]+)/i) ?? arg.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 */ @@ -236,7 +271,9 @@ export interface ExaFilterResult { /** * Filter out Exa MCP servers and extract their API keys. - * Since we have native Exa integration, we don't need the MCP server. + * 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, @@ -248,17 +285,22 @@ export function filterExaMCPServers( for (const [name, config] of Object.entries(configs)) { if (isExaMCPServer(name, config)) { - // Extract API key before filtering + // 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); } - } else { - filtered[name] = config; - if (sources[name]) { - filteredSources[name] = sources[name]; + 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 }; diff --git a/packages/coding-agent/test/mcp-exa-filter.test.ts b/packages/coding-agent/test/mcp-exa-filter.test.ts new file mode 100644 index 000000000..4ea89a8c7 --- /dev/null +++ b/packages/coding-agent/test/mcp-exa-filter.test.ts @@ -0,0 +1,66 @@ +/** + * Regression: Exa MCP servers are filtered out by default because the native + * Exa integration covers `web_search_exa`. But a config that explicitly + * requests Exa tools the native integration does NOT provide (e.g. + * `web_fetch_exa`, `web_search_advanced_exa`) must stay mounted as an MCP + * server instead of being dropped. + */ +import { describe, expect, test } from "bun:test"; +import type { SourceMeta } from "@oh-my-pi/pi-coding-agent/capability/types"; +import { filterExaMCPServers } from "@oh-my-pi/pi-coding-agent/mcp/config"; +import type { MCPServerConfig } from "@oh-my-pi/pi-coding-agent/mcp/types"; + +const SOURCE: SourceMeta = { + provider: "test", + providerName: "Test", + path: "/tmp/mcp.json", + level: "user", +}; + +describe("Exa MCP filtering", () => { + test("filters an exa server restricted to the native web_search_exa tool", () => { + const configs: Record = { + exa: { type: "http", url: "https://mcp.exa.ai/mcp?tools=web_search_exa&exaApiKey=sk-1" }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(result.configs).toEqual({}); + expect(result.exaApiKeys).toEqual(["sk-1"]); + }); + + test("keeps an exa server that requests tools beyond the native integration", () => { + const configs: Record = { + exa: { + type: "http", + url: "https://mcp.exa.ai/mcp?tools=web_search_exa,web_fetch_exa,web_search_advanced_exa&exaApiKey=sk-1", + }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(Object.keys(result.configs)).toEqual(["exa"]); + expect(result.sources.exa).toEqual(SOURCE); + expect(result.exaApiKeys).toEqual(["sk-1"]); + }); + + test("filters an exa server with no tools restriction", () => { + const configs: Record = { + exa: { type: "http", url: "https://mcp.exa.ai/mcp" }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(result.configs).toEqual({}); + }); + + test("keeps a stdio exa server that requests extra tools", () => { + const configs: Record = { + exa: { + type: "stdio", + command: "npx", + args: ["-y", "exa-mcp-server", "--tools=web_search_exa,web_fetch_exa"], + }, + }; + const result = filterExaMCPServers(configs, { exa: SOURCE }); + + expect(Object.keys(result.configs)).toEqual(["exa"]); + }); +});