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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ bun run open-browser run "Find the top story on Hacker News and summarize it"
bun run open-browser interactive
```

## Web search

The agent's `search` action accepts `engine: "google"`, `"duckduckgo"`, `"bing"`, or `"parallel"`. Google remains the default when `engine` is omitted. The separate `web_search` action also continues to use Google.

`{"action":"search","query":"latest TypeScript release","engine":"parallel"}` uses [Parallel's free Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) over Streamable HTTP. It needs no Parallel account or API key. The action returns up to five results with source URLs and excerpts in the agent's command result; it does not navigate the browser. Free access is rate limited. The query and an objective based on it are sent to Parallel, and the MCP request identifies this project as `open-browser/<package version>` for aggregate usage measurement. Browser cookies and page content are not sent. URL restrictions also apply to the MCP endpoint; any `allowedUrls` configuration must allow `https://search.parallel.ai/mcp` to use this engine. Search results themselves are not filtered by `allowedUrls` or `blockedUrls`.

The other search engines navigate the browser as before. `parallel` is an explicit choice; existing search behavior is unchanged unless it is selected.

## Architecture

Open Browser is a monorepo with three packages:
Expand Down
186 changes: 186 additions & 0 deletions bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@ai-sdk/openai": "^1.1.0",
"@ai-sdk/anthropic": "^1.1.0",
"@ai-sdk/google": "^1.1.0",
"@modelcontextprotocol/sdk": "^1.30.0",
"zod": "^3.24.0",
"playwright": "^1.51.0",
"mitt": "^3.0.1",
Expand Down
15 changes: 13 additions & 2 deletions packages/core/src/commands/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
ViewportCrashedError,
} from '../errors.js';
import { sleep } from '../utils.js';
import { PARALLEL_SEARCH_MCP_URL, searchParallel } from './parallel-search.js';

