From cb2d5b859af57b3620d92c347ef7224402b0131e Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 4 Jun 2026 13:18:08 +0000 Subject: [PATCH] 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 --- docs/tools/web_search.md | 6 +- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/exa/mcp-client.ts | 16 ++- packages/coding-agent/src/mcp/json-rpc.ts | 8 ++ .../src/web/search/providers/exa.ts | 104 ++++++++++++++++-- packages/coding-agent/src/web/search/types.ts | 2 +- .../test/tools/web-search-exa.test.ts | 67 +++++++++-- 7 files changed, 177 insertions(+), 27 deletions(-) diff --git a/docs/tools/web_search.md b/docs/tools/web_search.md index 9099280cb..b599e4ac2 100644 --- a/docs/tools/web_search.md +++ b/docs/tools/web_search.md @@ -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. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f81b8308c..4fdfc1923 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 ` and `omp plugin install @`) ([#1842](https://github.com/can1357/oh-my-pi/issues/1842)). diff --git a/packages/coding-agent/src/exa/mcp-client.ts b/packages/coding-agent/src/exa/mcp-client.ts index 3ee80ae1f..fc69cd238 100644 --- a/packages/coding-agent/src/exa/mcp-client.ts +++ b/packages/coding-agent/src/exa/mcp-client.ts @@ -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, apiKey: string | null, + options?: CallMcpOptions, ): Promise { 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 }); diff --git a/packages/coding-agent/src/mcp/json-rpc.ts b/packages/coding-agent/src/mcp/json-rpc.ts index 838901483..6acd3d916 100644 --- a/packages/coding-agent/src/mcp/json-rpc.ts +++ b/packages/coding-agent/src/mcp/json-rpc.ts @@ -37,18 +37,25 @@ export interface JsonRpcResponse { }; } +/** 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( url: string, method: string, params?: Record, + options?: CallMcpOptions, ): Promise> { const body = { jsonrpc: "2.0", @@ -64,6 +71,7 @@ export async function callMCP( Accept: "application/json, text/event-stream", }, body: JSON.stringify(body), + signal: options?.signal, }); if (!response.ok) { diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index bb25d761a..8f60ad6b2 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -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 | null { + if (typeof value !== "object" || value === null) return null; + return value as Record; +} + +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; } +function buildExaMcpArgs(params: ExaSearchParams): Record { + const args: Record = { 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 { + 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 { @@ -140,11 +232,7 @@ export async function searchExa(params: ExaSearchParams): Promise { diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 67562d483..7ccb2d5d4 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -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" }, diff --git a/packages/coding-agent/test/tools/web-search-exa.test.ts b/packages/coding-agent/test/tools/web-search-exa.test.ts index 1962db2f0..c3d9f38a9 100644 --- a/packages/coding-agent/test/tools/web-search-exa.test.ts +++ b/packages/coding-agent/test/tools/web-search-exa.test.ts @@ -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 () => {