Merge pull request #1371 from YanzheL/feat/websearch-multi-provider

feat(mcp): add multi-provider websearch support (Exa + Tavily)
This commit is contained in:
YeonGyu-Kim 2026-02-04 13:52:36 +09:00 committed by GitHub
commit ffcf1b5715
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 251 additions and 23 deletions

View File

@ -2977,6 +2977,18 @@
}
}
},
"websearch": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": [
"exa",
"tavily"
]
}
}
},
"tmux": {
"type": "object",
"properties": {

View File

@ -340,6 +340,17 @@ export const BrowserAutomationConfigSchema = z.object({
provider: BrowserAutomationProviderSchema.default("playwright"),
})
export const WebsearchProviderSchema = z.enum(["exa", "tavily"])
export const WebsearchConfigSchema = z.object({
/**
* Websearch provider to use.
* - "exa": Uses Exa websearch (default, works without API key)
* - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY)
*/
provider: WebsearchProviderSchema.optional(),
})
export const TmuxLayoutSchema = z.enum([
'main-horizontal', // main pane top, agent panes bottom stack
'main-vertical', // main pane left, agent panes right stack (default)
@ -393,6 +404,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
websearch: WebsearchConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(),
})
@ -420,6 +432,8 @@ export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema>
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>

View File

@ -25,7 +25,7 @@ mcp/
| Name | URL | Purpose | Auth |
|------|-----|---------|------|
| websearch | mcp.exa.ai/mcp?tools=web_search_exa | Real-time web search | EXA_API_KEY |
| websearch | mcp.exa.ai / mcp.tavily.com | Real-time web search | EXA_API_KEY / TAVILY_API_KEY |
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY |
| grep_app | mcp.grep.app | GitHub code search | None |
@ -35,6 +35,36 @@ mcp/
2. **Claude Code compat**: `.mcp.json` with `${VAR}` expansion
3. **Skill-embedded**: YAML frontmatter in skills (handled by skill-mcp-manager)
## Websearch Provider Configuration
The `websearch` MCP supports multiple providers. Exa is the default for backward compatibility and works without an API key.
| Provider | URL | Auth | API Key Required |
|----------|-----|------|------------------|
| exa (default) | mcp.exa.ai | x-api-key header | No (optional) |
| tavily | mcp.tavily.com | Authorization Bearer | Yes |
### Configuration Example
```jsonc
{
"websearch": {
"provider": "tavily" // or "exa" (default)
}
}
```
### Environment Variables
- `EXA_API_KEY`: Optional. Used when provider is `exa`.
- `TAVILY_API_KEY`: Required when provider is `tavily`.
### Priority and Behavior
- **Default**: Exa is used if no provider is specified.
- **Backward Compatibility**: Existing setups using `EXA_API_KEY` continue to work without changes.
- **Validation**: Selecting `tavily` without providing `TAVILY_API_KEY` will result in a configuration error.
## CONFIG PATTERN
```typescript
@ -68,3 +98,4 @@ const mcps = createBuiltinMcps(["websearch"]) // Disable specific
- **Disable**: User can set `disabled_mcps: ["name"]` in config
- **Context7**: Optional auth using `CONTEXT7_API_KEY` env var
- **Exa**: Optional auth using `EXA_API_KEY` env var
- **Tavily**: Requires `TAVILY_API_KEY` env var

View File

@ -83,4 +83,24 @@ describe("createBuiltinMcps", () => {
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(3)
})
test("should not throw when websearch disabled even if tavily configured without API key", () => {
// given
const originalTavilyKey = process.env.TAVILY_API_KEY
delete process.env.TAVILY_API_KEY
const disabledMcps = ["websearch"]
const config = { websearch: { provider: "tavily" as const } }
try {
// when
const createMcps = () => createBuiltinMcps(disabledMcps, config)
// then
expect(createMcps).not.toThrow()
const result = createMcps()
expect(result).not.toHaveProperty("websearch")
} finally {
if (originalTavilyKey) process.env.TAVILY_API_KEY = originalTavilyKey
}
})
})

View File

@ -1,7 +1,8 @@
import { websearch } from "./websearch"
import { createWebsearchConfig } from "./websearch"
import { context7 } from "./context7"
import { grep_app } from "./grep-app"
import type { McpName } from "./types"
import type { OhMyOpenCodeConfig } from "../config/schema"
export { McpNameSchema, type McpName } from "./types"
@ -13,19 +14,19 @@ type RemoteMcpConfig = {
oauth?: false
}
const allBuiltinMcps: Record<McpName, RemoteMcpConfig> = {
websearch,
context7,
grep_app,
}
export function createBuiltinMcps(disabledMcps: string[] = []) {
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
const mcps: Record<string, RemoteMcpConfig> = {}
for (const [name, config] of Object.entries(allBuiltinMcps)) {
if (!disabledMcps.includes(name)) {
mcps[name] = config
}
if (!disabledMcps.includes("websearch")) {
mcps.websearch = createWebsearchConfig(config?.websearch)
}
if (!disabledMcps.includes("context7")) {
mcps.context7 = context7
}
if (!disabledMcps.includes("grep_app")) {
mcps.grep_app = grep_app
}
return mcps

116
src/mcp/websearch.test.ts Normal file
View File

@ -0,0 +1,116 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { createWebsearchConfig } from "./websearch"
describe("websearch MCP provider configuration", () => {
const originalEnv = { ...process.env }
beforeEach(() => {
delete process.env.EXA_API_KEY
delete process.env.TAVILY_API_KEY
})
afterEach(() => {
process.env = { ...originalEnv }
})
test("returns Exa config when no config provided", () => {
//#given - no config
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.type).toBe("remote")
expect(result.enabled).toBe(true)
})
test("returns Exa config when provider is 'exa'", () => {
//#given
const config = { provider: "exa" as const }
//#when
const result = createWebsearchConfig(config)
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.type).toBe("remote")
})
test("includes x-api-key header when EXA_API_KEY is set", () => {
//#given
const apiKey = "test-exa-key-12345"
process.env.EXA_API_KEY = apiKey
//#when
const result = createWebsearchConfig()
//#then
expect(result.headers).toEqual({ "x-api-key": apiKey })
})
test("returns Tavily config when provider is 'tavily' and TAVILY_API_KEY set", () => {
//#given
const tavilyKey = "test-tavily-key-67890"
process.env.TAVILY_API_KEY = tavilyKey
const config = { provider: "tavily" as const }
//#when
const result = createWebsearchConfig(config)
//#then
expect(result.url).toContain("mcp.tavily.com")
expect(result.headers).toEqual({ Authorization: `Bearer ${tavilyKey}` })
})
test("throws error when provider is 'tavily' but TAVILY_API_KEY missing", () => {
//#given
delete process.env.TAVILY_API_KEY
const config = { provider: "tavily" as const }
//#when
const createTavilyConfig = () => createWebsearchConfig(config)
//#then
expect(createTavilyConfig).toThrow("TAVILY_API_KEY environment variable is required")
})
test("returns Exa when both keys present but no explicit provider", () => {
//#given
process.env.EXA_API_KEY = "test-exa-key"
process.env.TAVILY_API_KEY = "test-tavily-key"
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.headers).toEqual({ "x-api-key": "test-exa-key" })
})
test("Tavily config uses Authorization Bearer header format", () => {
//#given
const tavilyKey = "tavily-secret-key-xyz"
process.env.TAVILY_API_KEY = tavilyKey
const config = { provider: "tavily" as const }
//#when
const result = createWebsearchConfig(config)
//#then
expect(result.headers?.Authorization).toMatch(/^Bearer /)
expect(result.headers?.Authorization).toBe(`Bearer ${tavilyKey}`)
})
test("Exa config has no headers when EXA_API_KEY not set", () => {
//#given
delete process.env.EXA_API_KEY
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.headers).toBeUndefined()
})
})

View File

@ -1,10 +1,44 @@
export const websearch = {
type: "remote" as const,
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
enabled: true,
headers: process.env.EXA_API_KEY
? { "x-api-key": process.env.EXA_API_KEY }
: undefined,
// Disable OAuth auto-detection - Exa uses API key header, not OAuth
oauth: false as const,
import type { WebsearchConfig } from "../config/schema"
type RemoteMcpConfig = {
type: "remote"
url: string
enabled: boolean
headers?: Record<string, string>
oauth?: false
}
export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig {
const provider = config?.provider || "exa"
if (provider === "tavily") {
const tavilyKey = process.env.TAVILY_API_KEY
if (!tavilyKey) {
throw new Error("TAVILY_API_KEY environment variable is required for Tavily provider")
}
return {
type: "remote" as const,
url: "https://mcp.tavily.com/mcp/",
enabled: true,
headers: {
Authorization: `Bearer ${tavilyKey}`,
},
oauth: false as const,
}
}
// Default to Exa
return {
type: "remote" as const,
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
enabled: true,
headers: process.env.EXA_API_KEY
? { "x-api-key": process.env.EXA_API_KEY }
: undefined,
oauth: false as const,
}
}
// Backward compatibility: export static instance using default config
export const websearch = createWebsearchConfig()

View File

@ -450,7 +450,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: { servers: {} };
config.mcp = {
...createBuiltinMcps(pluginConfig.disabled_mcps),
...createBuiltinMcps(pluginConfig.disabled_mcps, pluginConfig),
...(config.mcp as Record<string, unknown>),
...mcpResult.servers,
...pluginComponents.mcpServers,