export interface CommandExecutorOptions {
model?: LanguageModel;
Expand Down Expand Up @@ -560,15 +561,25 @@ export class CommandExecutor {
// Search page (multi-engine)
this.registry.register({
name: 'search',
description: 'Search the web using a specified search engine',
description: 'Search the web using Google, DuckDuckGo, Bing, or Parallel. Parallel returns cited results without navigating the browser.',
schema: SearchCommandSchema.omit({ action: true }),
handler: async (params, ctx) => {
const { query, engine } = params as {
query: string;
engine?: 'google' | 'duckduckgo' | 'bing';
engine?: 'google' | 'duckduckgo' | 'bing' | 'parallel';
};

const searchEngine = engine ?? 'google';
if (searchEngine === 'parallel') {
if (!isUrlPermitted(PARALLEL_SEARCH_MCP_URL, this.allowedUrls, this.blockedUrls)) {
throw new UrlBlockedError(PARALLEL_SEARCH_MCP_URL);
}
return {
success: true,
extractedContent: await searchParallel(query),
includeInMemory: true,
};
}
const url = buildSearchUrl(query, searchEngine);

if (!isUrlPermitted(url, this.allowedUrls, this.blockedUrls)) {
Expand Down
120 changes: 120 additions & 0 deletions packages/core/src/commands/parallel-search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { CommandExecutor } from './executor.js';
import type { Command, ExecutionContext } from './types.js';

const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});

function context(): ExecutionContext {
return {
browserSession: {
navigate: () => {
throw new Error('Unexpected navigation');
},
} as unknown as ExecutionContext['browserSession'],
} as ExecutionContext;
}

function mcpResponse(id: number, result: unknown): Response {
return Response.json(
{ jsonrpc: '2.0', id, result },
{
headers: { 'content-type': 'application/json' },
},
);
}

describe('Parallel search action', () => {
test('uses the MCP tool and returns bounded, cited results without navigation', async () => {
const requests: Array<{ method: string; userAgent: string | null; body: any }> = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body));
requests.push({ method: init?.method ?? '', userAgent: new Headers(init?.headers).get('user-agent'), body });
if (body.method === 'initialize') {
return mcpResponse(body.id, {
protocolVersion: '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'fixture', version: '1' },
});
}
if (body.method === 'notifications/initialized') return new Response(null, { status: 202 });
if (body.method === 'tools/call') {
return mcpResponse(body.id, {
content: [{ type: 'text', text: 'duplicate representation' }],
structuredContent: {
results: [{ url: 'https://example.com/source', title: 'A useful source', excerpts: ['A source excerpt'] }],
warnings: [{ type: 'query_adjusted', message: 'Query shortened', detail: { original_length: 300 } }],
},
});
}
throw new Error(`Unexpected MCP method ${body.method}`);
}) as unknown as typeof fetch;

const result = await new CommandExecutor().executeAction(
{ action: 'search', query: 'test query', engine: 'parallel' } as Command,
context(),
);
expect(result.success).toBe(true);
expect(result.extractedContent).toContain('A useful source');
expect(result.extractedContent).toContain('https://example.com/source');
expect(result.extractedContent).toContain('A source excerpt');
expect(result.extractedContent).toContain('query_adjusted: Query shortened');
expect(result.extractedContent).not.toContain('duplicate representation');
expect(requests.some((request) => request.body.method === 'tools/call')).toBe(true);
const call = requests.find((request) => request.body.method === 'tools/call');
expect(call?.body.params.name).toBe('web_search');
expect(call?.body.params.arguments.search_queries).toEqual(['test query']);
expect(call?.body.params.arguments.objective).toContain('test query');
expect(requests.every((request) => request.userAgent === 'open-browser/1.1.0')).toBe(true);
expect(requests.every((request) => request.method === 'POST')).toBe(true);
});

test('preserves MCP tool errors as failures', async () => {
globalThis.fetch = (async (_input, init) => {
const body = JSON.parse(String(init?.body));
if (body.method === 'initialize')
return mcpResponse(body.id, {
protocolVersion: '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'fixture', version: '1' },
});
if (body.method === 'notifications/initialized') return new Response(null, { status: 202 });
return mcpResponse(body.id, { isError: true, content: [{ type: 'text', text: 'Rate limited' }] });
}) as typeof fetch;
await expect(
new CommandExecutor().executeAction(
{ action: 'search', query: 'test query', engine: 'parallel' } as Command,
context(),
),
).rejects.toThrow('Rate limited');
});

test('rejects redirects before sending the query to another origin', async () => {
const requestedUrls: string[] = [];
globalThis.fetch = (async (input) => {
requestedUrls.push(String(input));
return new Response(null, { status: 302, headers: { location: 'https://example.com/' } });
}) as typeof fetch;
await expect(
new CommandExecutor().executeAction(
{ action: 'search', query: 'test query', engine: 'parallel' } as Command,
context(),
),
).rejects.toThrow('redirected');
expect(requestedUrls).toEqual(['https://search.parallel.ai/mcp']);
});

test('honors URL restrictions before making a third-party request', async () => {
globalThis.fetch = (async (_input: RequestInfo | URL, _init?: RequestInit) => {
throw new Error('Unexpected request');
}) as unknown as typeof fetch;
await expect(
new CommandExecutor({ allowedUrls: ['example.com'] }).executeAction(
{ action: 'search', query: 'test query', engine: 'parallel' } as Command,
context(),
),
).rejects.toThrow('search.parallel.ai');
});
});
128 changes: 128 additions & 0 deletions packages/core/src/commands/parallel-search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { readFileSync } from 'node:fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';

export const PARALLEL_SEARCH_MCP_URL = 'https://search.parallel.ai/mcp';
const ENDPOINT = new URL(PARALLEL_SEARCH_MCP_URL);
const MAX_RESPONSE_BYTES = 1_000_000;
const REQUEST_TIMEOUT_MS = 20_000;
const PACKAGE_VERSION = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
.version as string;

const SearchPayload = z.object({
results: z.array(
z.object({
url: z.string().url().max(2048),
title: z.string().nullish(),
excerpts: z.array(z.string()),
}),
),
warnings: z
.array(
z.union([
z.string(),
z.object({ type: z.string(), message: z.string(), detail: z.record(z.unknown()).nullish() }),
]),
)
.nullish(),
});

