fix(mcp): keep Exa MCP servers that request non-native tools
Exa MCP servers were always filtered out because the native Exa integration covers web_search_exa. But configs that explicitly request web_fetch_exa or web_search_advanced_exa have no native equivalent, so filtering them made /mcp reconnect exa fail and hid those tools. Keep the MCP server mounted when its tools restriction includes anything beyond web_search_exa, while still extracting the API key for native search.
This commit is contained in:
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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[] };
|
||||
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<string, MCPServerConfig>,
|
||||
@@ -248,18 +285,23 @@ 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 {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<string, MCPServerConfig> = {
|
||||
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<string, MCPServerConfig> = {
|
||||
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<string, MCPServerConfig> = {
|
||||
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<string, MCPServerConfig> = {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user