Skip to content
Merged
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
83 changes: 49 additions & 34 deletions src/tools/web-search.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,34 @@
/**
* Web search tool using DuckDuckGo HTML interface.
* Web search tool using Exa AI neural search via JSON-RPC/SSE.
*/

import { parseHTML } from "linkedom";
import type { Tool } from "./index";

const EXA_SEARCH_URL = "https://mcp.exa.ai/mcp";
const MAX_RESULTS = 10;
const DEFAULT_RESULTS = 5;
const CONTEXT_MAX_CHARS = 10_000;

interface ExaResponse {
jsonrpc: "2.0";
id: number;
result?: { content?: { type: string; text: string }[] };
error?: { code: number; message: string };
}

function parseSSEResponse(text: string): string {
for (const line of text.split("\n")) {
if (line.startsWith("data: ")) {
return line.substring(6);
}
}
throw new Error("No data field found in SSE response");
}

export const webSearchTool: Tool = {
name: "web_search",
description:
"Search the web using DuckDuckGo. Returns a list of results with titles, URLs, and snippets.",
"Search the web using Exa AI. Returns a list of results with titles, URLs, and snippets.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -31,51 +48,49 @@ export const webSearchTool: Tool = {
const count = Math.min(Math.max((input.count as number) || DEFAULT_RESULTS, 1), MAX_RESULTS);

try {
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
const response = await fetch(url, {
const response = await fetch(EXA_SEARCH_URL, {
method: "POST",
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-GB,en;q=0.5",
"Cache-Control": "no-cache",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
},
redirect: "follow",
body: JSON.stringify({
jsonrpc: "2.0",
id: Date.now(),
method: "tools/call",
params: {
name: "web_search_exa",
arguments: {
query,
numResults: count,
type: "auto",
contextMaxCharacters: CONTEXT_MAX_CHARS,
},
},
}),
signal: AbortSignal.timeout(15_000),
});

if (!response.ok) {
return `Search failed: HTTP ${response.status} ${response.statusText}`;
}

const html = await response.text();
const { document } = parseHTML(html);
const text = await response.text();
const jsonData = parseSSEResponse(text);
const parsed: ExaResponse = JSON.parse(jsonData);

const resultElements = document.querySelectorAll(".result__a");
if (resultElements.length === 0) {
return `No results found for "${query}"`;
if (parsed.error) {
return `Search failed: ${parsed.error.message}`;
}

const results: string[] = [];
for (let i = 0; i < Math.min(resultElements.length, count); i++) {
const linkEl = resultElements[i] as unknown as HTMLAnchorElement;
const title = linkEl.textContent?.trim() ?? "";
const href = linkEl.getAttribute("href") ?? "";

const resultItem = linkEl.closest(".result");
const snippetEl = resultItem?.querySelector(".result__snippet");
const snippet = snippetEl?.textContent?.trim() ?? "";

results.push(`${i + 1}. ${title}\n ${href}\n ${snippet}`);
if (!parsed.result?.content?.length) {
return `No results found for "${query}"`;
}

return results.join("\n\n");
return parsed.result.content
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n\n");
} catch (err) {
return `Search error: ${err instanceof Error ? err.message : String(err)}`;
}
Expand Down
107 changes: 55 additions & 52 deletions tests/web-search-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ afterEach(() => {
globalThis.fetch = originalFetch;
});

function makeExaSSE(content: { type: string; text: string }[]): string {
return `event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
result: { content },
})}\n\n`;
}

function makeExaErrorSSE(code: number, message: string): string {
return `event: message\ndata: ${JSON.stringify({
jsonrpc: "2.0",
id: 1,
error: { code, message },
})}\n\n`;
}

function stubFetch(body: string, init?: { status?: number; statusText?: string }) {
globalThis.fetch = async () =>
new Response(body, {
Expand All @@ -19,30 +35,17 @@ function stubFetch(body: string, init?: { status?: number; statusText?: string }
});
}

const DDG_HTML = `<html><body>
<div class="result">
<a class="result__a" href="https://example.com/one">First Result</a>
<a class="result__snippet">Description of first result</a>
</div>
<div class="result">
<a class="result__a" href="https://example.com/two">Second Result</a>
<a class="result__snippet">Description of second result</a>
</div>
<div class="result">
<a class="result__a" href="https://example.com/three">Third Result</a>
<a class="result__snippet">Description of third result</a>
</div>
</body></html>`;
const EXA_RESULTS = makeExaSSE([
{ type: "text", text: "First result about testing" },
{ type: "text", text: "Second result about testing" },
]);

describe("web search tool", () => {
test("returns formatted results from valid DDG HTML", async () => {
stubFetch(DDG_HTML);
test("returns formatted results from Exa response", async () => {
stubFetch(EXA_RESULTS);
const result = await webSearchTool.execute({ query: "test query" });
expect(result).toContain("1. First Result");
expect(result).toContain("https://example.com/one");
expect(result).toContain("Description of first result");
expect(result).toContain("2. Second Result");
expect(result).toContain("3. Third Result");
expect(result).toContain("First result about testing");
expect(result).toContain("Second result about testing");
});

test("returns error on fetch failure", async () => {
Expand All @@ -59,55 +62,55 @@ describe("web search tool", () => {
expect(result).toBe("Search failed: HTTP 503 Service Unavailable");
});

test("respects count parameter", async () => {
stubFetch(DDG_HTML);
const result = await webSearchTool.execute({ query: "test", count: 2 });
expect(result).toContain("1. First Result");
expect(result).toContain("2. Second Result");
expect(result).not.toContain("3. Third Result");
});

test("clamps count to max 10", async () => {
stubFetch(DDG_HTML);
const result = await webSearchTool.execute({ query: "test", count: 50 });
// Only 3 results available in HTML, so all 3 should show
expect(result).toContain("1. First Result");
expect(result).toContain("2. Second Result");
expect(result).toContain("3. Third Result");
test("returns error on Exa API error", async () => {
stubFetch(makeExaErrorSSE(-32600, "Invalid request"));
const result = await webSearchTool.execute({ query: "test" });
expect(result).toBe("Search failed: Invalid request");
});

test("handles empty results", async () => {
stubFetch("<html><body><div>No results</div></body></html>");
stubFetch(makeExaSSE([]));
const result = await webSearchTool.execute({ query: "xyzzy nonsense" });
expect(result).toContain('No results found for "xyzzy nonsense"');
});

test("encodes query in URL", async () => {
let capturedUrl = "";
globalThis.fetch = async (url: string | URL | Request) => {
capturedUrl = typeof url === "string" ? url : url.toString();
return new Response(DDG_HTML);
test("handles SSE parsing error", async () => {
stubFetch("not a valid SSE response");
const result = await webSearchTool.execute({ query: "test" });
expect(result).toContain("Search error:");
});

test("sends correct JSON-RPC body with query and count", async () => {
let capturedBody = "";
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
capturedBody = typeof init?.body === "string" ? init.body : "";
return new Response(EXA_RESULTS);
};
await webSearchTool.execute({ query: "hello world" });
expect(capturedUrl).toContain("q=hello%20world");
await webSearchTool.execute({ query: "hello world", count: 3 });
const parsed = JSON.parse(capturedBody);
expect(parsed.method).toBe("tools/call");
expect(parsed.params.name).toBe("web_search_exa");
expect(parsed.params.arguments.query).toBe("hello world");
expect(parsed.params.arguments.numResults).toBe(3);
expect(parsed.params.arguments.contextMaxCharacters).toBe(10_000);
});

test("sends browser-like headers", async () => {
let capturedHeaders: HeadersInit | undefined;
test("clamps count to max 10", async () => {
let capturedBody = "";
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
capturedHeaders = init?.headers;
return new Response(DDG_HTML);
capturedBody = typeof init?.body === "string" ? init.body : "";
return new Response(EXA_RESULTS);
};
await webSearchTool.execute({ query: "test" });
const headers = capturedHeaders as Record<string, string>;
expect(headers["User-Agent"]).toContain("Mozilla/5.0");
await webSearchTool.execute({ query: "test", count: 50 });
const parsed = JSON.parse(capturedBody);
expect(parsed.params.arguments.numResults).toBe(10);
});

test("passes AbortSignal for timeout", async () => {
let capturedSignal: AbortSignal | undefined;
globalThis.fetch = async (_url: string | URL | Request, init?: RequestInit) => {
capturedSignal = init?.signal ?? undefined;
return new Response(DDG_HTML);
return new Response(EXA_RESULTS);
};
await webSearchTool.execute({ query: "test" });
expect(capturedSignal).toBeDefined();
Expand Down
Loading