From 53968d448ab1d6b82e0e4b2acb156b7937388520 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 21 Sep 2026 12:49:47 -0700 Subject: [PATCH 1/2] Add optional Parallel provider for WebSearch --- CHANGELOG.md | 6 +++++ README.md | 6 +++++ src/agent/tools/web.provider.test.ts | 40 ++++++++++++++++++++++++++++ src/agent/tools/web.ts | 10 +++++++ src/extension.ts | 6 ++++- src/integrations/parallelSearch.ts | 39 +++++++++++++++++++++++++++ src/stores/featureStore.test.ts | 12 +++++++++ src/stores/featureStore.ts | 3 +++ webview-ui/settings/App.tsx | 6 +++++ webview-ui/settings/features.ts | 3 +++ 10 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/agent/tools/web.provider.test.ts create mode 100644 src/integrations/parallelSearch.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bab51bad..9fcadab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the "ocursor" extension will be documented in this file. Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. +## [Unreleased] + +### Added + +- Optional Parallel provider for WebSearch in Settings > Agents > Context, using free Search MCP without an API key; DuckDuckGo remains the default + ## [0.1.4] - 2026-09-11 ### Added diff --git a/README.md b/README.md index e713a9cc..860a12cb 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,12 @@ Ask questions in plain language — *"where do we refresh the auth token?"* — > Native runtime dependencies (ONNX runtime, image processing) are downloaded once on first activation with integrity checks — they're too heavy to ship in the VSIX. +## Web search + +DuckDuckGo remains the default. To use Parallel, open OpenCursor Settings > Agents > Context and select **Parallel (free, no key)** as the Web Search Provider. + +Parallel uses the [free Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) through a stdio bridge. It needs Node.js (with `npx`) on your PATH, internet access, and no Parallel account or API key. The first search downloads the bridge. Free access is rate limited. When selected, search queries go to `https://search.parallel.ai/mcp`; the agent may search under your existing web approval settings. Web Fetch keeps its existing behavior. Switching back to DuckDuckGo restores the original search path. + ## Building from source ```bash diff --git a/src/agent/tools/web.provider.test.ts b/src/agent/tools/web.provider.test.ts new file mode 100644 index 00000000..28e7b00f --- /dev/null +++ b/src/agent/tools/web.provider.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +const bridge = vi.hoisted(() => ({ connect: vi.fn(), callTool: vi.fn(), dispose: vi.fn(), tools: [{ name: "web_search" }], configs: [] as any[] })); +vi.mock("../../integrations/mcpClient", () => ({ McpConnection: class { + tools = bridge.tools; connect = bridge.connect; callTool = bridge.callTool; dispose = bridge.dispose; + constructor(config: any) { bridge.configs.push(config); } +} })); +import { setWebSearchProvider, webSearchTool } from "./web"; +afterEach(() => { setWebSearchProvider(undefined); vi.unstubAllGlobals(); vi.clearAllMocks(); bridge.configs.length = 0; }); +describe("built-in WebSearch provider routing", () => { + it("keeps DuckDuckGo by default without starting a bridge", async () => { + const fetch = vi.fn().mockResolvedValue({ ok: true, text: async () => 'Example result' }); vi.stubGlobal("fetch", fetch); + expect((await webSearchTool.execute({ search_term: "example" })).output).toContain("https://example.com"); + expect(fetch.mock.calls[0][0]).toContain("duckduckgo.com"); expect(bridge.configs).toHaveLength(0); + }); + it("routes explicit selection through discovery and preserves citations", async () => { + setWebSearchProvider("parallel", "0.1.4"); + bridge.callTool.mockResolvedValue(JSON.stringify({ results: [{ title: "Example", url: "https://example.com", excerpts: ["Useful evidence"] }], warnings: ["Query shortened"] })); + const result = await webSearchTool.execute({ search_term: "example" }); + expect(result.output).toContain("Useful evidence"); expect(result.output).toContain("https://example.com"); expect(result.output).toContain("Query shortened"); + expect(bridge.callTool).toHaveBeenCalledWith("web_search", { objective: "example", search_queries: ["example"] }, undefined); + expect(bridge.configs[0].args).toContain("User-Agent:OpenCursor/0.1.4"); expect(bridge.configs[0].args).toContain("https://search.parallel.ai/mcp"); expect(bridge.dispose).toHaveBeenCalledOnce(); + }); + it.each(["error: rate limited", "not JSON", JSON.stringify({ results: {} }), JSON.stringify({ results: [{ url: "https://example.com", excerpts: [42] }] })])("reports failure without fallback: %s", async (payload) => { + setWebSearchProvider("parallel"); const fetch = vi.fn(); vi.stubGlobal("fetch", fetch); bridge.callTool.mockResolvedValue(payload); + expect((await webSearchTool.execute({ search_term: "example" })).output).toMatch(/^error:/); expect(fetch).not.toHaveBeenCalled(); expect(bridge.dispose).toHaveBeenCalledOnce(); + }); + it("distinguishes empty success from failure", async () => { + setWebSearchProvider("parallel"); bridge.callTool.mockResolvedValue(JSON.stringify({ results: [] })); expect((await webSearchTool.execute({ search_term: "example" })).output).toContain("No results found"); + }); + it("cancels bridge initialization", async () => { + setWebSearchProvider("parallel"); const abort = new AbortController(); bridge.connect.mockImplementationOnce(async () => { abort.abort(); }); + expect((await webSearchTool.execute({ search_term: "example" }, abort.signal)).output).toContain("aborted"); expect(bridge.callTool).not.toHaveBeenCalled(); expect(bridge.dispose).toHaveBeenCalled(); + }); + it("does not start a bridge after cancellation", async () => { + setWebSearchProvider("parallel"); const abort = new AbortController(); abort.abort(); expect((await webSearchTool.execute({ search_term: "example" }, abort.signal)).output).toContain("aborted"); expect(bridge.configs).toHaveLength(0); + }); + it("reports missing Node/npx", async () => { + setWebSearchProvider("parallel"); bridge.connect.mockRejectedValueOnce(new Error("spawn npx ENOENT")); expect((await webSearchTool.execute({ search_term: "example" })).output).toContain("ENOENT"); expect(bridge.dispose).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/agent/tools/web.ts b/src/agent/tools/web.ts index eaf89d9e..32743806 100644 --- a/src/agent/tools/web.ts +++ b/src/agent/tools/web.ts @@ -8,6 +8,15 @@ */ import { defineTool } from "./types"; +import { parallelSearch } from "../../integrations/parallelSearch"; + +let extensionVersion: string | undefined; +let webSearchProvider: "duckduckgo" | "parallel" = "duckduckgo"; + +export function setWebSearchProvider(provider: "duckduckgo" | "parallel" | undefined, version?: string): void { + webSearchProvider = provider ?? "duckduckgo"; + extensionVersion = version; +} const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"; @@ -110,6 +119,7 @@ export const webSearchTool = defineTool("WebSearch", false, async (input, abortS const LIMIT = 10; try { + if (webSearchProvider === "parallel") return { output: await parallelSearch(term, abortSignal, extensionVersion) }; let hits = await ddgSearch(term, "https://html.duckduckgo.com/html/", abortSignal, LIMIT); if (hits.length === 0) { // Fallback engine/endpoint. diff --git a/src/extension.ts b/src/extension.ts index 54b0c6dc..105e11af 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -15,6 +15,7 @@ import { registerGitSync } from './integrations/gitSync'; import { SettingsPanel } from './ui/settingsPanel'; import { FeatureStore } from './stores/featureStore'; import { setToolTimeoutOverrides } from './agent/tools/shared'; +import { setWebSearchProvider } from './agent/tools/web'; import { mcpManager } from './integrations/mcpClient'; import { setIndexStorageDir } from './agent/semanticIndex'; import { setDocsStorageDir, setDocSourcesProvider } from './agent/docsIndex'; @@ -36,7 +37,10 @@ export function activate(context: vscode.ExtensionContext) { const settingsManager = new SettingsManager(context); const featureStore = new FeatureStore(context); - const syncToolTimeouts = () => setToolTimeoutOverrides(featureStore.get().toolTimeoutsSec); + const syncToolTimeouts = () => { + setToolTimeoutOverrides(featureStore.get().toolTimeoutsSec); + setWebSearchProvider(featureStore.get().webSearchProvider, context.extension.packageJSON.version); + }; syncToolTimeouts(); context.subscriptions.push(featureStore.onDidChange(syncToolTimeouts)); initOAuth(context); diff --git a/src/integrations/parallelSearch.ts b/src/integrations/parallelSearch.ts new file mode 100644 index 00000000..6b57b938 --- /dev/null +++ b/src/integrations/parallelSearch.ts @@ -0,0 +1,39 @@ +import { McpConnection } from "./mcpClient"; + +/** Uses the host's stdio MCP client, without registering extra generic MCP tools. */ +export async function parallelSearch(term: string, signal?: AbortSignal, version?: string): Promise { + if (signal?.aborted) throw new Error("aborted: web search"); + const connection = new McpConnection({ + name: "parallel-search", + transport: "stdio", + command: "npx", + args: ["-y", "mcp-remote@0.14.3", "https://search.parallel.ai/mcp", "--transport", "http-only", "--header", `User-Agent:OpenCursor${version ? `/${version}` : ""}`], + enabled: true, + }); + // Disposing also rejects initialization, so cancellation covers bridge startup. + const abort = () => connection.dispose(); + signal?.addEventListener("abort", abort, { once: true }); + try { + await connection.connect(30_000); + if (signal?.aborted) throw new Error("aborted: web search"); + if (!connection.tools.some((tool) => tool.name === "web_search")) throw new Error("Parallel MCP did not expose web_search"); + const text = await connection.callTool("web_search", { objective: term, search_queries: [term] }, signal); + if (text.startsWith("error:")) throw new Error(text); + const payload = JSON.parse(text); + if (!Array.isArray(payload.results)) throw new Error("Invalid Parallel search result"); + const lines = [`Web results for "${term}" (Parallel):`]; + if (Array.isArray(payload.warnings)) lines.push(...payload.warnings.map((warning: unknown) => `Warning: ${String(warning)}`)); + for (const result of payload.results.slice(0, 10)) { + if (typeof result.url !== "string" || !/^https?:\/\//.test(result.url) || !Array.isArray(result.excerpts) || !result.excerpts.every((excerpt: unknown) => typeof excerpt === "string")) { + throw new Error("Invalid Parallel search result"); + } + lines.push(`${result.title || "(untitled)"}\n${result.url}\n${result.excerpts.join("\n")}`); + } + if (payload.results.length === 0) lines.push("No results found. Try rephrasing the query."); + const output = lines.join("\n\n"); + return output.length > 20_000 ? `${output.slice(0, 20_000)}\n[Search output truncated]` : output; + } finally { + signal?.removeEventListener("abort", abort); + connection.dispose(); + } +} diff --git a/src/stores/featureStore.test.ts b/src/stores/featureStore.test.ts index e9ea66aa..daf14adb 100644 --- a/src/stores/featureStore.test.ts +++ b/src/stores/featureStore.test.ts @@ -141,3 +141,15 @@ describe("current flagship catalog boundaries", () => { expect(store.defFor("gemini-3.1-pro-preview", "google")).toBeDefined(); }); }); + +describe("web search provider settings", () => { + it("keeps existing installs on DuckDuckGo and persists an explicit Parallel choice", async () => { + const store = storeWith({}); + expect(store.get().webSearchProvider).toBe("duckduckgo"); + await store.set({ webSearchProvider: "parallel" }); + expect(store.get().webSearchProvider).toBe("parallel"); + await store.set({ webSearchEnabled: false }); + expect(store.get().webSearchProvider).toBe("parallel"); + expect(store.get().webSearchEnabled).toBe(false); + }); +}); diff --git a/src/stores/featureStore.ts b/src/stores/featureStore.ts index fdd1dbb5..32438415 100644 --- a/src/stores/featureStore.ts +++ b/src/stores/featureStore.ts @@ -276,6 +276,8 @@ export interface FeatureConfig { completionSound: boolean; /** Allow the agent to use the WebSearch tool. */ webSearchEnabled: boolean; + /** Backend used by the built-in WebSearch tool. */ + webSearchProvider: "duckduckgo" | "parallel"; /** Allow the agent to use the WebFetch tool. */ webFetchEnabled: boolean; /** Per-action-type approval policy (shell/edits/delete/mcp/web). */ @@ -326,6 +328,7 @@ const DEFAULTS: FeatureConfig = { autoContinue: false, completionSound: false, webSearchEnabled: true, + webSearchProvider: "duckduckgo", webFetchEnabled: true, approvalPolicy: DEFAULT_APPROVAL, docSources: [], diff --git a/webview-ui/settings/App.tsx b/webview-ui/settings/App.tsx index 703334cd..27c0bbd9 100644 --- a/webview-ui/settings/App.tsx +++ b/webview-ui/settings/App.tsx @@ -1030,6 +1030,12 @@ export function App() { setFeatures({ webSearchEnabled: v })} /> + + + setFeatures({ webFetchEnabled: v })} /> diff --git a/webview-ui/settings/features.ts b/webview-ui/settings/features.ts index 4143ccb3..b5e31895 100644 --- a/webview-ui/settings/features.ts +++ b/webview-ui/settings/features.ts @@ -285,6 +285,8 @@ export interface FeatureConfig { autoContinue: boolean; completionSound: boolean; webSearchEnabled: boolean; + /** Backend used by the built-in WebSearch tool. */ + webSearchProvider: "duckduckgo" | "parallel"; webFetchEnabled: boolean; approvalPolicy: ApprovalPolicy; indexingEnabled: boolean; @@ -379,6 +381,7 @@ export const EMPTY_FEATURES: FeatureConfig = { autoContinue: false, completionSound: false, webSearchEnabled: true, + webSearchProvider: "duckduckgo", webFetchEnabled: true, approvalPolicy: DEFAULT_APPROVAL, indexingEnabled: true, From 6518def9667d2cdf46f50e95b58d703680f901cb Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 21 Sep 2026 13:03:58 -0700 Subject: [PATCH 2/2] Preserve structured Parallel search warnings --- src/agent/tools/web.provider.test.ts | 10 ++++++++++ src/integrations/parallelSearch.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/agent/tools/web.provider.test.ts b/src/agent/tools/web.provider.test.ts index 28e7b00f..7b4f7743 100644 --- a/src/agent/tools/web.provider.test.ts +++ b/src/agent/tools/web.provider.test.ts @@ -27,6 +27,16 @@ describe("built-in WebSearch provider routing", () => { it("distinguishes empty success from failure", async () => { setWebSearchProvider("parallel"); bridge.callTool.mockResolvedValue(JSON.stringify({ results: [] })); expect((await webSearchTool.execute({ search_term: "example" })).output).toContain("No results found"); }); + it("preserves structured warning messages and details", async () => { + setWebSearchProvider("parallel"); + const warning = { type: "query_shortened", message: "Query shortened", detail: "Only the first 200 characters were searched" }; + bridge.callTool.mockResolvedValue(JSON.stringify({ results: [], warnings: [warning] })); + const result = await webSearchTool.execute({ search_term: "example" }); + expect(result.output).toContain(warning.message); + expect(result.output).toContain(warning.detail); + expect(result.output).toContain(warning.type); + expect(result.output).not.toContain("[object Object]"); + }); it("cancels bridge initialization", async () => { setWebSearchProvider("parallel"); const abort = new AbortController(); bridge.connect.mockImplementationOnce(async () => { abort.abort(); }); expect((await webSearchTool.execute({ search_term: "example" }, abort.signal)).output).toContain("aborted"); expect(bridge.callTool).not.toHaveBeenCalled(); expect(bridge.dispose).toHaveBeenCalled(); diff --git a/src/integrations/parallelSearch.ts b/src/integrations/parallelSearch.ts index 6b57b938..502b70a3 100644 --- a/src/integrations/parallelSearch.ts +++ b/src/integrations/parallelSearch.ts @@ -22,7 +22,7 @@ export async function parallelSearch(term: string, signal?: AbortSignal, version const payload = JSON.parse(text); if (!Array.isArray(payload.results)) throw new Error("Invalid Parallel search result"); const lines = [`Web results for "${term}" (Parallel):`]; - if (Array.isArray(payload.warnings)) lines.push(...payload.warnings.map((warning: unknown) => `Warning: ${String(warning)}`)); + if (Array.isArray(payload.warnings)) lines.push(...payload.warnings.map((warning: unknown) => `Warning: ${typeof warning === "string" ? warning : JSON.stringify(warning)}`)); for (const result of payload.results.slice(0, 10)) { if (typeof result.url !== "string" || !/^https?:\/\//.test(result.url) || !Array.isArray(result.excerpts) || !result.excerpts.every((excerpt: unknown) => typeof excerpt === "string")) { throw new Error("Invalid Parallel search result");