diff --git a/README.md b/README.md index a400e81c..f44dfa9b 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Each plugin lives in `plugins/`. The directory name is the install keyword | `speak` | Speaks completed Cline replies with ElevenLabs text to speech. | | `typescript-lsp` | TypeScript language service `goto_definition` support. | | `weather-metrics` | Demo weather tool plus runtime metrics hooks. | -| `web-search` | Exa-backed web search as a Cline tool. | +| `web-search` | Web search with free Parallel Search or a configured Exa API key. | ## Install From Source diff --git a/plugins/web-search/README.md b/plugins/web-search/README.md index 4211e18d..9570fd37 100644 --- a/plugins/web-search/README.md +++ b/plugins/web-search/README.md @@ -1,10 +1,10 @@ # web-search -Adds Exa-backed web search as a Cline tool. +Adds web search as a Cline tool, with free Parallel Search available without an API key. ## What It Does -Registers `web_search`, which searches public web results through Exa and returns normalized result metadata. Use it to discover relevant URLs before fetching page content with normal Cline tools. +Registers `web_search`, which searches public web results through Parallel or Exa and returns normalized result metadata. Use it to discover relevant URLs before fetching page content with normal Cline tools. ## Install @@ -26,12 +26,14 @@ After installation, ask Cline: Search the web for current Cline plugin package manifest examples and summarize the most relevant results. ``` -Cline can call `web_search` to retrieve Exa-backed web results before deciding which public pages to inspect. +Cline can call `web_search` to retrieve web results before deciding which public pages to inspect. ## Requirements -- `EXA_API_KEY` +No search API key is required. Without `EXA_API_KEY`, the plugin uses the free Parallel Search MCP endpoint at `https://search.parallel.ai/mcp`. + +Set `EXA_API_KEY` to use Exa instead. Existing Exa configurations continue to take priority. ## Security Notes -Queries are sent to Exa. Do not include private code, secrets, customer data, or other confidential text in search queries. +Queries are sent to Exa when `EXA_API_KEY` is configured, or to Parallel otherwise. Do not include private code, secrets, customer data, or other confidential text in search queries. diff --git a/plugins/web-search/index.test.mjs b/plugins/web-search/index.test.mjs new file mode 100644 index 00000000..6ce3ab6f --- /dev/null +++ b/plugins/web-search/index.test.mjs @@ -0,0 +1,320 @@ +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import test from "node:test"; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@cline/core") { + return { + url: "data:text/javascript,export const createTool = options => options", + shortCircuit: true, + }; + } + return nextResolve(specifier, context); + }, +}); + +const { searchWeb } = await import("./index.ts"); + +function mockSearchEnvironment(t, apiKey, response) { + const originalApiKey = process.env.EXA_API_KEY; + if (apiKey === undefined) { + delete process.env.EXA_API_KEY; + } else { + process.env.EXA_API_KEY = apiKey; + } + + t.after(() => { + if (originalApiKey === undefined) { + delete process.env.EXA_API_KEY; + } else { + process.env.EXA_API_KEY = originalApiKey; + } + }); + + return t.mock.method(globalThis, "fetch", async (...args) => response(...args)); +} + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +test("uses anonymous Parallel MCP when no Exa key is configured", async (t) => { + const fetchMock = mockSearchEnvironment(t, undefined, (url, options) => { + assert.equal(url, "https://search.parallel.ai/mcp"); + assert.equal(options.method, "POST"); + assert.deepEqual(options.headers, { + "Content-Type": "application/json", + Accept: "application/json", + }); + + const request = JSON.parse(options.body); + assert.equal(request.jsonrpc, "2.0"); + assert.equal(request.method, "tools/call"); + assert.equal(request.params.name, "web_search"); + assert.match(request.params.arguments.objective, /recent Cline releases/); + assert.match(request.params.arguments.objective, /github\.com/); + assert.match(request.params.arguments.objective, /7 days/); + assert.match(request.params.arguments.objective, /us/i); + assert.deepEqual(request.params.arguments.search_queries, [ + "recent Cline releases site:github.com", + ]); + assert.match( + request.params.arguments.session_id, + /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/, + ); + + return jsonResponse({ + jsonrpc: "2.0", + id: request.id, + result: { + structuredContent: { + search_id: "search_parallel_123", + results: [ + { + title: "Cline releases", + url: "https://github.com/cline/cline/releases", + publish_date: "2026-08-24", + excerpts: ["Latest release", "More details"], + }, + { title: "Missing URL", excerpts: ["Skip this result"] }, + { + url: "https://github.com/cline/plugins", + excerpts: ["Official plugins"], + }, + { + url: "https://github.com/cline/extra", + excerpts: ["Over the requested limit"], + }, + ], + }, + }, + }); + }); + + const result = await searchWeb({ + query: "recent Cline releases", + limit: 2, + domains: ["https://github.com/cline", "github.com"], + recencyDays: 7, + country: "US", + }); + + assert.deepEqual(result, { + provider: "parallel", + query: "recent Cline releases", + requestId: "search_parallel_123", + results: [ + { + title: "Cline releases", + url: "https://github.com/cline/cline/releases", + snippet: "Latest release More details", + publishedAt: "2026-08-24", + source: "parallel", + }, + { + title: "https://github.com/cline/plugins", + url: "https://github.com/cline/plugins", + snippet: "Official plugins", + publishedAt: undefined, + source: "parallel", + }, + ], + }); + assert.equal(fetchMock.mock.callCount(), 1); +}); + +test("reuses a stable session for anonymous Parallel searches", async (t) => { + const sessionIds = []; + mockSearchEnvironment(t, undefined, (_url, options) => { + const request = JSON.parse(options.body); + sessionIds.push(request.params.arguments.session_id); + return jsonResponse({ + jsonrpc: "2.0", + id: request.id, + result: { structuredContent: { results: [] } }, + }); + }); + + await searchWeb({ query: "first search" }); + await searchWeb({ query: "second search" }); + + assert.equal(sessionIds.length, 2); + assert.equal(sessionIds[0], sessionIds[1]); +}); + +test("enforces requested domains on Parallel search results", async (t) => { + mockSearchEnvironment(t, undefined, () => + jsonResponse({ + result: { + structuredContent: { + results: [ + { url: "https://example.com/first", excerpts: [] }, + { url: "https://example.com.attacker.test", excerpts: [] }, + { url: "not a valid URL", excerpts: [] }, + { url: "https://docs.example.com/second", excerpts: [] }, + ], + }, + }, + }), + ); + + const result = await searchWeb({ + query: "example documentation", + domains: ["EXAMPLE.COM"], + }); + + assert.deepEqual( + result.results.map(({ url }) => url), + ["https://example.com/first", "https://docs.example.com/second"], + ); +}); + +test("preserves configured Exa requests, normalization, and provider priority", async (t) => { + const fetchMock = mockSearchEnvironment(t, " configured-exa-key ", (url, options) => { + assert.equal(url, "https://api.exa.ai/search"); + assert.equal(options.headers["x-api-key"], "configured-exa-key"); + + const request = JSON.parse(options.body); + assert.equal(request.query, "Cline plugin docs"); + assert.equal(request.numResults, 1); + assert.deepEqual(request.contents, { highlights: true }); + assert.deepEqual(request.includeDomains, ["docs.cline.bot"]); + assert.equal(request.userLocation, "us"); + assert.ok(Date.parse(request.startPublishedDate)); + + return jsonResponse({ + requestId: "exa_request_123", + results: [ + { + title: "Plugin docs", + url: "https://docs.cline.bot/plugins", + highlights: ["Install plugins"], + publishedDate: "2026-08-24", + author: "Cline", + score: 0.9, + }, + { url: "https://docs.cline.bot/extra" }, + ], + }); + }); + + const result = await searchWeb({ + query: "Cline plugin docs", + limit: 1, + domains: ["https://docs.cline.bot/plugins"], + recencyDays: 3, + country: "US", + }); + + assert.deepEqual(result, { + provider: "exa", + query: "Cline plugin docs", + requestId: "exa_request_123", + results: [ + { + title: "Plugin docs", + url: "https://docs.cline.bot/plugins", + snippet: "Install plugins", + publishedAt: "2026-08-24", + author: "Cline", + score: 0.9, + source: "exa", + }, + ], + }); + assert.equal(fetchMock.mock.callCount(), 1); +}); + +test("accepts MCP responses that provide only JSON text content", async (t) => { + mockSearchEnvironment(t, " ", () => + jsonResponse({ + jsonrpc: "2.0", + id: 1, + result: { + content: [ + { + type: "text", + text: JSON.stringify({ + search_id: "search_text_only", + results: [ + { + title: "Cline", + url: "https://cline.bot", + excerpts: ["Coding agent"], + }, + ], + }), + }, + ], + }, + }), + ); + + const result = await searchWeb({ query: "Cline" }); + assert.equal(result.provider, "parallel"); + assert.equal(result.requestId, "search_text_only"); + assert.equal(result.results[0].url, "https://cline.bot"); +}); + +test("surfaces JSON-RPC errors returned by Parallel", async (t) => { + mockSearchEnvironment(t, undefined, () => + jsonResponse({ + jsonrpc: "2.0", + id: 1, + error: { code: -32602, message: "Invalid search arguments" }, + }), + ); + + await assert.rejects(searchWeb({ query: "Cline" }), /Invalid search arguments/); +}); + +test("surfaces MCP tool execution errors returned by Parallel", async (t) => { + mockSearchEnvironment(t, undefined, () => + jsonResponse({ + jsonrpc: "2.0", + id: 1, + result: { + isError: true, + content: [{ type: "text", text: "Search rate limit exceeded" }], + }, + }), + ); + + await assert.rejects(searchWeb({ query: "Cline" }), /Search rate limit exceeded/); +}); + +test("rejects malformed successful MCP search payloads", async (t) => { + mockSearchEnvironment(t, undefined, () => + jsonResponse({ + jsonrpc: "2.0", + id: 1, + result: { content: [{ type: "text", text: "not JSON" }] }, + }), + ); + + await assert.rejects(searchWeb({ query: "Cline" }), /invalid search results/i); +}); + +test("preserves meaningful HTTP errors from the fallback endpoint", async (t) => { + mockSearchEnvironment(t, undefined, () => + jsonResponse({ error: "Parallel temporarily unavailable" }, 503), + ); + + await assert.rejects( + searchWeb({ query: "Cline" }), + /HTTP 503: Parallel temporarily unavailable/, + ); +}); + +test("rejects blank queries before making a provider request", async (t) => { + const fetchMock = mockSearchEnvironment(t, undefined, () => { + throw new Error("fetch should not be called"); + }); + + await assert.rejects(searchWeb({ query: " " }), /query is required/); + assert.equal(fetchMock.mock.callCount(), 0); +}); diff --git a/plugins/web-search/index.ts b/plugins/web-search/index.ts index b953aba7..dc7f3a61 100644 --- a/plugins/web-search/index.ts +++ b/plugins/web-search/index.ts @@ -1,17 +1,20 @@ /** * Web Search Plugin Example * - * Registers a `web_search` tool backed by Exa. + * Registers a `web_search` tool backed by Exa or free Parallel Search. * * CLI usage: * cline plugin install web-search + * cline "Search the web for recent TypeScript 6 updates" * EXA_API_KEY=... cline "Search the web for recent TypeScript 6 updates" * * Provider key: - * EXA_API_KEY Enables Exa search. A separate model provider key - * is still required for CLI inference. + * EXA_API_KEY Enables Exa search when configured. Otherwise, + * anonymous Parallel Search is used. A separate + * model provider key may be needed for inference. */ +import { randomUUID } from "node:crypto"; import { type AgentPlugin, createTool } from "@cline/core"; export interface WebSearchInput { @@ -29,11 +32,11 @@ export interface WebSearchResult { publishedAt?: string; author?: string; score?: number; - source: "exa"; + source: "exa" | "parallel"; } export interface WebSearchOutput { - provider: "exa"; + provider: "exa" | "parallel"; query: string; results: WebSearchResult[]; requestId?: string; @@ -56,9 +59,32 @@ interface ExaSearchResponse { error?: string; } +interface ParallelSearchResult { + title?: string | null; + url?: string; + publish_date?: string | null; + excerpts?: string[]; +} + +interface ParallelSearchResponse { + search_id?: string; + results?: ParallelSearchResult[]; +} + +interface McpToolResponse { + error?: { message?: string }; + result?: { + isError?: boolean; + structuredContent?: ParallelSearchResponse; + content?: Array<{ type?: string; text?: string }>; + }; +} + const DEFAULT_RESULT_LIMIT = 5; const MAX_RESULT_LIMIT = 10; const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const PARALLEL_SEARCH_ENDPOINT = "https://search.parallel.ai/mcp"; +const PARALLEL_SESSION_ID = randomUUID(); function env(name: string): string | undefined { const value = process.env[name]?.trim(); @@ -129,12 +155,24 @@ function asNumber(value: unknown): number | undefined { : undefined; } -function hasResultUrl( - result: ExaSearchResult, -): result is ExaSearchResult & { url: string } { +function hasResultUrl( + result: T, +): result is T & { url: string } { return typeof result.url === "string" && result.url.trim().length > 0; } +function hasAllowedDomain(url: string, domains: string[]): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return domains.some((domain) => { + const normalized = domain.toLowerCase(); + return hostname === normalized || hostname.endsWith(`.${normalized}`); + }); + } catch { + return false; + } +} + function parseWebSearchInput(input: unknown): WebSearchInput { if (!input || typeof input !== "object") { throw new Error("web_search input must be an object"); @@ -183,14 +221,6 @@ async function readJsonResponse(response: Response): Promise { return body as T; } -function resolveExaApiKey(): string { - const exaApiKey = env("EXA_API_KEY"); - if (!exaApiKey) { - throw new Error("Set EXA_API_KEY to use web_search"); - } - return exaApiKey; -} - async function searchExa( input: WebSearchInput, apiKey: string, @@ -250,6 +280,93 @@ async function searchExa( }; } +async function searchParallel( + input: WebSearchInput, + limit: number, + domains: string[] | undefined, +): Promise { + const objective = [input.query]; + if (domains) { + objective.push(`Limit results to these domains: ${domains.join(", ")}.`); + } + if ( + typeof input.recencyDays === "number" && + Number.isFinite(input.recencyDays) && + input.recencyDays > 0 + ) { + objective.push( + `Prefer results published within the last ${Math.trunc(input.recencyDays)} days.`, + ); + } + if (input.country) { + objective.push(`Prefer results relevant to ${input.country.toLowerCase()}.`); + } + + const domainQuery = domains?.map((domain) => `site:${domain}`).join(" OR "); + const searchQuery = domainQuery + ? `${input.query} ${(domains?.length ?? 0) > 1 ? `(${domainQuery})` : domainQuery}` + : input.query; + + const response = await fetch(PARALLEL_SEARCH_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "web_search", + arguments: { + objective: objective.join(" "), + search_queries: [searchQuery], + session_id: PARALLEL_SESSION_ID, + }, + }, + }), + }); + const json = await readJsonResponse(response); + if (json.error) { + throw new Error(json.error.message || "Parallel search failed"); + } + + const text = json.result?.content?.find((item) => item.type === "text")?.text; + if (json.result?.isError) { + throw new Error(text || "Parallel search failed"); + } + + let search = json.result?.structuredContent; + if (!search && text) { + try { + search = JSON.parse(text) as ParallelSearchResponse; + } catch { + throw new Error("Parallel returned invalid search results"); + } + } + if (!search || !Array.isArray(search.results)) { + throw new Error("Parallel returned invalid search results"); + } + + return { + provider: "parallel", + query: input.query, + requestId: search.search_id, + results: search.results + .filter(hasResultUrl) + .filter((result) => !domains || hasAllowedDomain(result.url, domains)) + .slice(0, limit) + .map((result) => ({ + title: result.title || result.url || "Untitled", + url: result.url, + snippet: truncateSnippet(result.excerpts?.join("\n")), + publishedAt: result.publish_date ?? undefined, + source: "parallel", + })), + }; +} + export async function searchWeb( input: WebSearchInput, ): Promise { @@ -259,9 +376,11 @@ export async function searchWeb( const limit = clampResultLimit(input.limit); const domains = normalizeDomains(input.domains); - const apiKey = resolveExaApiKey(); + const apiKey = env("EXA_API_KEY"); - return searchExa(input, apiKey, limit, domains); + return apiKey + ? searchExa(input, apiKey, limit, domains) + : searchParallel(input, limit, domains); } const plugin: AgentPlugin = { @@ -275,9 +394,9 @@ const plugin: AgentPlugin = { createTool({ name: "web_search", description: - "Search the web for current public information using Exa. " + + "Search the web for current public information using Exa or Parallel. " + "Use this to discover relevant URLs, news, docs, and recent facts; use fetch_web_content afterward when a page needs deeper inspection. " + - "Requires EXA_API_KEY in the plugin host environment.", + "Uses Exa when EXA_API_KEY is configured, or free Parallel Search otherwise.", inputSchema: { type: "object", properties: { @@ -299,7 +418,7 @@ const plugin: AgentPlugin = { recencyDays: { type: "number", description: - "Optional freshness window in days. Maps to Exa startPublishedDate.", + "Optional freshness window in days. Maps to Exa startPublishedDate or guides Parallel search.", }, country: { type: "string",