/** Search through Parallel's anonymous Search MCP, returning bounded, cited text. */
export async function searchParallel(query: string): Promise<string> {
const searchQuery = query.trim();
if (!searchQuery || searchQuery.length > 200) {
throw new Error('Parallel search query must contain 1 to 200 characters');
}

const transport = new StreamableHTTPClientTransport(ENDPOINT, {
// Identify this project for aggregate free MCP usage without user identifiers.
requestInit: { headers: { 'User-Agent': `open-browser/${PACKAGE_VERSION}` } },
fetch: boundedFetch,
});
const client = new Client({ name: 'open-browser', version: PACKAGE_VERSION });

try {
await client.connect(transport);
const result = CallToolResultSchema.parse(
await client.callTool(
{
name: 'web_search',
arguments: {
objective: `Find current web information about ${searchQuery}`,
search_queries: [searchQuery],
},
},
undefined,
{ timeout: REQUEST_TIMEOUT_MS },
),
);
if (result.isError) {
const detail = result.content.find((item) => item.type === 'text');
throw new Error(`Parallel search failed: ${detail?.type === 'text' ? detail.text.slice(0, 300) : 'tool error'}`);
}

const textContent = result.content.find((item) => item.type === 'text');
const raw = result.structuredContent ?? (textContent?.type === 'text' ? JSON.parse(textContent.text) : undefined);
const payload = SearchPayload.parse(raw);
const lines = payload.results.slice(0, 5).map((item, index) => {
const excerpts = item.excerpts.join(' ').trim();
return `${index + 1}. ${item.title?.slice(0, 200) || item.url}\n${item.url}\n${excerpts.slice(0, 700) || 'No excerpt available.'}${excerpts.length > 700 ? ' [excerpt shortened]' : ''}`;
});
if (lines.length === 0) lines.push('No results found.');
if (payload.results.length > 5) lines.push('Additional results omitted.');
if (payload.warnings?.length) {
const warnings = payload.warnings.map((warning) =>
typeof warning === 'string' ? warning : `${warning.type}: ${warning.message}`,
);
lines.push(`Warnings: ${warnings.join('; ').slice(0, 500)}`);
}
return lines.join('\n\n');
} finally {
if (transport.sessionId) await transport.terminateSession().catch(() => {});
await client.close().catch(() => {});
}
}

async function boundedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const requestedUrl = new URL(input instanceof Request ? input.url : String(input));
if (requestedUrl.href !== ENDPOINT.href) {
throw new Error('Parallel MCP request left the configured endpoint');
}
const response = await fetch(input, {
...init,
redirect: 'manual',
signal: AbortSignal.any(
[init?.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)].filter((signal): signal is AbortSignal => signal != null),
),
});
if (response.status >= 300 && response.status < 400) {
await response.body?.cancel();
throw new Error('Parallel MCP redirected outside its fixed endpoint');
}
const contentLength = Number(response.headers.get('content-length'));
if (contentLength > MAX_RESPONSE_BYTES) {
await response.body?.cancel();
throw new Error('Parallel MCP response exceeded the size limit');
}
if (!response.body) return response;
let bytes = 0;
const body = response.body.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
bytes += chunk.byteLength;
if (bytes > MAX_RESPONSE_BYTES) {
controller.error(new Error('Parallel MCP response exceeded the size limit'));
return;
}
controller.enqueue(chunk);
},
}),
);
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
2 changes: 1 addition & 1 deletion packages/core/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const FindCommandSchema = z.object({
export const SearchCommandSchema = z.object({
action: z.literal('search'),
query: z.string().describe('Search query'),
engine: z.enum(['google', 'duckduckgo', 'bing']).optional().default('google'),
engine: z.enum(['google', 'duckduckgo', 'bing', 'parallel']).optional().default('google'),
});

export const ListOptionsCommandSchema = z.object({
Expand Down