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..7b4f7743
--- /dev/null
+++ b/src/agent/tools/web.provider.test.ts
@@ -0,0 +1,50 @@
+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("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();
+ });
+ 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..502b70a3
--- /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: ${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");
+ }
+ 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,