Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 50 additions & 0 deletions src/agent/tools/web.provider.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => '<a href="https://example.com">Example result</a>' }); 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();
});
});
10 changes: 10 additions & 0 deletions src/agent/tools/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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.
Expand Down
6 changes: 5 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
39 changes: 39 additions & 0 deletions src/integrations/parallelSearch.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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();
}
}
12 changes: 12 additions & 0 deletions src/stores/featureStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
3 changes: 3 additions & 0 deletions src/stores/featureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -326,6 +328,7 @@ const DEFAULTS: FeatureConfig = {
autoContinue: false,
completionSound: false,
webSearchEnabled: true,
webSearchProvider: "duckduckgo",
webFetchEnabled: true,
approvalPolicy: DEFAULT_APPROVAL,
docSources: [],
Expand Down
6 changes: 6 additions & 0 deletions webview-ui/settings/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,12 @@ export function App() {
<Row title="Web Search Tool" desc="Allow the agent to search the web for relevant information.">
<Toggle checked={features.webSearchEnabled !== false} onChange={(v) => setFeatures({ webSearchEnabled: v })} />
</Row>
<Row title="Web Search Provider" desc="DuckDuckGo is the default. Parallel needs Node.js and no API key; queries and search context go to Parallel when selected. Free access is rate limited.">
<select value={features.webSearchProvider ?? "duckduckgo"} onChange={(e) => setFeatures({ webSearchProvider: e.target.value as "duckduckgo" | "parallel" })}>
<option value="duckduckgo">DuckDuckGo</option>
<option value="parallel">Parallel (free, no key)</option>
</select>
</Row>
<Row title="Web Fetch Tool" desc="Allow the agent to fetch content from URLs.">
<Toggle checked={features.webFetchEnabled !== false} onChange={(v) => setFeatures({ webFetchEnabled: v })} />
</Row>
Expand Down
3 changes: 3 additions & 0 deletions webview-ui/settings/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -379,6 +381,7 @@ export const EMPTY_FEATURES: FeatureConfig = {
autoContinue: false,
completionSound: false,
webSearchEnabled: true,
webSearchProvider: "duckduckgo",
webFetchEnabled: true,
approvalPolicy: DEFAULT_APPROVAL,
indexingEnabled: true,
Expand Down