feat: add ExaMCP free web search provider (#11874)

*  feat: add ExaMCP free web search provider

Add a new web search provider that uses Exa's free MCP API endpoint,
requiring no API key. This provides users with a free alternative
to the existing Exa provider.

- Add 'exa-mcp' to WebSearchProviderIds
- Create ExaMcpProvider using JSON-RPC/SSE protocol
- Add provider config and migration for existing users
- Use same Exa logo in settings UI

* Add robust text chunk parser for ExaMcpProvider results
This commit is contained in:
LiuVaayne 2025-12-16 09:28:42 +08:00 committed by GitHub
parent 71df9d61fd
commit aeebd343d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 250 additions and 1 deletions

View File

@ -31,6 +31,11 @@ export const WEB_SEARCH_PROVIDER_CONFIG: Record<WebSearchProviderId, WebSearchPr
apiKey: 'https://dashboard.exa.ai/api-keys'
}
},
'exa-mcp': {
websites: {
official: 'https://exa.ai'
}
},
bocha: {
websites: {
official: 'https://bochaai.com',
@ -80,6 +85,11 @@ export const WEB_SEARCH_PROVIDERS: WebSearchProvider[] = [
apiHost: 'https://api.exa.ai',
apiKey: ''
},
{
id: 'exa-mcp',
name: 'ExaMCP',
apiHost: 'https://mcp.exa.ai/mcp'
},
{
id: 'bocha',
name: 'Bocha',

View File

@ -145,6 +145,7 @@ const WebSearchProviderSetting: FC<Props> = ({ providerId }) => {
case 'searxng':
return SearxngLogo
case 'exa':
case 'exa-mcp':
return ExaLogo
case 'bocha':
return BochaLogo

View File

@ -0,0 +1,209 @@
import { loggerService } from '@logger'
import type { WebSearchState } from '@renderer/store/websearch'
import type { WebSearchProvider, WebSearchProviderResponse } from '@renderer/types'
import BaseWebSearchProvider from './BaseWebSearchProvider'
const logger = loggerService.withContext('ExaMcpProvider')
interface McpSearchRequest {
jsonrpc: string
id: number
method: string
params: {
name: string
arguments: {
query: string
numResults?: number
livecrawl?: 'fallback' | 'preferred'
type?: 'auto' | 'fast' | 'deep'
}
}
}
interface McpSearchResponse {
jsonrpc: string
result: {
content: Array<{ type: string; text: string }>
}
}
interface ExaSearchResult {
title?: string
url?: string
text?: string
publishedDate?: string
author?: string
}
interface ExaSearchResults {
results?: ExaSearchResult[]
autopromptString?: string
}
const DEFAULT_API_HOST = 'https://mcp.exa.ai/mcp'
const DEFAULT_NUM_RESULTS = 8
const REQUEST_TIMEOUT_MS = 25000
export default class ExaMcpProvider extends BaseWebSearchProvider {
constructor(provider: WebSearchProvider) {
super(provider)
if (!this.apiHost) {
this.apiHost = DEFAULT_API_HOST
}
}
public async search(
query: string,
websearch: WebSearchState,
httpOptions?: RequestInit
): Promise<WebSearchProviderResponse> {
try {
if (!query.trim()) {
throw new Error('Search query cannot be empty')
}
const searchRequest: McpSearchRequest = {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {
name: 'web_search_exa',
arguments: {
query,
type: 'auto',
numResults: websearch.maxResults || DEFAULT_NUM_RESULTS,
livecrawl: 'fallback'
}
}
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
try {
const response = await fetch(this.apiHost!, {
method: 'POST',
headers: {
...this.defaultHeaders(),
accept: 'application/json, text/event-stream',
'content-type': 'application/json'
},
body: JSON.stringify(searchRequest),
signal: httpOptions?.signal ? AbortSignal.any([controller.signal, httpOptions.signal]) : controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Search error (${response.status}): ${errorText}`)
}
const responseText = await response.text()
const searchResults = this.parseResponse(responseText)
return {
query: searchResults.autopromptString || query,
results: (searchResults.results || []).slice(0, websearch.maxResults).map((result) => ({
title: result.title || 'No title',
content: result.text || '',
url: result.url || ''
}))
}
} catch (error) {
clearTimeout(timeoutId)
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Search request timed out')
}
throw error
}
} catch (error) {
logger.error('Exa MCP search failed:', error as Error)
throw new Error(`Search failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
private parsetextChunk(raw: string): ExaSearchResult[] {
const items: ExaSearchResult[] = []
for (const chunk of raw.split('\n\n')) {
// logger.debug('Parsing chunk:', {"chunks": chunk})
// 3. Parse the labeled lines inside the text block
const lines = chunk.split('\n')
// logger.debug('Lines:', lines);
let title = ''
let publishedDate = ''
let url = ''
let fullText = ''
// Well capture everything after the first "Text:" as the article text
let textStartIndex = -1
lines.forEach((line, idx) => {
if (line.startsWith('Title:')) {
title = line.replace(/^Title:\s*/, '')
} else if (line.startsWith('Published Date:')) {
publishedDate = line.replace(/^Published Date:\s*/, '')
} else if (line.startsWith('URL:')) {
url = line.replace(/^URL:\s*/, '')
} else if (line.startsWith('Text:') && textStartIndex === -1) {
// mark where "Text:" starts
textStartIndex = idx
// text on the same line after "Text: "
fullText = line.replace(/^Text:\s*/, '')
}
})
if (textStartIndex !== -1) {
const rest = lines.slice(textStartIndex + 1).join('\n')
if (rest.trim().length > 0) {
fullText = (fullText ? fullText + '\n' : '') + rest
}
}
// If we at least got a title or URL, treat it as a valid article
if (title || url || fullText) {
items.push({
title,
publishedDate,
url,
text: fullText
})
}
}
return items
}
private parseResponse(responseText: string): ExaSearchResults {
// Parse SSE response format
const lines = responseText.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data: McpSearchResponse = JSON.parse(line.substring(6))
if (data.result?.content?.[0]?.text) {
// The text content contains stringified JSON with the actual results
return { results: this.parsetextChunk(data.result.content[0].text) }
}
} catch {
// Continue to next line if parsing fails
logger.warn('Failed to parse SSE line:', { line })
}
}
}
// Try parsing as direct JSON response (non-SSE)
try {
const data: McpSearchResponse = JSON.parse(responseText)
if (data.result?.content?.[0]?.text) {
return { results: this.parsetextChunk(data.result.content[0].text) }
}
} catch {
// Ignore parsing errors
logger.warn('Failed to parse direct JSON response:', { responseText })
}
return { results: [] }
}
}

View File

@ -3,6 +3,7 @@ import type { WebSearchProvider } from '@renderer/types'
import type BaseWebSearchProvider from './BaseWebSearchProvider'
import BochaProvider from './BochaProvider'
import DefaultProvider from './DefaultProvider'
import ExaMcpProvider from './ExaMcpProvider'
import ExaProvider from './ExaProvider'
import LocalBaiduProvider from './LocalBaiduProvider'
import LocalBingProvider from './LocalBingProvider'
@ -24,6 +25,8 @@ export default class WebSearchProviderFactory {
return new SearxngProvider(provider)
case 'exa':
return new ExaProvider(provider)
case 'exa-mcp':
return new ExaMcpProvider(provider)
case 'local-google':
return new LocalGoogleProvider(provider)
case 'local-baidu':

View File

@ -67,7 +67,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 183,
version: 184,
blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs', 'toolPermissions'],
migrate
},

View File

@ -2992,6 +2992,31 @@ const migrateConfig = {
logger.error('migrate 183 error', error as Error)
return state
}
},
'184': (state: RootState) => {
try {
// Add exa-mcp (free) web search provider if not exists
const exaMcpExists = state.websearch.providers.some((p) => p.id === 'exa-mcp')
if (!exaMcpExists) {
// Find the index of 'exa' provider to insert after it
const exaIndex = state.websearch.providers.findIndex((p) => p.id === 'exa')
const newProvider = {
id: 'exa-mcp' as const,
name: 'ExaMCP',
apiHost: 'https://mcp.exa.ai/mcp'
}
if (exaIndex !== -1) {
state.websearch.providers.splice(exaIndex + 1, 0, newProvider)
} else {
state.websearch.providers.push(newProvider)
}
}
logger.info('migrate 184 success')
return state
} catch (error) {
logger.error('migrate 184 error', error as Error)
return state
}
}
}

View File

@ -600,6 +600,7 @@ export const WebSearchProviderIds = {
tavily: 'tavily',
searxng: 'searxng',
exa: 'exa',
'exa-mcp': 'exa-mcp',
bocha: 'bocha',
'local-google': 'local-google',
'local-bing': 'local-bing',