diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4d68feb..661891f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "sdlc", - "version": "1.19.1", + "version": "1.20.0", "description": "Comprehensive SDLC plugin with specialized agents, commands, and integrations for enhanced software development workflow", "author": { "name": "Ladislav Martincik", diff --git a/.mcp.json b/.mcp.json index 83cdb09..b35448e 100644 --- a/.mcp.json +++ b/.mcp.json @@ -4,13 +4,15 @@ "command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"] }, - "perplexity": { + "search": { "command": "bun", - "args": ["${CLAUDE_PLUGIN_ROOT}/utils/perplexity-mcp/index.ts"], + "args": ["${CLAUDE_PLUGIN_ROOT}/utils/search-mcp/index.ts"], "env": { "OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}", - "OPENROUTER_MODEL": "${OPENROUTER_MODEL:-perplexity/sonar-pro}" + "OPENROUTER_MODEL": "${OPENROUTER_MODEL:-perplexity/sonar-pro}", + "EXA_API_KEY": "${EXA_API_KEY}", + "BRAVE_API_KEY": "${BRAVE_API_KEY}" } } } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d6f13..8d8358f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ All notable changes to the SDLC Plugin will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.20.0] - 2026-02-14 + +### Added + +- **Multi-provider search MCP server** — unified `search_web` tool supporting Exa, Brave, and Perplexity via OpenRouter +- **Provider adapters** with consistent error handling, timeout management, and response formatting +- **Provider parameter validation** — cross-provider param misuse returns clear errors +- **Automatic provider detection** from environment variables with priority cascade: Exa > Brave > Perplexity + +### Fixed + +- **Exa timeout memory leak** — `setTimeout` in `Promise.race` pattern now cleaned up via `finally` block +- **Redundant `clearTimeout`** in Perplexity SSE stream error handler + +### Removed + +- **Old `perplexity-mcp/`** directory replaced by new `search-mcp/` implementation + ## [1.19.1] - 2026-02-12 ### Changed diff --git a/README.md b/README.md index 1b9bdd0..6c4aa86 100644 --- a/README.md +++ b/README.md @@ -416,12 +416,14 @@ Configure in `.mcp.json`: "command": "npx", "args": ["-y", "@upstash/context7-mcp@latest"] }, - "perplexity": { + "search": { "command": "bun", - "args": ["${CLAUDE_PLUGIN_ROOT}/utils/perplexity-mcp/index.ts"], + "args": ["${CLAUDE_PLUGIN_ROOT}/utils/search-mcp/index.ts"], "env": { "OPENROUTER_API_KEY": "${OPENROUTER_API_KEY}", - "OPENROUTER_MODEL": "${OPENROUTER_MODEL:-perplexity/sonar-pro}" + "OPENROUTER_MODEL": "${OPENROUTER_MODEL:-perplexity/sonar-pro}", + "EXA_API_KEY": "${EXA_API_KEY}", + "BRAVE_API_KEY": "${BRAVE_API_KEY}" } } } @@ -458,8 +460,14 @@ sdlc-plugin/ │ ├── tdd/SKILL.md # TDD enforcement │ └── test/SKILL.md ├── utils/ -│ └── perplexity-mcp/ -│ └── index.ts # Perplexity Sonar MCP server (via OpenRouter) +│ └── search-mcp/ +│ ├── index.ts # Multi-provider search MCP server +│ ├── types.ts # Shared types and helpers +│ ├── format.ts # Response formatter +│ └── providers/ +│ ├── perplexity.ts # Perplexity/OpenRouter adapter +│ ├── exa.ts # Exa search adapter +│ └── brave.ts # Brave Search adapter ├── logs/ # Hook execution logs ├── .mcp.json # MCP server configuration ├── README.md diff --git a/agents/web-search-researcher.md b/agents/web-search-researcher.md index 92955c3..5df9862 100644 --- a/agents/web-search-researcher.md +++ b/agents/web-search-researcher.md @@ -1,7 +1,7 @@ --- name: web-search-researcher description: Research specialist for finding accurate, relevant information from web sources using Sonar (via OpenRouter), WebSearch, Context7, and other tools with proper citations -tools: WebSearch, WebFetch, TodoWrite, Read, Grep, Glob, Skill, LS, mcp__perplexity__search_web, mcp__context7__resolve-library-id, mcp__context7__get-library-docs +tools: WebSearch, WebFetch, TodoWrite, Read, Grep, Glob, Skill, LS, mcp__search__search_web, mcp__context7__resolve-library-id, mcp__context7__get-library-docs color: yellow model: sonnet --- diff --git a/bun.lock b/bun.lock index dddcbfb..3af1cf1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,11 +5,12 @@ "name": "sdlc-plugin", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "exa-js": "^2.4.0", + "zod": "^3.23.8", }, "devDependencies": { "@anthropic-ai/sdk": "^0.74.0", "@types/bun": "latest", - "zod": "^3.23.8", }, }, }, @@ -54,6 +55,8 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -62,6 +65,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dotenv": ["dotenv@16.4.7", "", {}, "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], @@ -82,6 +87,8 @@ "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + "exa-js": ["exa-js@2.4.0", "", { "dependencies": { "cross-fetch": "~4.1.0", "dotenv": "~16.4.7", "openai": "^5.0.1", "zod": "^3.22.0", "zod-to-json-schema": "^3.20.0" } }, "sha512-zOFClWWZnh9wyUN3xiBgbhuT8DsS62uZJY+P9toN4KgxyCRQma7aU89/7UCtrXNwq5kEFAACw4eualXcTKjiAQ=="], + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], @@ -146,6 +153,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -154,6 +163,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "openai": ["openai@5.23.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -198,6 +209,8 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -208,6 +221,10 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/package.json b/package.json index 7b468ed..b207369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sdlc-plugin", - "version": "1.19.1", + "version": "1.20.0", "description": "Comprehensive SDLC plugin with specialized agents, commands, and integrations", "private": true, "type": "module", @@ -21,6 +21,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "exa-js": "^2.4.0", "zod": "^3.23.8" } } diff --git a/utils/perplexity-mcp/index.ts b/utils/perplexity-mcp/index.ts deleted file mode 100644 index beab492..0000000 --- a/utils/perplexity-mcp/index.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; - -const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY; -if (!OPENROUTER_API_KEY) { - console.error("OPENROUTER_API_KEY environment variable is required"); - process.exit(1); -} - -const DEFAULT_MODEL = process.env.OPENROUTER_MODEL || "perplexity/sonar-pro"; -const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"; -const TIMEOUT_MS = 120_000; - -interface SSEResult { - content: string; - citations: string[]; - images: string[]; - model: string; - usage: Record; -} - -function processSSELine( - line: string, - state: { content: string; citations: Set; images: Set; model: string; usage: Record; pendingError: boolean } -): void { - if (line.startsWith("event: error")) { - state.pendingError = true; - return; - } - - if (!line.startsWith("data: ")) { - if (line.trim() === "") state.pendingError = false; - return; - } - - const data = line.slice("data: ".length).trim(); - - if (state.pendingError) { - state.pendingError = false; - throw new Error(`SSE error: ${data}`); - } - - if (data === "[DONE]") return; - - let parsed: Record; - try { - parsed = JSON.parse(data); - } catch { - return; - } - - if (parsed.model) state.model = parsed.model as string; - if (parsed.usage) state.usage = parsed.usage as Record; - - if (parsed.citations) { - for (const c of parsed.citations as string[]) state.citations.add(c); - } - - const choices = parsed.choices as - | Array<{ delta?: { content?: string } }> - | undefined; - if (choices?.[0]?.delta?.content) { - state.content += choices[0].delta.content; - } - - if (parsed.images) { - for (const img of parsed.images as string[]) state.images.add(img); - } - const choiceImages = ( - choices?.[0] as { delta?: { images?: string[] } } | undefined - )?.delta?.images; - if (choiceImages) { - for (const img of choiceImages) state.images.add(img); - } -} - -async function parseSSEStream(response: Response): Promise { - if (!response.body) { - throw new Error("Response body is null"); - } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - const state = { - content: "", - citations: new Set(), - images: new Set(), - model: "", - usage: {} as Record, - pendingError: false, - }; - let buffer = ""; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() || ""; - - for (const line of lines) { - processSSELine(line, state); - } - } - - // Process any residual buffer content - if (buffer.trim()) { - processSSELine(buffer, state); - } - } finally { - reader.releaseLock(); - } - - return { - content: state.content, - citations: [...state.citations], - images: [...state.images], - model: state.model, - usage: state.usage, - }; -} - -async function searchWeb(args: { - query: string; - model?: string; - recency?: string; - temperature?: number; - top_p?: number; - top_k?: number; - max_tokens?: number; - frequency_penalty?: number; - presence_penalty?: number; -}): Promise<{ content: Array<{ type: "text"; text: string }>; isError?: boolean }> { - const model = args.model || DEFAULT_MODEL; - - const systemParts = [ - "You are a helpful search assistant. Provide accurate, well-cited answers.", - ]; - if (args.recency) { - systemParts.push(`Focus on results from the last ${args.recency}.`); - } - - const body: Record = { - model, - stream: true, - messages: [ - { role: "system", content: systemParts.join(" ") }, - { role: "user", content: args.query }, - ], - }; - - if (args.temperature !== undefined) body.temperature = args.temperature; - if (args.top_p !== undefined) body.top_p = args.top_p; - if (args.top_k !== undefined) body.top_k = args.top_k; - if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens; - if (args.frequency_penalty !== undefined) body.frequency_penalty = args.frequency_penalty; - if (args.presence_penalty !== undefined) body.presence_penalty = args.presence_penalty; - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS); - - let response: Response; - try { - response = await fetch(OPENROUTER_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${OPENROUTER_API_KEY}`, - "Content-Type": "application/json", - "HTTP-Referer": "https://github.com/iamladi/sdlc-plugin", - "X-Title": "SDLC Plugin MCP Server", - }, - body: JSON.stringify(body), - signal: controller.signal, - }); - } catch (error: unknown) { - clearTimeout(timeout); - if (error instanceof Error && error.name === "AbortError") { - return { - content: [{ type: "text", text: `Request timed out after ${TIMEOUT_MS / 1000}s` }], - isError: true, - }; - } - return { - content: [{ type: "text", text: `Network error: ${error instanceof Error ? error.message : String(error)}` }], - isError: true, - }; - } - - if (!response.ok) { - clearTimeout(timeout); - let errorBody: string; - try { - errorBody = await response.text(); - } catch { - errorBody = "Unable to read error body"; - } - return { - content: [{ type: "text", text: `OpenRouter API error (${response.status}): ${errorBody}` }], - isError: true, - }; - } - - let result: SSEResult; - try { - result = await parseSSEStream(response); - } catch (error: unknown) { - return { - content: [{ type: "text", text: `SSE parsing error: ${error instanceof Error ? error.message : String(error)}` }], - isError: true, - }; - } finally { - clearTimeout(timeout); - } - - const parts: string[] = [result.content]; - - if (result.citations.length > 0) { - parts.push("\n\n---\n**Sources:**"); - result.citations.forEach((citation, i) => { - parts.push(`[${i + 1}] ${citation}`); - }); - } - - if (result.images.length > 0) { - parts.push("\n\n**Images:**"); - result.images.forEach((image) => { - parts.push(`![](${image})`); - }); - } - - const meta = { - citations: result.citations, - images: result.images, - model: result.model, - usage: result.usage, - }; - - return { - content: [{ type: "text", text: parts.join("\n") + `\n\n${JSON.stringify(meta)}` }], - }; -} - -const server = new McpServer({ name: "perplexity", version: "2.0.0" }); - -server.registerTool( - "search_web", - { - description: "Search the web using Perplexity AI with recency filtering", - inputSchema: { - query: z.string().describe("Search query"), - model: z - .string() - .optional() - .describe( - "OpenRouter model ID (e.g. perplexity/sonar-pro, perplexity/sonar). Defaults to perplexity/sonar-pro." - ), - recency: z - .enum(["day", "week", "month", "year"]) - .optional() - .describe("Filter results by recency"), - temperature: z - .number() - .optional() - .describe("Controls generation randomness, with 0 being deterministic and values approaching 2 being more random."), - top_p: z - .number() - .optional() - .describe("Nucleus sampling threshold, controlling the token selection pool based on cumulative probability."), - top_k: z - .number() - .optional() - .describe("Limits the number of high-probability tokens to consider for generation. Set to 0 to disable."), - max_tokens: z - .number() - .optional() - .describe("The maximum number of tokens to generate."), - frequency_penalty: z - .number() - .optional() - .describe("Multiplicative penalty for new tokens based on their frequency in the text to avoid repetition."), - presence_penalty: z - .number() - .optional() - .describe("Penalty for new tokens based on their current presence in the text, encouraging topic variety."), - }, - }, - async (args) => searchWeb(args) -); - -async function main() { - const transport = new StdioServerTransport(); - await server.connect(transport); -} - -main().catch((error) => { - console.error("Fatal error:", error); - process.exit(1); -}); diff --git a/utils/search-mcp/__tests__/brave.test.ts b/utils/search-mcp/__tests__/brave.test.ts new file mode 100644 index 0000000..c194376 --- /dev/null +++ b/utils/search-mcp/__tests__/brave.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test"; +import { searchBrave } from "../providers/brave"; +import type { SearchInput, BraveOptions } from "../types"; + +describe("searchBrave", () => { + test("returns SearchResult from Brave response", async () => { + const mockResponse = { + web: { + results: [ + { + title: "Test Page", + url: "https://example.com", + description: "Test description", + thumbnail: { src: "https://example.com/thumb.jpg" }, + }, + ], + }, + }; + + const fakeFetch: typeof fetch = async () => { + return new Response(JSON.stringify(mockResponse), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + + const input: SearchInput = { + query: "test query", + provider: "brave", + num_results: 10, + }; + + const result = await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(result).toMatchObject({ + content: "Test description", + sources: [ + { + title: "Test Page", + url: "https://example.com", + snippet: "Test description", + }, + ], + images: ["https://example.com/thumb.jpg"], + meta: { + provider: "brave", + latencyMs: expect.any(Number), + }, + }); + }); + + test("maps results to sources with title, url, snippet", async () => { + const mockResponse = { + web: { + results: [ + { + title: "First Result", + url: "https://first.com", + description: "First snippet", + }, + { + title: "Second Result", + url: "https://second.com", + description: "Second snippet", + }, + ], + }, + }; + + const fakeFetch: typeof fetch = async () => { + return new Response(JSON.stringify(mockResponse), { status: 200 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + const result = await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(result.sources).toEqual([ + { title: "First Result", url: "https://first.com", snippet: "First snippet" }, + { title: "Second Result", url: "https://second.com", snippet: "Second snippet" }, + ]); + }); + + test("extracts thumbnail images", async () => { + const mockResponse = { + web: { + results: [ + { + title: "Page 1", + url: "https://page1.com", + description: "Desc 1", + thumbnail: { src: "https://img1.com/thumb.jpg" }, + }, + { + title: "Page 2", + url: "https://page2.com", + description: "Desc 2", + thumbnail: { src: "https://img2.com/thumb.jpg" }, + }, + { + title: "Page 3", + url: "https://page3.com", + description: "Desc 3", + }, + ], + }, + }; + + const fakeFetch: typeof fetch = async () => { + return new Response(JSON.stringify(mockResponse), { status: 200 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + const result = await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(result.images).toEqual(["https://img1.com/thumb.jpg", "https://img2.com/thumb.jpg"]); + }); + + test("maps recency to freshness param in URL", async () => { + const capturedUrls: string[] = []; + + const fakeFetch: typeof fetch = async (input: RequestInfo | URL) => { + capturedUrls.push(input.toString()); + return new Response(JSON.stringify({ web: { results: [] } }), { status: 200 }); + }; + + const testCases: Array<{ recency: "day" | "week" | "month" | "year"; expected: string }> = [ + { recency: "day", expected: "pd" }, + { recency: "week", expected: "pw" }, + { recency: "month", expected: "pm" }, + { recency: "year", expected: "py" }, + ]; + + for (const { recency, expected } of testCases) { + const input: SearchInput = { + query: "test", + provider: "brave", + recency, + }; + + await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + } + + expect(capturedUrls[0]).toContain("freshness=pd"); + expect(capturedUrls[1]).toContain("freshness=pw"); + expect(capturedUrls[2]).toContain("freshness=pm"); + expect(capturedUrls[3]).toContain("freshness=py"); + }); + + test("clamps num_results to max 20", async () => { + const capturedUrls: string[] = []; + + const fakeFetch: typeof fetch = async (input: RequestInfo | URL) => { + capturedUrls.push(input.toString()); + return new Response(JSON.stringify({ web: { results: [] } }), { status: 200 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + num_results: 50, // Should be clamped to 20 + }; + + await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(capturedUrls[0]).toContain("count=20"); + }); + + test("handles missing web.results gracefully", async () => { + const mockResponse = { + web: {}, + }; + + const fakeFetch: typeof fetch = async () => { + return new Response(JSON.stringify(mockResponse), { status: 200 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + const result = await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(result.content).toBe("No results found"); + expect(result.sources).toEqual([]); + expect(result.images).toEqual([]); + }); + + test("handles missing thumbnails", async () => { + const mockResponse = { + web: { + results: [ + { + title: "Page 1", + url: "https://page1.com", + description: "Desc 1", + }, + { + title: "Page 2", + url: "https://page2.com", + description: "Desc 2", + thumbnail: { src: "https://img2.com/thumb.jpg" }, + }, + ], + }, + }; + + const fakeFetch: typeof fetch = async () => { + return new Response(JSON.stringify(mockResponse), { status: 200 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + const result = await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(result.images).toEqual(["https://img2.com/thumb.jpg"]); + }); + + test("measures latencyMs", async () => { + const fakeFetch: typeof fetch = async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return new Response(JSON.stringify({ web: { results: [] } }), { status: 200 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + const result = await searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch }); + + expect(result.meta.latencyMs).toBeGreaterThanOrEqual(50); + }); + + test("throws on non-OK response with status", async () => { + const fakeFetch: typeof fetch = async () => { + return new Response(JSON.stringify({ error: "Invalid API key" }), { status: 401 }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + await expect(searchBrave(input, {}, { apiKey: "bad-key", fetchFn: fakeFetch })).rejects.toThrow( + "brave error (401):" + ); + }); + + test( + "throws on timeout", + async () => { + const fakeFetch: typeof fetch = async (_url, options?: RequestInit) => { + return new Promise((resolve, reject) => { + const signal = options?.signal; + if (signal) { + signal.addEventListener("abort", () => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + reject(error); + }); + } + // Never resolve - let the abort signal handle it + }); + }; + + const input: SearchInput = { + query: "test", + provider: "brave", + }; + + await expect(searchBrave(input, {}, { apiKey: "test-key", fetchFn: fakeFetch })).rejects.toThrow( + "brave error (timeout): Request timed out after 30s" + ); + }, + 35000 + ); +}); diff --git a/utils/search-mcp/__tests__/exa.test.ts b/utils/search-mcp/__tests__/exa.test.ts new file mode 100644 index 0000000..7e7e0f7 --- /dev/null +++ b/utils/search-mcp/__tests__/exa.test.ts @@ -0,0 +1,454 @@ +import { describe, test, expect } from "bun:test"; +import type { SearchInput, ExaOptions, SearchResult } from "../types"; +import type { ExaClient } from "../providers/exa"; +import { searchExa } from "../providers/exa"; + +describe("searchExa", () => { + test("returns SearchResult from Exa response", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "Test Article", + url: "https://example.com/article", + text: "This is test content", + }, + ], + searchTime: 123, + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result).toMatchObject({ + content: expect.any(String), + sources: expect.any(Array), + images: expect.any(Array), + meta: { + provider: "exa", + latencyMs: expect.any(Number), + }, + }); + }); + + test("maps result titles and URLs to sources", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "First Article", + url: "https://example.com/first", + text: "First content", + }, + { + title: "Second Article", + url: "https://example.com/second", + text: "Second content", + }, + ], + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.sources).toHaveLength(2); + expect(result.sources[0]).toMatchObject({ + title: "First Article", + url: "https://example.com/first", + snippet: "First content", + }); + expect(result.sources[1]).toMatchObject({ + title: "Second Article", + url: "https://example.com/second", + snippet: "Second content", + }); + }); + + test("extracts images from results", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "Article with image", + url: "https://example.com/article", + text: "Content", + image: "https://example.com/image1.jpg", + }, + { + title: "Another article", + url: "https://example.com/article2", + text: "More content", + image: "https://example.com/image2.jpg", + }, + ], + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.images).toEqual([ + "https://example.com/image1.jpg", + "https://example.com/image2.jpg", + ]); + }); + + test("maps recency to startPublishedDate", async () => { + let capturedOptions: Record | undefined; + const fakeClient: ExaClient = { + search: async (_query, options) => { + capturedOptions = options; + return { results: [] }; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + recency: "week", + }; + + await searchExa(input, {}, { client: fakeClient }); + + expect(capturedOptions).toHaveProperty("startPublishedDate"); + expect(typeof capturedOptions?.startPublishedDate).toBe("string"); + }); + + test("maps search_type to Exa type param", async () => { + let capturedOptions: Record | undefined; + const fakeClient: ExaClient = { + search: async (_query, options) => { + capturedOptions = options; + return { results: [] }; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const options: ExaOptions = { + search_type: "neural", + }; + + await searchExa(input, options, { client: fakeClient }); + + expect(capturedOptions?.type).toBe("neural"); + }); + + test("maps include_domains and exclude_domains to camelCase", async () => { + let capturedOptions: Record | undefined; + const fakeClient: ExaClient = { + search: async (_query, options) => { + capturedOptions = options; + return { results: [] }; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const options: ExaOptions = { + include_domains: ["example.com", "test.com"], + exclude_domains: ["spam.com"], + }; + + await searchExa(input, options, { client: fakeClient }); + + expect(capturedOptions?.includeDomains).toEqual(["example.com", "test.com"]); + expect(capturedOptions?.excludeDomains).toEqual(["spam.com"]); + }); + + test("clamps num_results to max 100", async () => { + let capturedOptions: Record | undefined; + const fakeClient: ExaClient = { + search: async (_query, options) => { + capturedOptions = options; + return { results: [] }; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + num_results: 150, + }; + + await searchExa(input, {}, { client: fakeClient }); + + expect(capturedOptions?.numResults).toBe(100); + }); + + test("handles null titles as Untitled", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: null, + url: "https://example.com/no-title", + text: "Content without title", + }, + ], + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.sources[0].title).toBe("Untitled"); + }); + + test("handles empty results", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [], + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.sources).toEqual([]); + expect(result.images).toEqual([]); + expect(result.content).toBe("No content returned"); + }); + + test("includes cost in meta when available", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "Article", + url: "https://example.com/article", + text: "Content", + }, + ], + costDollars: { total: 0.0042 }, + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.meta.cost).toBe(0.0042); + }); + + test("passes include_text correctly", async () => { + let capturedOptions: Record | undefined; + const fakeClient: ExaClient = { + search: async (_query, options) => { + capturedOptions = options; + return { results: [] }; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const optionsWithText: ExaOptions = { + include_text: true, + }; + + await searchExa(input, optionsWithText, { client: fakeClient }); + + expect(capturedOptions?.contents).toEqual({ text: true }); + + // Test with include_text false + const optionsWithoutText: ExaOptions = { + include_text: false, + }; + + await searchExa(input, optionsWithoutText, { client: fakeClient }); + + expect(capturedOptions?.contents).toBe(false); + }); + + test("includes resolvedSearchType as model in meta", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "Article", + url: "https://example.com/article", + text: "Content", + }, + ], + resolvedSearchType: "neural", + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.meta.model).toBe("neural"); + }); + + test("uses searchTime for latencyMs when available", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "Article", + url: "https://example.com/article", + text: "Content", + }, + ], + searchTime: 456, + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.meta.latencyMs).toBe(456); + }); + + test("combines result texts into content", async () => { + const fakeClient: ExaClient = { + search: async () => ({ + results: [ + { + title: "First", + url: "https://example.com/1", + text: "First piece of content", + }, + { + title: "Second", + url: "https://example.com/2", + text: "Second piece of content", + }, + ], + }), + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const result = await searchExa(input, {}, { client: fakeClient }); + + expect(result.content).toContain("First piece of content"); + expect(result.content).toContain("Second piece of content"); + }); + + test("passes category option directly", async () => { + let capturedOptions: Record | undefined; + const fakeClient: ExaClient = { + search: async (_query, options) => { + capturedOptions = options; + return { results: [] }; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + const options: ExaOptions = { + category: "news", + }; + + await searchExa(input, options, { client: fakeClient }); + + expect(capturedOptions?.category).toBe("news"); + }); + + test("throws timeout error with dynamic duration", async () => { + const fakeClient: ExaClient = { + search: async () => { + const error = new DOMException("The operation was aborted", "AbortError"); + throw error; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + // Default 30s timeout + await expect(searchExa(input, {}, { client: fakeClient })).rejects.toThrow( + "exa error (timeout): Request timed out after 30s" + ); + + // Custom timeout + await expect(searchExa(input, {}, { client: fakeClient, timeoutMs: 5000 })).rejects.toThrow( + "exa error (timeout): Request timed out after 5s" + ); + }); + + test("normalizes client errors to exa error format", async () => { + const fakeClient: ExaClient = { + search: async () => { + throw new Error("Invalid API key"); + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + await expect(searchExa(input, {}, { client: fakeClient })).rejects.toThrow( + "exa error: Invalid API key" + ); + }); + + test("normalizes non-Error exceptions to exa error format", async () => { + const fakeClient: ExaClient = { + search: async () => { + throw "string error"; + }, + }; + + const input: SearchInput = { + query: "test query", + provider: "exa", + }; + + await expect(searchExa(input, {}, { client: fakeClient })).rejects.toThrow( + "exa error: string error" + ); + }); +}); diff --git a/utils/search-mcp/__tests__/format.test.ts b/utils/search-mcp/__tests__/format.test.ts new file mode 100644 index 0000000..3a43585 --- /dev/null +++ b/utils/search-mcp/__tests__/format.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { formatResponse } from "../format"; +import type { SearchResult } from "../types"; + +function makeResult(overrides: Partial = {}): SearchResult { + return { + content: "Search results content", + sources: [], + images: [], + meta: { provider: "perplexity", latencyMs: 150 }, + ...overrides, + }; +} + +describe("formatResponse", () => { + test("returns content as text", () => { + const result = formatResponse(makeResult()); + expect(result.content).toHaveLength(1); + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Search results content"); + }); + + test("includes sources section when sources exist", () => { + const result = formatResponse( + makeResult({ + sources: [ + { title: "Example", url: "https://example.com", snippet: "A snippet" }, + { title: "Other", url: "https://other.com" }, + ], + }) + ); + const text = result.content[0].text; + expect(text).toContain("**Sources:**"); + expect(text).toContain("[1] [Example](https://example.com)"); + expect(text).toContain("[2] [Other](https://other.com)"); + }); + + test("omits sources section when no sources", () => { + const result = formatResponse(makeResult({ sources: [] })); + expect(result.content[0].text).not.toContain("**Sources:**"); + }); + + test("includes images section when images exist", () => { + const result = formatResponse( + makeResult({ images: ["https://img.com/1.png", "https://img.com/2.png"] }) + ); + const text = result.content[0].text; + expect(text).toContain("**Images:**"); + expect(text).toContain("![](https://img.com/1.png)"); + expect(text).toContain("![](https://img.com/2.png)"); + }); + + test("omits images section when no images", () => { + const result = formatResponse(makeResult({ images: [] })); + expect(result.content[0].text).not.toContain("**Images:**"); + }); + + test("includes meta tag with provider info", () => { + const result = formatResponse( + makeResult({ + meta: { provider: "exa", latencyMs: 42, model: "exa-instant" }, + }) + ); + const text = result.content[0].text; + expect(text).toContain(""); + expect(text).toContain(""); + const metaMatch = text.match(/(.*?)<\/meta>/); + expect(metaMatch).not.toBeNull(); + const meta = JSON.parse(metaMatch![1]); + expect(meta.provider).toBe("exa"); + expect(meta.latencyMs).toBe(42); + expect(meta.model).toBe("exa-instant"); + }); + + test("does not set isError for normal results", () => { + const result = formatResponse(makeResult()); + expect(result.isError).toBeUndefined(); + }); + + test("full output structure matches expected format", () => { + const result = formatResponse( + makeResult({ + content: "Answer text", + sources: [{ title: "Src", url: "https://src.com" }], + images: ["https://img.com/a.png"], + meta: { provider: "brave", latencyMs: 200 }, + }) + ); + const text = result.content[0].text; + // Order: content, sources, images, meta + const contentIdx = text.indexOf("Answer text"); + const sourcesIdx = text.indexOf("**Sources:**"); + const imagesIdx = text.indexOf("**Images:**"); + const metaIdx = text.indexOf(""); + expect(contentIdx).toBeLessThan(sourcesIdx); + expect(sourcesIdx).toBeLessThan(imagesIdx); + expect(imagesIdx).toBeLessThan(metaIdx); + }); +}); diff --git a/utils/search-mcp/__tests__/index.test.ts b/utils/search-mcp/__tests__/index.test.ts new file mode 100644 index 0000000..8070e52 --- /dev/null +++ b/utils/search-mcp/__tests__/index.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { + detectAvailableProviders, + resolveDefaultProvider, + buildToolDescription, +} from "../index"; + +describe("detectAvailableProviders", () => { + test("detects perplexity when OPENROUTER_API_KEY is set", () => { + const result = detectAvailableProviders({ + OPENROUTER_API_KEY: "key", + }); + expect(result).toContain("perplexity"); + }); + + test("detects exa when EXA_API_KEY is set", () => { + const result = detectAvailableProviders({ + EXA_API_KEY: "key", + }); + expect(result).toContain("exa"); + }); + + test("detects brave when BRAVE_API_KEY is set", () => { + const result = detectAvailableProviders({ + BRAVE_API_KEY: "key", + }); + expect(result).toContain("brave"); + }); + + test("detects all three when all keys are set, ordered by priority", () => { + const result = detectAvailableProviders({ + OPENROUTER_API_KEY: "key", + EXA_API_KEY: "key", + BRAVE_API_KEY: "key", + }); + expect(result).toEqual(["exa", "brave", "perplexity"]); + }); + + test("returns empty array when no keys are set", () => { + const result = detectAvailableProviders({}); + expect(result).toEqual([]); + }); + + test("ignores empty string keys", () => { + const result = detectAvailableProviders({ + OPENROUTER_API_KEY: "", + }); + expect(result).toEqual([]); + }); +}); + +describe("resolveDefaultProvider", () => { + test("returns DEFAULT_PROVIDER when set and available", () => { + const result = resolveDefaultProvider("exa", ["perplexity", "exa", "brave"]); + expect(result).toBe("exa"); + }); + + test("returns first available provider when DEFAULT_PROVIDER is not set", () => { + const result = resolveDefaultProvider(undefined, ["exa", "brave"]); + expect(result).toBe("exa"); + }); + + test("returns first available in priority order: exa, brave, perplexity", () => { + const result = resolveDefaultProvider(undefined, ["exa", "brave", "perplexity"]); + expect(result).toBe("exa"); + }); + + test("throws when DEFAULT_PROVIDER is set to unavailable provider", () => { + expect(() => + resolveDefaultProvider("brave", ["perplexity", "exa"]) + ).toThrow("DEFAULT_PROVIDER 'brave' is not available"); + }); + + test("throws when DEFAULT_PROVIDER is set to unknown value", () => { + expect(() => + resolveDefaultProvider("google", ["perplexity"]) + ).toThrow("DEFAULT_PROVIDER 'google' is not a valid provider"); + }); + + test("throws when no providers available", () => { + expect(() => resolveDefaultProvider(undefined, [])).toThrow( + "No search providers available" + ); + }); +}); + +describe("buildToolDescription", () => { + test("lists all available providers", () => { + const desc = buildToolDescription(["perplexity", "exa", "brave"], "perplexity"); + expect(desc).toContain("perplexity"); + expect(desc).toContain("exa"); + expect(desc).toContain("brave"); + }); + + test("indicates the default provider", () => { + const desc = buildToolDescription(["perplexity", "exa"], "exa"); + expect(desc).toContain("exa"); + expect(desc).toContain("default"); + }); + + test("lists only available providers", () => { + const desc = buildToolDescription(["perplexity"], "perplexity"); + expect(desc).toContain("perplexity"); + expect(desc).not.toContain("exa"); + expect(desc).not.toContain("brave"); + }); +}); diff --git a/utils/search-mcp/__tests__/perplexity.test.ts b/utils/search-mcp/__tests__/perplexity.test.ts new file mode 100644 index 0000000..fe0971b --- /dev/null +++ b/utils/search-mcp/__tests__/perplexity.test.ts @@ -0,0 +1,369 @@ +import { describe, test, expect, mock, beforeEach } from "bun:test"; +import type { + SearchInput, + SearchResult, + PerplexityOptions, +} from "../types.ts"; +import { searchPerplexity } from "../providers/perplexity.ts"; + +describe("searchPerplexity", () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = mock(() => + Promise.resolve( + new Response("data: [DONE]\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ) + ); + global.fetch = mockFetch as any; + }); + + test("returns SearchResult with content from SSE stream", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Hello"}}]} +data: {"choices":[{"delta":{"content":" world"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + const result = await searchPerplexity( + input, + {}, + { apiKey: "test-key", defaultModel: "perplexity/sonar-pro" } + ); + + expect(result.content).toBe("Hello world"); + expect(result.meta.provider).toBe("perplexity"); + }); + + test("extracts citations into sources array", async () => { + const sseStream = `data: {"citations":["https://example.com","https://test.com"]} +data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + const result = await searchPerplexity( + input, + {}, + { apiKey: "test-key" } + ); + + expect(result.sources).toHaveLength(2); + expect(result.sources[0].url).toBe("https://example.com"); + expect(result.sources[0].title).toBe("https://example.com"); + expect(result.sources[1].url).toBe("https://test.com"); + }); + + test("extracts images from SSE stream", async () => { + const sseStream = `data: {"images":["https://img1.com/a.jpg"]} +data: {"choices":[{"delta":{"images":["https://img2.com/b.jpg"]}}]} +data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + const result = await searchPerplexity( + input, + {}, + { apiKey: "test-key" } + ); + + expect(result.images).toHaveLength(2); + expect(result.images).toContain("https://img1.com/a.jpg"); + expect(result.images).toContain("https://img2.com/b.jpg"); + }); + + test("includes model in meta", async () => { + const sseStream = `data: {"model":"perplexity/sonar-pro"} +data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + const result = await searchPerplexity( + input, + {}, + { apiKey: "test-key" } + ); + + expect(result.meta.model).toBe("perplexity/sonar-pro"); + }); + + test("measures latencyMs", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + const result = await searchPerplexity( + input, + {}, + { apiKey: "test-key" } + ); + + expect(result.meta.latencyMs).toBeGreaterThanOrEqual(0); + expect(typeof result.meta.latencyMs).toBe("number"); + }); + + test("applies recency to system prompt", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + recency: "week", + }; + + await searchPerplexity(input, {}, { apiKey: "test-key" }); + + const callArgs = mockFetch.mock.calls[0] as any[]; + const body = JSON.parse(callArgs[1].body); + const systemMessage = body.messages[0].content; + + expect(systemMessage).toContain("Focus on results from the last week"); + }); + + test("passes LLM tuning params to API request", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + const options: PerplexityOptions = { + temperature: 0.7, + top_p: 0.9, + top_k: 50, + max_tokens: 1000, + frequency_penalty: 0.5, + presence_penalty: 0.3, + }; + + await searchPerplexity(input, options, { apiKey: "test-key" }); + + const callArgs = mockFetch.mock.calls[0] as any[]; + const body = JSON.parse(callArgs[1].body); + + expect(body.temperature).toBe(0.7); + expect(body.top_p).toBe(0.9); + expect(body.top_k).toBe(50); + expect(body.max_tokens).toBe(1000); + expect(body.frequency_penalty).toBe(0.5); + expect(body.presence_penalty).toBe(0.3); + }); + + test("throws on non-OK response with status code", async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ error: "Bad request" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + await expect( + searchPerplexity(input, {}, { apiKey: "test-key" }) + ).rejects.toThrow('perplexity error (400): {"error":"Bad request"}'); + }); + + test("throws on timeout", async () => { + mockFetch.mockImplementation(() => { + return new Promise((_, reject) => { + setTimeout(() => { + const error = new Error("The operation was aborted"); + error.name = "AbortError"; + reject(error); + }, 10); + }); + }); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + // Use shorter timeout for testing + await expect( + searchPerplexity(input, {}, { apiKey: "test-key" }, 50) + ).rejects.toThrow("perplexity error (timeout): Request timed out after"); + }, 1000); + + test("throws on SSE error event", async () => { + const sseStream = `event: error +data: Rate limit exceeded +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + await expect( + searchPerplexity(input, {}, { apiKey: "test-key" }) + ).rejects.toThrow("SSE error: Rate limit exceeded"); + }); + + test("uses defaultModel when not specified in options", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + await searchPerplexity( + input, + {}, + { apiKey: "test-key", defaultModel: "perplexity/sonar-pro" } + ); + + const callArgs = mockFetch.mock.calls[0] as any[]; + const body = JSON.parse(callArgs[1].body); + + expect(body.model).toBe("perplexity/sonar-pro"); + }); + + test("uses model from options when specified", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + await searchPerplexity( + input, + { model: "perplexity/sonar" }, + { apiKey: "test-key", defaultModel: "perplexity/sonar-pro" } + ); + + const callArgs = mockFetch.mock.calls[0] as any[]; + const body = JSON.parse(callArgs[1].body); + + expect(body.model).toBe("perplexity/sonar"); + }); + + test("uses baseUrl from deps when provided", async () => { + const sseStream = `data: {"choices":[{"delta":{"content":"Answer"}}]} +data: [DONE] +`; + mockFetch.mockResolvedValue( + new Response(sseStream, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + ); + + const input: SearchInput = { + query: "test query", + provider: "perplexity", + }; + + await searchPerplexity( + input, + {}, + { apiKey: "test-key", baseUrl: "https://custom-url.com/v1/chat" } + ); + + const callArgs = mockFetch.mock.calls[0] as any[]; + const url = callArgs[0]; + + expect(url).toBe("https://custom-url.com/v1/chat"); + }); +}); diff --git a/utils/search-mcp/__tests__/types.test.ts b/utils/search-mcp/__tests__/types.test.ts new file mode 100644 index 0000000..7dcf813 --- /dev/null +++ b/utils/search-mcp/__tests__/types.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test"; +import { + recencyToDate, + clampNumResults, + validateProviderParams, + PROVIDER_PARAM_MATRIX, + type SearchProvider, + type Recency, +} from "../types"; + +describe("recencyToDate", () => { + test("day returns ISO date ~24h ago", () => { + const now = new Date("2026-02-14T12:00:00Z"); + const result = recencyToDate("day", now); + expect(result).toBe("2026-02-13T12:00:00.000Z"); + }); + + test("week returns ISO date ~7d ago", () => { + const now = new Date("2026-02-14T12:00:00Z"); + const result = recencyToDate("week", now); + expect(result).toBe("2026-02-07T12:00:00.000Z"); + }); + + test("month returns ISO date ~30d ago", () => { + const now = new Date("2026-02-14T12:00:00Z"); + const result = recencyToDate("month", now); + expect(result).toBe("2026-01-15T12:00:00.000Z"); + }); + + test("year returns ISO date ~365d ago", () => { + const now = new Date("2026-02-14T12:00:00Z"); + const result = recencyToDate("year", now); + expect(result).toBe("2025-02-14T12:00:00.000Z"); + }); + + test("defaults to current time when no reference provided", () => { + const before = Date.now(); + const result = recencyToDate("day"); + const after = Date.now(); + const resultMs = new Date(result).getTime(); + const expectedMin = before - 24 * 60 * 60 * 1000; + const expectedMax = after - 24 * 60 * 60 * 1000; + expect(resultMs).toBeGreaterThanOrEqual(expectedMin - 1000); + expect(resultMs).toBeLessThanOrEqual(expectedMax + 1000); + }); +}); + +describe("clampNumResults", () => { + test("brave clamps to max 20", () => { + expect(clampNumResults("brave", 50)).toBe(20); + }); + + test("brave passes through values <= 20", () => { + expect(clampNumResults("brave", 10)).toBe(10); + }); + + test("exa clamps to max 100", () => { + expect(clampNumResults("exa", 200)).toBe(100); + }); + + test("exa passes through values <= 100", () => { + expect(clampNumResults("exa", 50)).toBe(50); + }); + + test("perplexity returns undefined (LLM-based, no num_results)", () => { + expect(clampNumResults("perplexity", 10)).toBeUndefined(); + }); + + test("returns undefined when num_results is undefined", () => { + expect(clampNumResults("brave", undefined)).toBeUndefined(); + expect(clampNumResults("exa", undefined)).toBeUndefined(); + }); +}); + +describe("validateProviderParams", () => { + test("allows common params for any provider", () => { + const errors = validateProviderParams("perplexity", { + query: "test", + recency: "day", + }); + expect(errors).toEqual([]); + }); + + test("rejects exa-only params on perplexity", () => { + const errors = validateProviderParams("perplexity", { + include_domains: ["example.com"], + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("include_domains"); + expect(errors[0]).toContain("exa"); + }); + + test("rejects exa-only params on brave", () => { + const errors = validateProviderParams("brave", { + search_type: "instant", + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("search_type"); + expect(errors[0]).toContain("exa"); + }); + + test("allows exa-specific params on exa", () => { + const errors = validateProviderParams("exa", { + include_domains: ["example.com"], + search_type: "instant", + exclude_domains: ["spam.com"], + category: "news", + include_text: true, + }); + expect(errors).toEqual([]); + }); + + test("rejects perplexity-only params on exa", () => { + const errors = validateProviderParams("exa", { + model: "perplexity/sonar-pro", + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("model"); + expect(errors[0]).toContain("perplexity"); + }); + + test("rejects brave-only params on perplexity", () => { + const errors = validateProviderParams("perplexity", { + result_filter: "web", + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("result_filter"); + expect(errors[0]).toContain("brave"); + }); + + test("allows brave-specific params on brave", () => { + const errors = validateProviderParams("brave", { + result_filter: "web", + }); + expect(errors).toEqual([]); + }); + + test("reports multiple invalid params at once", () => { + const errors = validateProviderParams("brave", { + include_domains: ["example.com"], + model: "perplexity/sonar-pro", + }); + expect(errors).toHaveLength(2); + }); + + test("allows perplexity LLM tuning params", () => { + const errors = validateProviderParams("perplexity", { + temperature: 0.5, + top_p: 0.9, + top_k: 40, + max_tokens: 1000, + frequency_penalty: 0.5, + presence_penalty: 0.5, + }); + expect(errors).toEqual([]); + }); +}); + +describe("PROVIDER_PARAM_MATRIX", () => { + test("maps exa-only params correctly", () => { + expect(PROVIDER_PARAM_MATRIX.include_domains).toBe("exa"); + expect(PROVIDER_PARAM_MATRIX.exclude_domains).toBe("exa"); + expect(PROVIDER_PARAM_MATRIX.search_type).toBe("exa"); + expect(PROVIDER_PARAM_MATRIX.category).toBe("exa"); + expect(PROVIDER_PARAM_MATRIX.include_text).toBe("exa"); + }); + + test("maps perplexity-only params correctly", () => { + expect(PROVIDER_PARAM_MATRIX.model).toBe("perplexity"); + expect(PROVIDER_PARAM_MATRIX.temperature).toBe("perplexity"); + expect(PROVIDER_PARAM_MATRIX.top_p).toBe("perplexity"); + expect(PROVIDER_PARAM_MATRIX.top_k).toBe("perplexity"); + expect(PROVIDER_PARAM_MATRIX.max_tokens).toBe("perplexity"); + expect(PROVIDER_PARAM_MATRIX.frequency_penalty).toBe("perplexity"); + expect(PROVIDER_PARAM_MATRIX.presence_penalty).toBe("perplexity"); + }); + + test("maps brave-only params correctly", () => { + expect(PROVIDER_PARAM_MATRIX.result_filter).toBe("brave"); + }); +}); diff --git a/utils/search-mcp/format.ts b/utils/search-mcp/format.ts new file mode 100644 index 0000000..a88c17f --- /dev/null +++ b/utils/search-mcp/format.ts @@ -0,0 +1,35 @@ +import type { SearchResult, ToolOutput } from "./types"; + +export function formatResponse(result: SearchResult): ToolOutput { + const parts: string[] = [result.content]; + + if (result.sources.length > 0) { + parts.push("\n\n---\n**Sources:**"); + result.sources.forEach((source, i) => { + parts.push(`[${i + 1}] [${source.title}](${source.url})`); + }); + } + + if (result.images.length > 0) { + parts.push("\n\n**Images:**"); + result.images.forEach((image) => { + parts.push(`![](${image})`); + }); + } + + const meta = { + provider: result.meta.provider, + ...(result.meta.model && { model: result.meta.model }), + latencyMs: result.meta.latencyMs, + ...(result.meta.cost !== undefined && { cost: result.meta.cost }), + }; + + return { + content: [ + { + type: "text", + text: parts.join("\n") + `\n\n${JSON.stringify(meta)}`, + }, + ], + }; +} diff --git a/utils/search-mcp/index.ts b/utils/search-mcp/index.ts new file mode 100644 index 0000000..7f022d0 --- /dev/null +++ b/utils/search-mcp/index.ts @@ -0,0 +1,265 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import Exa from "exa-js"; + +import type { + SearchProvider, + SearchInput, + PerplexityOptions, + ExaOptions, + BraveOptions, +} from "./types"; +import { validateProviderParams } from "./types"; +import { formatResponse } from "./format"; +import { searchPerplexity } from "./providers/perplexity"; +import { searchExa, type ExaClient } from "./providers/exa"; +import { searchBrave } from "./providers/brave"; + +const ALL_PROVIDERS: SearchProvider[] = ["exa", "brave", "perplexity"]; + +const ENV_KEY_MAP: Record = { + perplexity: "OPENROUTER_API_KEY", + exa: "EXA_API_KEY", + brave: "BRAVE_API_KEY", +}; + +export function detectAvailableProviders( + env: Record +): SearchProvider[] { + return ALL_PROVIDERS.filter((p) => { + const key = env[ENV_KEY_MAP[p]]; + return key !== undefined && key !== ""; + }); +} + +export function resolveDefaultProvider( + defaultProvider: string | undefined, + available: SearchProvider[] +): SearchProvider { + if (available.length === 0) { + throw new Error( + "No search providers available. Set at least one of: OPENROUTER_API_KEY, EXA_API_KEY, BRAVE_API_KEY." + ); + } + + if (!defaultProvider) { + return available[0]; + } + + if (!ALL_PROVIDERS.includes(defaultProvider as SearchProvider)) { + throw new Error( + `DEFAULT_PROVIDER '${defaultProvider}' is not a valid provider. Valid: ${ALL_PROVIDERS.join(", ")}.` + ); + } + + if (!available.includes(defaultProvider as SearchProvider)) { + throw new Error( + `DEFAULT_PROVIDER '${defaultProvider}' is not available. Available: ${available.join(", ")}. Set ${ENV_KEY_MAP[defaultProvider as SearchProvider]} to enable.` + ); + } + + return defaultProvider as SearchProvider; +} + +export function buildToolDescription( + available: SearchProvider[], + defaultProvider: SearchProvider +): string { + const providerList = available + .map((p) => (p === defaultProvider ? `${p} (default)` : p)) + .join(", "); + return `Search the web using multiple providers. Available: ${providerList}. Omit 'provider' to use ${defaultProvider}.`; +} + +// --- Server startup (only runs when executed directly) --- + +function startServer() { + const available = detectAvailableProviders(process.env as Record); + const defaultProvider = resolveDefaultProvider(process.env.DEFAULT_PROVIDER, available); + + const envKeys = { + OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY || "", + OPENROUTER_MODEL: process.env.OPENROUTER_MODEL || "perplexity/sonar-pro", + EXA_API_KEY: process.env.EXA_API_KEY || "", + BRAVE_API_KEY: process.env.BRAVE_API_KEY || "", + }; + + // Initialize Exa client if available + let exaClient: ExaClient | undefined; + if (available.includes("exa")) { + exaClient = new Exa(envKeys.EXA_API_KEY) as unknown as ExaClient; + } + + const server = new McpServer({ name: "search", version: "1.0.0" }); + + server.registerTool( + "search_web", + { + description: buildToolDescription(available, defaultProvider), + inputSchema: { + query: z.string().describe("Search query"), + provider: z + .enum(["perplexity", "exa", "brave"]) + .optional() + .describe( + `Search provider. Available: ${available.join(", ")}. Default: ${defaultProvider}.` + ), + recency: z + .enum(["day", "week", "month", "year"]) + .optional() + .describe("Filter results by recency"), + num_results: z + .number() + .optional() + .describe("Number of results. Brave max 20, Exa max 100."), + // Perplexity-only params + model: z + .string() + .optional() + .describe("OpenRouter model ID (Perplexity only). Default: perplexity/sonar-pro."), + temperature: z.number().optional().describe("Generation randomness (Perplexity only)."), + top_p: z.number().optional().describe("Nucleus sampling threshold (Perplexity only)."), + top_k: z.number().optional().describe("Top-k token limit (Perplexity only)."), + max_tokens: z.number().optional().describe("Max tokens to generate (Perplexity only)."), + frequency_penalty: z.number().optional().describe("Frequency penalty (Perplexity only)."), + presence_penalty: z.number().optional().describe("Presence penalty (Perplexity only)."), + // Exa-only params + search_type: z + .enum(["instant", "auto", "neural", "fast", "deep"]) + .optional() + .describe("Exa search type (Exa only). Default: auto."), + include_domains: z + .array(z.string()) + .optional() + .describe("Only include results from these domains (Exa only)."), + exclude_domains: z + .array(z.string()) + .optional() + .describe("Exclude results from these domains (Exa only)."), + category: z.string().optional().describe("Result category filter (Exa only)."), + include_text: z + .boolean() + .optional() + .describe("Include full text in results (Exa only)."), + // Brave-only params + result_filter: z + .enum(["web"]) + .optional() + .describe("Result type filter (Brave only). Only 'web' supported."), + }, + }, + async (args) => { + const provider = (args.provider ?? defaultProvider) as SearchProvider; + + // Check provider availability + if (!available.includes(provider)) { + return { + content: [ + { + type: "text" as const, + text: `Provider '${provider}' is not available. Available: ${available.join(", ")}. Set ${ENV_KEY_MAP[provider]} to enable.`, + }, + ], + isError: true, + }; + } + + // Validate provider-specific params (FR-7) + const paramErrors = validateProviderParams(provider, args); + if (paramErrors.length > 0) { + return { + content: [{ type: "text" as const, text: paramErrors.join("\n") }], + isError: true, + }; + } + + const input: SearchInput = { + query: args.query, + provider, + recency: args.recency as SearchInput["recency"], + num_results: args.num_results, + }; + + try { + let result; + + switch (provider) { + case "perplexity": { + const perplexityOpts: PerplexityOptions = { + model: args.model, + temperature: args.temperature, + top_p: args.top_p, + top_k: args.top_k, + max_tokens: args.max_tokens, + frequency_penalty: args.frequency_penalty, + presence_penalty: args.presence_penalty, + }; + result = await searchPerplexity(input, perplexityOpts, { + apiKey: envKeys.OPENROUTER_API_KEY, + defaultModel: envKeys.OPENROUTER_MODEL, + }); + break; + } + + case "exa": { + if (!exaClient) { + return { + content: [{ type: "text" as const, text: "Exa client not initialized. Set EXA_API_KEY to enable." }], + isError: true, + }; + } + const exaOpts: ExaOptions = { + search_type: args.search_type as ExaOptions["search_type"], + include_domains: args.include_domains, + exclude_domains: args.exclude_domains, + category: args.category, + include_text: args.include_text, + }; + result = await searchExa(input, exaOpts, { + client: exaClient, + }); + break; + } + + case "brave": { + const braveOpts: BraveOptions = { + result_filter: args.result_filter as BraveOptions["result_filter"], + }; + result = await searchBrave(input, braveOpts, { + apiKey: envKeys.BRAVE_API_KEY, + }); + break; + } + } + + return formatResponse(result); + } catch (error) { + return { + content: [ + { + type: "text" as const, + text: `${provider} error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + } + ); + + async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + } + + main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); + }); +} + +// Only start server when run directly (not when imported for testing) +if (import.meta.main) { + startServer(); +} diff --git a/utils/search-mcp/providers/brave.ts b/utils/search-mcp/providers/brave.ts new file mode 100644 index 0000000..5707e81 --- /dev/null +++ b/utils/search-mcp/providers/brave.ts @@ -0,0 +1,107 @@ +import type { SearchInput, SearchResult, BraveOptions, Recency } from "../types"; +import { clampNumResults } from "../types"; + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +interface BraveWebResult { + title: string; + url: string; + description: string; + thumbnail?: { src: string }; +} + +interface BraveSearchResponse { + web?: { results: BraveWebResult[] }; +} + +const FRESHNESS_MAP: Record = { + day: "pd", + week: "pw", + month: "pm", + year: "py", +}; + +export async function searchBrave( + input: SearchInput, + _options: BraveOptions, + deps: { apiKey: string; baseUrl?: string; fetchFn?: FetchFn } +): Promise { + const startTime = performance.now(); + const { apiKey, baseUrl = "https://api.search.brave.com/res/v1/web/search", fetchFn = fetch } = deps; + + // Build query params + const params = new URLSearchParams(); + params.set("q", input.query); + + const count = clampNumResults("brave", input.num_results); + if (count !== undefined) { + params.set("count", String(count)); + } + + if (input.recency) { + params.set("freshness", FRESHNESS_MAP[input.recency]); + } + + const url = `${baseUrl}?${params.toString()}`; + + // Create AbortController with 30s timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 30000); + + try { + const response = await fetchFn(url, { + headers: { + "X-Subscription-Token": apiKey, + Accept: "application/json", + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`brave error (${response.status}): ${errorText}`); + } + + const data: BraveSearchResponse = await response.json(); + const results: BraveWebResult[] = data.web?.results || []; + + // Extract sources + const sources = results.map((result) => ({ + title: result.title, + url: result.url, + snippet: result.description, + })); + + // Extract images + const images = results + .filter((result) => result.thumbnail?.src) + .map((result) => result.thumbnail!.src); + + // Build content + const content = results.length > 0 + ? results.map((result) => result.description).join("\n\n") + : "No results found"; + + const latencyMs = performance.now() - startTime; + + return { + content, + sources, + images, + meta: { + provider: "brave", + latencyMs, + }, + }; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof Error && error.name === "AbortError") { + throw new Error("brave error (timeout): Request timed out after 30s"); + } + + throw error; + } +} diff --git a/utils/search-mcp/providers/exa.ts b/utils/search-mcp/providers/exa.ts new file mode 100644 index 0000000..4e8a67d --- /dev/null +++ b/utils/search-mcp/providers/exa.ts @@ -0,0 +1,128 @@ +import type { SearchInput, SearchResult, ExaOptions } from "../types"; +import { recencyToDate, clampNumResults } from "../types"; + +export interface ExaClient { + search(query: string, options?: Record): Promise<{ + results: Array<{ + title: string | null; + url: string; + text?: string; + image?: string; + }>; + costDollars?: { total: number }; + resolvedSearchType?: string; + searchTime?: number; + }>; +} + +export async function searchExa( + input: SearchInput, + options: ExaOptions, + deps: { client: ExaClient; timeoutMs?: number } +): Promise { + const startTime = Date.now(); + const timeoutMs = deps.timeoutMs ?? 30_000; + + // Build Exa search options + const searchOptions: Record = {}; + + // Map recency to startPublishedDate + if (input.recency) { + searchOptions.startPublishedDate = recencyToDate(input.recency); + } + + // Map search_type to type + if (options.search_type) { + searchOptions.type = options.search_type; + } + + // Map include_domains and exclude_domains + if (options.include_domains) { + searchOptions.includeDomains = options.include_domains; + } + if (options.exclude_domains) { + searchOptions.excludeDomains = options.exclude_domains; + } + + // Map category + if (options.category) { + searchOptions.category = options.category; + } + + // Map include_text to contents + if (options.include_text !== undefined) { + searchOptions.contents = options.include_text ? { text: true } : false; + } + + // Clamp num_results + const clampedResults = clampNumResults("exa", input.num_results); + if (clampedResults !== undefined) { + searchOptions.numResults = clampedResults; + } + + let response; + let timeoutId: ReturnType | undefined; + try { + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new DOMException("The operation was aborted", "AbortError")); + }, timeoutMs); + }); + + response = await Promise.race([ + deps.client.search(input.query, searchOptions), + timeoutPromise, + ]); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new Error(`exa error (timeout): Request timed out after ${timeoutMs / 1000}s`); + } + const message = error instanceof Error ? error.message : String(error); + throw new Error(`exa error: ${message}`); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + + // Calculate latency + const latencyMs = response.searchTime ?? (Date.now() - startTime); + + // Map results to sources + const sources = response.results.map((result) => ({ + title: result.title ?? "Untitled", + url: result.url, + snippet: result.text, + })); + + // Extract images + const images = response.results + .map((result) => result.image) + .filter((image): image is string => image !== undefined); + + // Build content from result texts + const contentParts = response.results + .map((result) => result.text) + .filter((text): text is string => text !== undefined); + const content = + contentParts.length > 0 ? contentParts.join("\n\n") : "No content returned"; + + // Build meta + const meta: SearchResult["meta"] = { + provider: "exa", + latencyMs, + }; + + if (response.resolvedSearchType) { + meta.model = response.resolvedSearchType; + } + + if (response.costDollars?.total !== undefined) { + meta.cost = response.costDollars.total; + } + + return { + content, + sources, + images, + meta, + }; +} diff --git a/utils/search-mcp/providers/perplexity.ts b/utils/search-mcp/providers/perplexity.ts new file mode 100644 index 0000000..142a05d --- /dev/null +++ b/utils/search-mcp/providers/perplexity.ts @@ -0,0 +1,225 @@ +import type { + SearchInput, + SearchResult, + PerplexityOptions, +} from "../types.ts"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1/chat/completions"; + +interface SSEState { + content: string; + citations: Set; + images: Set; + model: string; + usage: Record; + pendingError: boolean; +} + +interface SSEResult { + content: string; + citations: string[]; + images: string[]; + model: string; + usage: Record; +} + +export function processSSELine(line: string, state: SSEState): void { + if (line.startsWith("event: error")) { + state.pendingError = true; + return; + } + + if (!line.startsWith("data: ")) { + if (line.trim() === "") state.pendingError = false; + return; + } + + const data = line.slice("data: ".length).trim(); + + if (state.pendingError) { + state.pendingError = false; + throw new Error(`SSE error: ${data}`); + } + + if (data === "[DONE]") return; + + let parsed: Record; + try { + parsed = JSON.parse(data); + } catch { + return; + } + + if (parsed.model) state.model = parsed.model as string; + if (parsed.usage) state.usage = parsed.usage as Record; + + if (parsed.citations) { + for (const c of parsed.citations as string[]) state.citations.add(c); + } + + const choices = parsed.choices as + | Array<{ delta?: { content?: string; images?: string[] } }> + | undefined; + if (choices?.[0]?.delta?.content) { + state.content += choices[0].delta.content; + } + + if (parsed.images) { + for (const img of parsed.images as string[]) state.images.add(img); + } + + const choiceImages = choices?.[0]?.delta?.images; + if (choiceImages) { + for (const img of choiceImages) state.images.add(img); + } +} + +export async function parseSSEStream(response: Response): Promise { + if (!response.body) { + throw new Error("Response body is null"); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + const state: SSEState = { + content: "", + citations: new Set(), + images: new Set(), + model: "", + usage: {} as Record, + pendingError: false, + }; + let buffer = ""; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + processSSELine(line, state); + } + } + + // Process any residual buffer content + if (buffer.trim()) { + processSSELine(buffer, state); + } + } finally { + reader.releaseLock(); + } + + return { + content: state.content, + citations: [...state.citations], + images: [...state.images], + model: state.model, + usage: state.usage, + }; +} + +export async function searchPerplexity( + input: SearchInput, + options: PerplexityOptions, + deps: { apiKey: string; baseUrl?: string; defaultModel?: string }, + timeoutMs: number = DEFAULT_TIMEOUT_MS +): Promise { + const startTime = performance.now(); + const model = options.model || deps.defaultModel || "perplexity/sonar-pro"; + const baseUrl = deps.baseUrl || DEFAULT_BASE_URL; + + const systemParts = [ + "You are a helpful search assistant. Provide accurate, well-cited answers.", + ]; + if (input.recency) { + systemParts.push(`Focus on results from the last ${input.recency}.`); + } + + const body: Record = { + model, + stream: true, + messages: [ + { role: "system", content: systemParts.join(" ") }, + { role: "user", content: input.query }, + ], + }; + + if (options.temperature !== undefined) body.temperature = options.temperature; + if (options.top_p !== undefined) body.top_p = options.top_p; + if (options.top_k !== undefined) body.top_k = options.top_k; + if (options.max_tokens !== undefined) body.max_tokens = options.max_tokens; + if (options.frequency_penalty !== undefined) + body.frequency_penalty = options.frequency_penalty; + if (options.presence_penalty !== undefined) + body.presence_penalty = options.presence_penalty; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(baseUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${deps.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/iamladi/sdlc-plugin", + "X-Title": "SDLC Plugin MCP Server", + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (error: unknown) { + clearTimeout(timeout); + if (error instanceof Error && error.name === "AbortError") { + throw new Error( + `perplexity error (timeout): Request timed out after ${timeoutMs / 1000}s` + ); + } + throw new Error( + `perplexity error (network): ${error instanceof Error ? error.message : String(error)}` + ); + } + + if (!response.ok) { + clearTimeout(timeout); + let errorBody: string; + try { + errorBody = await response.text(); + } catch { + errorBody = "Unable to read error body"; + } + throw new Error(`perplexity error (${response.status}): ${errorBody}`); + } + + let sseResult: SSEResult; + try { + sseResult = await parseSSEStream(response); + } finally { + clearTimeout(timeout); + } + + const endTime = performance.now(); + const latencyMs = Math.round(endTime - startTime); + + const sources = sseResult.citations.map((url) => ({ + title: url, + url, + })); + + return { + content: sseResult.content, + sources, + images: sseResult.images, + meta: { + provider: "perplexity", + model: sseResult.model, + latencyMs, + }, + }; +} diff --git a/utils/search-mcp/types.ts b/utils/search-mcp/types.ts new file mode 100644 index 0000000..30394c0 --- /dev/null +++ b/utils/search-mcp/types.ts @@ -0,0 +1,120 @@ +export type SearchProvider = "perplexity" | "exa" | "brave"; + +export type Recency = "day" | "week" | "month" | "year"; + +export interface SearchSource { + title: string; + url: string; + snippet?: string; +} + +export interface SearchResult { + content: string; + sources: SearchSource[]; + images: string[]; + meta: { + provider: SearchProvider; + model?: string; + latencyMs: number; + cost?: number; + }; +} + +export interface SearchInput { + query: string; + provider: SearchProvider; + recency?: Recency; + num_results?: number; +} + +export interface PerplexityOptions { + model?: string; + temperature?: number; + top_p?: number; + top_k?: number; + max_tokens?: number; + frequency_penalty?: number; + presence_penalty?: number; +} + +export interface ExaOptions { + search_type?: "instant" | "auto" | "neural" | "fast" | "deep"; + include_domains?: string[]; + exclude_domains?: string[]; + category?: string; + include_text?: boolean; +} + +export interface BraveOptions { + result_filter?: "web"; +} + +export type ToolOutput = { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}; + +const RECENCY_DAYS: Record = { + day: 1, + week: 7, + month: 30, + year: 365, +}; + +export function recencyToDate(recency: Recency, now?: Date): string { + const reference = now ?? new Date(); + const ms = RECENCY_DAYS[recency] * 24 * 60 * 60 * 1000; + return new Date(reference.getTime() - ms).toISOString(); +} + +export const PROVIDER_PARAM_MATRIX: Record = { + // Exa-only + search_type: "exa", + include_domains: "exa", + exclude_domains: "exa", + category: "exa", + include_text: "exa", + // Perplexity-only + model: "perplexity", + temperature: "perplexity", + top_p: "perplexity", + top_k: "perplexity", + max_tokens: "perplexity", + frequency_penalty: "perplexity", + presence_penalty: "perplexity", + // Brave-only + result_filter: "brave", +}; + +const NUM_RESULTS_MAX: Partial> = { + brave: 20, + exa: 100, +}; + +export function clampNumResults( + provider: SearchProvider, + numResults: number | undefined +): number | undefined { + if (numResults === undefined) return undefined; + if (provider === "perplexity") return undefined; + const max = NUM_RESULTS_MAX[provider]; + if (max !== undefined && numResults > max) return max; + return numResults; +} + +export function validateProviderParams( + provider: SearchProvider, + params: Record +): string[] { + const errors: string[] = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined) continue; + const allowedProvider = PROVIDER_PARAM_MATRIX[key]; + if (allowedProvider && allowedProvider !== provider) { + errors.push( + `'${key}' is only available with provider='${allowedProvider}'.` + ); + } + } + return errors; +}