fix(coding-agent): restored exa mcp fallback

Restored Exa's unauthenticated MCP fallback when neither auth storage nor EXA_API_KEY provides credentials. Preserved API-key search ordering and bounded the MCP request with the web-search hard timeout. Updated provider copy and regression coverage for the no-key path.\n\nFixes #1860
This commit is contained in:
roboomp
2026-06-04 13:18:08 +00:00
parent 482c95d375
commit cb2d5b859a
7 changed files with 177 additions and 27 deletions
+3 -3
View File
@@ -149,8 +149,8 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
- `limit` and `num_search_results` are collapsed together before dispatch.
- Output may include parsed free-text `answer`, `sources`, `requestId`.
- **Exa** — `packages/coding-agent/src/web/search/providers/exa.ts`
- Availability: env or `agent.db` credential for `exa`; settings must not explicitly disable `exa.enabled` or `exa.enableSearch`.
- Querying: POST `https://api.exa.ai/search` with the resolved Exa API key.
- Availability: settings must not explicitly disable `exa.enabled` or `exa.enableSearch`; Exa can use public MCP when no credential exists.
- Querying: POST `https://api.exa.ai/search` with the resolved Exa API key, otherwise JSON-RPC `tools/call` against `https://mcp.exa.ai/mcp` for remote MCP tool `web_search_exa`.
- `limit` and `num_search_results` are collapsed together before dispatch.
- Output: synthesized `answer` from up to 3 result summaries, `sources`, `requestId`.
- **Parallel** — `packages/coding-agent/src/web/search/providers/parallel.ts`, `packages/coding-agent/src/web/parallel.ts`
@@ -225,4 +225,4 @@ Streaming: none. `WebSearchTool.execute()` forwards its `AbortSignal` into `exec
- The prompt says `recency` is for Brave and Perplexity, but code also implements it for Tavily and SearXNG.
- The year rewrite in `executeSearch()` is blunt: any `2020`-`2029` substring is replaced with the current year.
- `packages/coding-agent/src/config/settings-schema.ts` uses the shared `SEARCH_PROVIDER_PREFERENCES` / `SEARCH_PROVIDER_OPTIONS` metadata, so the settings selector and setup wizard expose `auto` plus every provider in the auto chain.
- Exa requires an API key from the environment or credential store; it no longer falls back to unauthenticated MCP search.
- Exa uses `authStorage.getApiKey("exa")`, then `EXA_API_KEY`, then unauthenticated `https://mcp.exa.ai/mcp` fallback.
+1
View File
@@ -4,6 +4,7 @@
### Fixed
- Fixed Exa web search with no stored or environment credential to use the public Exa MCP fallback again, preserving the auth storage → `EXA_API_KEY` → `mcp.exa.ai` resolution order ([#1860](https://github.com/can1357/oh-my-pi/issues/1860)).
- Fixed `provider.appendOnlyContext: "auto"` staying inactive for Xiaomi Token Plan/SGLang endpoints, preserving prefix-cache hits without forcing append-only mode globally ([#1851](https://github.com/can1357/oh-my-pi/issues/1851)).
- Fixed `models.yml` compatibility parsing to preserve `compat.cacheControlFormat: "anthropic"` for custom OpenAI-compatible Claude proxies. ([#1845](https://github.com/can1357/oh-my-pi/issues/1845))
- Fixed the TUI's `Settings → Plugins` panel reporting "No plugins installed" when only marketplace plugins were installed. The panel now merges `PluginManager.list()` with `MarketplaceManager.listInstalledPlugins()` — the same data source the `/plugins list` slash command and `omp plugin list` CLI already used — and tags each row with an `[npm]` / `[marketplace]` kind badge, a scope tag, and a shadow indicator for project-shadowed user installs. Selecting a marketplace row opens a new `MarketplacePluginDetailComponent` whose single `Enabled` toggle calls `MarketplaceManager.setPluginEnabled(pluginId, enabled, scope)`, with read-only metadata (version, install path, installed-at, last-updated, git commit SHA) listed below the toggle. The empty-state now lists both install commands (`omp plugin install <package>` and `omp plugin install <name>@<marketplace>`) ([#1842](https://github.com/can1357/oh-my-pi/issues/1842)).
+11 -5
View File
@@ -1,7 +1,7 @@
import type { TSchema } from "@oh-my-pi/pi-ai";
import { $env, logger } from "@oh-my-pi/pi-utils";
import type { CustomTool, CustomToolResult } from "../extensibility/custom-tools/types";
import { callMCP } from "../mcp/json-rpc";
import { type CallMcpOptions, callMCP } from "../mcp/json-rpc";
import type {
ExaRenderDetails,
ExaSearchResponse,
@@ -105,15 +105,21 @@ export async function callExaTool(
toolName: string,
args: Record<string, unknown>,
apiKey: string | null,
options?: CallMcpOptions,
): Promise<unknown> {
const params = new URLSearchParams();
if (apiKey) params.set("exaApiKey", apiKey);
params.set("tools", toolName);
const url = `https://mcp.exa.ai/mcp?${params.toString()}`;
const response = (await callMCP(url, "tools/call", {
name: toolName,
arguments: args,
})) as MCPCallResponse;
const response = (await callMCP(
url,
"tools/call",
{
name: toolName,
arguments: args,
},
options,
)) as MCPCallResponse;
if (response.error) {
logger.error("MCP tools/call error", { toolName, args, error: response.error });
@@ -37,18 +37,25 @@ export interface JsonRpcResponse<T = unknown> {
};
}
/** Options controlling a single MCP JSON-RPC HTTP request. */
export interface CallMcpOptions {
signal?: AbortSignal;
}
/**
* Call an MCP server with JSON-RPC 2.0 over HTTPS.
*
* @param url - Full MCP server URL (including any query parameters)
* @param method - JSON-RPC method name (e.g., "tools/list", "tools/call")
* @param params - Method parameters
* @param options - Optional transport controls such as cancellation.
* @returns Parsed JSON-RPC response
*/
export async function callMCP<T = unknown>(
url: string,
method: string,
params?: Record<string, unknown>,
options?: CallMcpOptions,
): Promise<JsonRpcResponse<T>> {
const body = {
jsonrpc: "2.0",
@@ -64,6 +71,7 @@ export async function callMCP<T = unknown>(
Accept: "application/json, text/event-stream",
},
body: JSON.stringify(body),
signal: options?.signal,
});
if (!response.ok) {
@@ -8,6 +8,7 @@
*/
import { type AuthStorage, getEnvApiKey } from "@oh-my-pi/pi-ai";
import { settings } from "../../../config/settings";
import { callExaTool, findApiKey, isSearchResponse } from "../../../exa/mcp-client";
import type { SearchResponse, SearchSource } from "../../../web/search/types";
import { SearchProviderError } from "../../../web/search/types";
@@ -56,6 +57,71 @@ interface ExaSearchResponse {
costDollars?: { total: number };
searchTime?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null) return null;
return value as Record<string, unknown>;
}
function parseOptionalField(section: string, label: string): string | null | undefined {
const regex = new RegExp(`(?:^|\\n)${label}:\\s*([^\\n]*)`);
const match = section.match(regex);
if (!match) return undefined;
const value = match[1].trim();
return value.length > 0 ? value : null;
}
function parseTextField(section: string): string | null | undefined {
const match = section.match(/(?:^|\n)Text:\s*([\s\S]*)$/);
if (!match) return undefined;
const value = match[1].trim();
return value.length > 0 ? value : null;
}
function parseExaMcpTextPayload(payload: unknown): ExaSearchResponse | null {
const root = asRecord(payload);
if (!root) return null;
const content = root.content;
if (!Array.isArray(content)) return null;
const textBlocks = content
.map(item => {
const part = asRecord(item);
const text = typeof part?.text === "string" ? part.text : "";
return text.replace(/\r\n?/g, "\n").trim();
})
.filter(text => text.length > 0);
if (textBlocks.length === 0) return null;
const sections = textBlocks
.join("\n\n")
.split(/\n{2,}(?=Title:\s*[^\n]*(?:\n(?:URL|Author|Published Date|Text):))/)
.map(section => section.trim())
.filter(section => section.startsWith("Title:"));
const results: ExaSearchResult[] = [];
for (const section of sections) {
const title = parseOptionalField(section, "Title");
const url = parseOptionalField(section, "URL");
const author = parseOptionalField(section, "Author");
const publishedDate = parseOptionalField(section, "Published Date");
const text = parseTextField(section);
if (!title && !url && !text) continue;
results.push({
title: title ?? undefined,
url: url ?? undefined,
author: author ?? undefined,
publishedDate: publishedDate ?? undefined,
text: text ?? undefined,
});
}
if (results.length === 0) return null;
return { results };
}
export function normalizeSearchType(type: ExaSearchParamType | undefined): ExaSearchType {
if (!type) return "auto";
@@ -133,6 +199,32 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise<E
return response.json() as Promise<ExaSearchResponse>;
}
function buildExaMcpArgs(params: ExaSearchParams): Record<string, unknown> {
const args: Record<string, unknown> = { query: params.query };
if (params.num_results !== undefined) args.num_results = params.num_results;
if (params.type !== undefined) args.type = params.type;
if (params.include_domains !== undefined) args.include_domains = params.include_domains;
if (params.exclude_domains !== undefined) args.exclude_domains = params.exclude_domains;
if (params.start_published_date !== undefined) args.start_published_date = params.start_published_date;
if (params.end_published_date !== undefined) args.end_published_date = params.end_published_date;
return args;
}
async function callExaMcpSearch(params: ExaSearchParams): Promise<ExaSearchResponse> {
const response = await callExaTool("web_search_exa", buildExaMcpArgs(params), findApiKey(), {
signal: withHardTimeout(params.signal),
});
if (isSearchResponse(response)) {
return response as ExaSearchResponse;
}
const parsed = parseExaMcpTextPayload(response);
if (parsed) {
return parsed;
}
throw new Error("Exa MCP search returned unexpected response shape.");
}
/** Execute Exa web search */
export async function searchExa(params: ExaSearchParams): Promise<SearchResponse> {
@@ -140,11 +232,7 @@ export async function searchExa(params: ExaSearchParams): Promise<SearchResponse
? await params.authStorage.getApiKey("exa", params.sessionId, { signal: params.signal })
: undefined;
const apiKey = storedKey ?? getEnvApiKey("exa");
if (!apiKey) {
throw new Error("Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.");
}
const response = await callExaSearch(apiKey, params);
const response = apiKey ? await callExaSearch(apiKey, params) : await callExaMcpSearch(params);
// Convert to unified SearchResponse
const sources: SearchSource[] = [];
@@ -182,15 +270,15 @@ export class ExaProvider extends SearchProvider {
readonly id = "exa";
readonly label = "Exa";
isAvailable(authStorage: AuthStorage): boolean {
isAvailable(_authStorage: AuthStorage): boolean {
try {
if (settings.get("exa.enabled") === false || settings.get("exa.enableSearch") === false) {
return false;
}
} catch {
// Settings may be unavailable before CLI initialization; credential availability is still authoritative.
// Settings may be unavailable before CLI initialization; public MCP fallback remains available.
}
return authStorage.hasAuth("exa");
return true;
}
search(params: SearchParams): Promise<SearchResponse> {
@@ -53,7 +53,7 @@ export const SEARCH_PROVIDER_OPTIONS = [
description: "OpenAI's native web_search (uses ChatGPT OAuth via /login openai-codex)",
},
{ value: "zai", label: "Z.AI", description: "Calls Z.AI webSearchPrime MCP" },
{ value: "exa", label: "Exa", description: "Requires EXA_API_KEY" },
{ value: "exa", label: "Exa", description: "Uses Exa API when EXA_API_KEY is set; falls back to Exa MCP" },
{ value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
{ value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
{ value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
@@ -366,20 +366,67 @@ describe("searchExa", () => {
expect(result.answer).toContain("**Has URL**: real summary");
});
it("requires Exa credentials before starting a search", async () => {
it("uses Exa MCP when API key is missing", async () => {
delete process.env.EXA_API_KEY;
const fetchSpy = vi.fn(async () => {
return new Response(JSON.stringify(makeMockExaResponse()), {
let calledUrl = "";
using _hook = hookFetch((url, init) => {
calledUrl = String(url);
if (init?.body) {
capturedRequestBody = JSON.parse(init.body as string);
}
return new Response(JSON.stringify({ jsonrpc: "2.0", id: "mcp-1", result: makeMockExaResponse() }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
});
using _hook = hookFetch(fetchSpy);
await expect(searchExa({ query: "no key" })).rejects.toThrow(
"Exa credentials not found. Set EXA_API_KEY or login with 'omp /login exa'.",
);
expect(fetchSpy).not.toHaveBeenCalled();
const result = await searchExa({ query: "no key" });
expect(result.provider).toBe("exa");
expect(result.sources).toHaveLength(3);
expect(calledUrl).toContain("https://mcp.exa.ai/mcp");
expect(calledUrl).toContain("tools=web_search_exa");
expect(calledUrl).not.toContain("exaApiKey=");
expect(capturedRequestBody?.method).toBe("tools/call");
expect(capturedRequestBody?.params).toEqual({
name: "web_search_exa",
arguments: { query: "no key" },
});
});
it("parses Exa MCP plain-text payloads when API key is missing", async () => {
delete process.env.EXA_API_KEY;
using _hook = hookFetch(() => {
return new Response(
JSON.stringify({
jsonrpc: "2.0",
id: "mcp-text",
result: {
content: [
{
type: "text",
text: "Title: Plain Result\nURL: https://plain.example\nAuthor: Reporter\nPublished Date: 2024-06-01\nText: Plain text body",
},
],
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
});
const result = await searchExa({ query: "plain text" });
expect(result.provider).toBe("exa");
expect(result.sources).toEqual([
{
title: "Plain Result",
url: "https://plain.example",
snippet: "Plain text body",
publishedDate: "2024-06-01",
ageSeconds: expect.any(Number),
author: "Reporter",
},
]);
});
it("uses AuthStorage credentials when EXA_API_KEY is unset", async () => {
@@ -402,12 +449,12 @@ describe("searchExa", () => {
expect(receivedKey).toBe("stored-key-xyz");
});
it("reports unavailable without EXA_API_KEY or stored credentials", async () => {
it("reports available without EXA_API_KEY or stored credentials", async () => {
delete process.env.EXA_API_KEY;
const available = await withLocalAuthStorage(authStorage =>
Promise.resolve(new ExaProvider().isAvailable(authStorage)),
);
expect(available).toBe(false);
expect(available).toBe(true);
});
it("reports available with EXA_API_KEY", async () => {