From 4840864ed88f6266eb9f3aae93a28a89ca2571fb Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 03:36:42 +0800 Subject: [PATCH 1/7] feat(config): add websearch provider schema --- assets/oh-my-opencode.schema.json | 12 ++++++++++++ src/config/schema.ts | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 787c8d3a..c562bdad 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2977,6 +2977,18 @@ } } }, + "websearch": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "enum": [ + "exa", + "tavily" + ] + } + } + }, "tmux": { "type": "object", "properties": { diff --git a/src/config/schema.ts b/src/config/schema.ts index 19b4ad89..d0aca627 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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 export type GitMasterConfig = z.infer export type BrowserAutomationProvider = z.infer export type BrowserAutomationConfig = z.infer +export type WebsearchProvider = z.infer +export type WebsearchConfig = z.infer export type TmuxConfig = z.infer export type TmuxLayout = z.infer export type SisyphusTasksConfig = z.infer From 00f576868b58be0a701c0be4f37d655f9c67b672 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 03:36:48 +0800 Subject: [PATCH 2/7] feat(mcp): add multi-provider websearch support --- src/mcp/index.ts | 19 +++++----- src/mcp/websearch.ts | 52 ++++++++++++++++++++++----- src/plugin-handlers/config-handler.ts | 2 +- 3 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index db6f0537..1adeff00 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -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,18 +14,18 @@ type RemoteMcpConfig = { oauth?: false } -const allBuiltinMcps: Record = { - websearch, - context7, - grep_app, -} +export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) { + const allBuiltinMcps: Record = { + websearch: createWebsearchConfig(config?.websearch), + context7, + grep_app, + } -export function createBuiltinMcps(disabledMcps: string[] = []) { const mcps: Record = {} - for (const [name, config] of Object.entries(allBuiltinMcps)) { + for (const [name, mcp] of Object.entries(allBuiltinMcps)) { if (!disabledMcps.includes(name)) { - mcps[name] = config + mcps[name] = mcp } } diff --git a/src/mcp/websearch.ts b/src/mcp/websearch.ts index cc267406..91eddccc 100644 --- a/src/mcp/websearch.ts +++ b/src/mcp/websearch.ts @@ -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 + 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() diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 457e8069..692accf3 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -447,7 +447,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { : { servers: {} }; config.mcp = { - ...createBuiltinMcps(pluginConfig.disabled_mcps), + ...createBuiltinMcps(pluginConfig.disabled_mcps, pluginConfig), ...(config.mcp as Record), ...mcpResult.servers, ...pluginComponents.mcpServers, From ef3d0afa320019820c7697570cfdb60b4fc1f1f9 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 03:36:55 +0800 Subject: [PATCH 3/7] test(mcp): add websearch provider tests --- src/mcp/websearch.test.ts | 133 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 src/mcp/websearch.test.ts diff --git a/src/mcp/websearch.test.ts b/src/mcp/websearch.test.ts new file mode 100644 index 00000000..27db1967 --- /dev/null +++ b/src/mcp/websearch.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" + +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 + const provider = undefined + const exaKey = undefined + const tavilyKey = undefined + + //#when + process.env.EXA_API_KEY = exaKey + process.env.TAVILY_API_KEY = tavilyKey + + //#then + expect(provider).toBeUndefined() + }) + + test("returns Exa config when provider is 'exa'", () => { + //#given + const provider = "exa" + + //#when + const selectedProvider = provider + + //#then + expect(selectedProvider).toBe("exa") + }) + + 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 headers = process.env.EXA_API_KEY + ? { "x-api-key": process.env.EXA_API_KEY } + : undefined + + //#then + expect(headers).toEqual({ "x-api-key": "test-exa-key-12345" }) + expect(headers?.["x-api-key"]).toBe(apiKey) + }) + + test("returns Tavily config when provider is 'tavily' and TAVILY_API_KEY set", () => { + //#given + const provider = "tavily" + const tavilyKey = "test-tavily-key-67890" + process.env.TAVILY_API_KEY = tavilyKey + + //#when + const headers = process.env.TAVILY_API_KEY + ? { Authorization: `Bearer ${process.env.TAVILY_API_KEY}` } + : undefined + + //#then + expect(provider).toBe("tavily") + expect(headers).toEqual({ Authorization: "Bearer test-tavily-key-67890" }) + expect(headers?.Authorization).toContain("Bearer") + }) + + test("throws error when provider is 'tavily' but TAVILY_API_KEY missing", () => { + //#given + const provider = "tavily" + delete process.env.TAVILY_API_KEY + + //#when + const createTavilyConfig = () => { + if (provider === "tavily" && !process.env.TAVILY_API_KEY) { + throw new Error("TAVILY_API_KEY environment variable is required for Tavily provider") + } + } + + //#then + expect(createTavilyConfig).toThrow("TAVILY_API_KEY environment variable is required") + }) + + test("returns Exa when both keys present but no explicit provider (conflict resolution)", () => { + //#given + const exaKey = "test-exa-key" + const tavilyKey = "test-tavily-key" + const provider = undefined + process.env.EXA_API_KEY = exaKey + process.env.TAVILY_API_KEY = tavilyKey + + //#when + const selectedProvider = provider || "exa" + + //#then + expect(selectedProvider).toBe("exa") + expect(process.env.EXA_API_KEY).toBe(exaKey) + expect(process.env.TAVILY_API_KEY).toBe(tavilyKey) + }) + + test("Tavily config uses Authorization Bearer header", () => { + //#given + const tavilyKey = "tavily-secret-key-xyz" + process.env.TAVILY_API_KEY = tavilyKey + + //#when + const headers = { + Authorization: `Bearer ${process.env.TAVILY_API_KEY}`, + } + + //#then + expect(headers.Authorization).toMatch(/^Bearer /) + expect(headers.Authorization).toBe(`Bearer ${tavilyKey}`) + expect(headers.Authorization).not.toContain("x-api-key") + }) + + test("Tavily config points to mcp.tavily.com", () => { + //#given + const tavilyUrl = "https://mcp.tavily.com/mcp/" + + //#when + const url = tavilyUrl + + //#then + expect(url).toContain("mcp.tavily.com") + expect(url).toMatch(/^https:\/\//) + expect(url).toEndWith("/") + }) +}) From fea7bd2dcf65c69972f4bd98f503ffd7ccaffd4c Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 03:37:02 +0800 Subject: [PATCH 4/7] docs(mcp): document websearch provider configuration --- src/mcp/AGENTS.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index 7f175dff..478a0384 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -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 From 17cb49543a890d88e2fa9fe4ee483c43ad124527 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 03:55:39 +0800 Subject: [PATCH 5/7] fix(mcp): rewrite tests to call createWebsearchConfig directly Previously tests were tautological - they defined local logic instead of invoking the actual implementation. Now all tests properly exercise createWebsearchConfig. --- src/mcp/websearch.test.ts | 85 ++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 51 deletions(-) diff --git a/src/mcp/websearch.test.ts b/src/mcp/websearch.test.ts index 27db1967..f29bf663 100644 --- a/src/mcp/websearch.test.ts +++ b/src/mcp/websearch.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { createWebsearchConfig } from "./websearch" describe("websearch MCP provider configuration", () => { const originalEnv = { ...process.env } @@ -13,28 +14,27 @@ describe("websearch MCP provider configuration", () => { }) test("returns Exa config when no config provided", () => { - //#given - const provider = undefined - const exaKey = undefined - const tavilyKey = undefined + //#given - no config //#when - process.env.EXA_API_KEY = exaKey - process.env.TAVILY_API_KEY = tavilyKey + const result = createWebsearchConfig() //#then - expect(provider).toBeUndefined() + 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 provider = "exa" + const config = { provider: "exa" as const } //#when - const selectedProvider = provider + const result = createWebsearchConfig(config) //#then - expect(selectedProvider).toBe("exa") + expect(result.url).toContain("mcp.exa.ai") + expect(result.type).toBe("remote") }) test("includes x-api-key header when EXA_API_KEY is set", () => { @@ -43,91 +43,74 @@ describe("websearch MCP provider configuration", () => { process.env.EXA_API_KEY = apiKey //#when - const headers = process.env.EXA_API_KEY - ? { "x-api-key": process.env.EXA_API_KEY } - : undefined + const result = createWebsearchConfig() //#then - expect(headers).toEqual({ "x-api-key": "test-exa-key-12345" }) - expect(headers?.["x-api-key"]).toBe(apiKey) + expect(result.headers).toEqual({ "x-api-key": apiKey }) }) test("returns Tavily config when provider is 'tavily' and TAVILY_API_KEY set", () => { //#given - const provider = "tavily" const tavilyKey = "test-tavily-key-67890" process.env.TAVILY_API_KEY = tavilyKey + const config = { provider: "tavily" as const } //#when - const headers = process.env.TAVILY_API_KEY - ? { Authorization: `Bearer ${process.env.TAVILY_API_KEY}` } - : undefined + const result = createWebsearchConfig(config) //#then - expect(provider).toBe("tavily") - expect(headers).toEqual({ Authorization: "Bearer test-tavily-key-67890" }) - expect(headers?.Authorization).toContain("Bearer") + 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 - const provider = "tavily" delete process.env.TAVILY_API_KEY + const config = { provider: "tavily" as const } //#when - const createTavilyConfig = () => { - if (provider === "tavily" && !process.env.TAVILY_API_KEY) { - throw new Error("TAVILY_API_KEY environment variable is required for Tavily provider") - } - } + 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 (conflict resolution)", () => { + test("returns Exa when both keys present but no explicit provider", () => { //#given - const exaKey = "test-exa-key" - const tavilyKey = "test-tavily-key" - const provider = undefined - process.env.EXA_API_KEY = exaKey - process.env.TAVILY_API_KEY = tavilyKey + process.env.EXA_API_KEY = "test-exa-key" + process.env.TAVILY_API_KEY = "test-tavily-key" //#when - const selectedProvider = provider || "exa" + const result = createWebsearchConfig() //#then - expect(selectedProvider).toBe("exa") - expect(process.env.EXA_API_KEY).toBe(exaKey) - expect(process.env.TAVILY_API_KEY).toBe(tavilyKey) + expect(result.url).toContain("mcp.exa.ai") + expect(result.headers).toEqual({ "x-api-key": "test-exa-key" }) }) - test("Tavily config uses Authorization Bearer header", () => { + 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 headers = { - Authorization: `Bearer ${process.env.TAVILY_API_KEY}`, - } + const result = createWebsearchConfig(config) //#then - expect(headers.Authorization).toMatch(/^Bearer /) - expect(headers.Authorization).toBe(`Bearer ${tavilyKey}`) - expect(headers.Authorization).not.toContain("x-api-key") + expect(result.headers?.Authorization).toMatch(/^Bearer /) + expect(result.headers?.Authorization).toBe(`Bearer ${tavilyKey}`) }) - test("Tavily config points to mcp.tavily.com", () => { + test("Exa config has no headers when EXA_API_KEY not set", () => { //#given - const tavilyUrl = "https://mcp.tavily.com/mcp/" + delete process.env.EXA_API_KEY //#when - const url = tavilyUrl + const result = createWebsearchConfig() //#then - expect(url).toContain("mcp.tavily.com") - expect(url).toMatch(/^https:\/\//) - expect(url).toEndWith("/") + expect(result.url).toContain("mcp.exa.ai") + expect(result.headers).toBeUndefined() }) }) From 5a2ab0095dbc11bfce64b6603e4b8394e3f270d3 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 03:55:50 +0800 Subject: [PATCH 6/7] fix(mcp): lazy evaluation prevents crash when websearch disabled createWebsearchConfig was called eagerly before checking disabledMcps, causing Tavily missing-key error even when websearch was disabled. Now each MCP is only created if not in disabledMcps list. --- src/mcp/index.test.ts | 18 ++++++++++++++++++ src/mcp/index.ts | 20 ++++++++++---------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/mcp/index.test.ts b/src/mcp/index.test.ts index cf6499e3..178caf19 100644 --- a/src/mcp/index.test.ts +++ b/src/mcp/index.test.ts @@ -83,4 +83,22 @@ 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 } } + + // when + const createMcps = () => createBuiltinMcps(disabledMcps, config) + + // then + expect(createMcps).not.toThrow() + const result = createMcps() + expect(result).not.toHaveProperty("websearch") + + if (originalTavilyKey) process.env.TAVILY_API_KEY = originalTavilyKey + }) }) diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 1adeff00..b8cf31df 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -15,18 +15,18 @@ type RemoteMcpConfig = { } export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) { - const allBuiltinMcps: Record = { - websearch: createWebsearchConfig(config?.websearch), - context7, - grep_app, - } - const mcps: Record = {} - for (const [name, mcp] of Object.entries(allBuiltinMcps)) { - if (!disabledMcps.includes(name)) { - mcps[name] = mcp - } + 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 From 9a2a6a695a376d6031a3e20e805a2b35ceba8690 Mon Sep 17 00:00:00 2001 From: YanzheL Date: Mon, 2 Feb 2026 04:00:06 +0800 Subject: [PATCH 7/7] fix(test): use try/finally for guaranteed env restoration --- src/mcp/index.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/mcp/index.test.ts b/src/mcp/index.test.ts index 178caf19..b1831ecd 100644 --- a/src/mcp/index.test.ts +++ b/src/mcp/index.test.ts @@ -91,14 +91,16 @@ describe("createBuiltinMcps", () => { const disabledMcps = ["websearch"] const config = { websearch: { provider: "tavily" as const } } - // when - const createMcps = () => createBuiltinMcps(disabledMcps, config) + try { + // when + const createMcps = () => createBuiltinMcps(disabledMcps, config) - // then - expect(createMcps).not.toThrow() - const result = createMcps() - expect(result).not.toHaveProperty("websearch") - - if (originalTavilyKey) process.env.TAVILY_API_KEY = originalTavilyKey + // then + expect(createMcps).not.toThrow() + const result = createMcps() + expect(result).not.toHaveProperty("websearch") + } finally { + if (originalTavilyKey) process.env.TAVILY_API_KEY = originalTavilyKey + } }) })