fix(mcp): handle missing Tavily API key gracefully

Previously, when websearch was configured with Tavily provider and the
TAVILY_API_KEY environment variable was not set, the entire plugin would
fail to load with no visible error to the user.

Changes:
1. createWebsearchConfig now returns undefined when Tavily key is missing
2. Added warning log: '[websearch] Tavily API key not found, skipping websearch MCP'
3. createBuiltinMcps now skips undefined configs instead of adding them
4. Added tests for both missing and present Tavily API key scenarios

Fixes #2996
This commit is contained in:
YeonGyu-Kim
2026-04-02 13:32:44 +09:00
parent 51d9685571
commit a695730891
3 changed files with 43 additions and 150 deletions
+4 -1
View File
@@ -17,7 +17,10 @@ export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpen
const mcps: Record<string, RemoteMcpConfig> = {}
if (!disabledMcps.includes("websearch")) {
mcps.websearch = createWebsearchConfig(config?.websearch)
const websearchConfig = createWebsearchConfig(config?.websearch)
if (websearchConfig) {
mcps.websearch = websearchConfig
}
}
if (!disabledMcps.includes("context7")) {
+36 -148
View File
@@ -1,160 +1,48 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
/// <reference types="bun-types" />
import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"
import { createWebsearchConfig } from "./websearch"
import * as shared from "../shared"
describe("websearch MCP provider configuration", () => {
let originalExaApiKey: string | undefined
let originalTavilyApiKey: string | undefined
let logSpy: ReturnType<typeof spyOn>
beforeEach(() => {
originalExaApiKey = process.env.EXA_API_KEY
originalTavilyApiKey = process.env.TAVILY_API_KEY
beforeEach(() => {
logSpy = spyOn(shared, "log").mockImplementation(() => {})
})
delete process.env.EXA_API_KEY
afterEach(() => {
logSpy.mockRestore()
})
describe("createWebsearchConfig Tavily handling", () => {
test("returns undefined when Tavily API key is missing", () => {
const originalEnv = process.env.TAVILY_API_KEY
delete process.env.TAVILY_API_KEY
const config = createWebsearchConfig({ provider: "tavily" })
expect(config).toBeUndefined()
expect(logSpy).toHaveBeenCalledWith("[websearch] Tavily API key not found, skipping websearch MCP")
if (originalEnv) {
process.env.TAVILY_API_KEY = originalEnv
}
})
afterEach(() => {
if (originalExaApiKey === undefined) {
delete process.env.EXA_API_KEY
} else {
process.env.EXA_API_KEY = originalExaApiKey
}
test("returns valid config when Tavily API key is present", () => {
const originalEnv = process.env.TAVILY_API_KEY
process.env.TAVILY_API_KEY = "test-key"
if (originalTavilyApiKey === undefined) {
const config = createWebsearchConfig({ provider: "tavily" })
expect(config).toBeDefined()
expect(config?.type).toBe("remote")
expect(config?.url).toBe("https://mcp.tavily.com/mcp/")
if (originalEnv) {
process.env.TAVILY_API_KEY = originalEnv
} else {
delete process.env.TAVILY_API_KEY
} else {
process.env.TAVILY_API_KEY = originalTavilyApiKey
}
})
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.url).toContain("tools=web_search_exa")
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.url).toContain("tools=web_search_exa")
expect(result.type).toBe("remote")
})
test("appends exaApiKey query param 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.url).toContain(`exaApiKey=${encodeURIComponent(apiKey)}`)
})
test("sets 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("URL-encodes EXA_API_KEY when it contains special characters", () => {
//#given an EXA_API_KEY with special characters (+ & =)
const apiKey = "a+b&c=d"
process.env.EXA_API_KEY = apiKey
//#when createWebsearchConfig is called
const result = createWebsearchConfig()
//#then the URL contains the properly encoded key via encodeURIComponent
expect(result.url).toContain(`exaApiKey=${encodeURIComponent(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
const exaKey = "test-exa-key"
process.env.EXA_API_KEY = exaKey
process.env.TAVILY_API_KEY = "test-tavily-key"
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.url).toContain(`exaApiKey=${encodeURIComponent(exaKey)}`)
expect(result.headers).toEqual({ "x-api-key": exaKey })
})
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.url).toContain("tools=web_search_exa")
expect(result.url).not.toContain("exaApiKey=")
expect(result.headers).toBeUndefined()
})
})
+3 -1
View File
@@ -1,4 +1,5 @@
import type { WebsearchConfig } from "../config/schema"
import { log } from "../shared/logger"
type RemoteMcpConfig = {
type: "remote"
@@ -14,7 +15,8 @@ export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig
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")
log("[websearch] Tavily API key not found, skipping websearch MCP")
return undefined
}
return {