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
16 changes: 13 additions & 3 deletions src/tools/webSearchAdvanced.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export function registerWebSearchAdvancedTool(server: McpServer, config?: { exaA

Best for: When you need specific filters like date ranges, domain restrictions, or category filters.
Not recommended for: Simple searches - use web_search_exa instead.
Returns: Search results with optional highlights, summaries, and subpage content.`,
Returns: Search results. Defaults to highlights only (token-efficient, recommended for agents). Set enableText for full page text, enableSummary for AI summaries, or combine modes as needed.`,
{
query: lenientString().describe("Search query - can be a question, statement, or keywords"),
numResults: lenientOptionalNumber().describe("Number of results (1-100, default: 10)"),
Expand All @@ -41,7 +41,8 @@ Returns: Search results with optional highlights, summaries, and subpage content

additionalQueries: z.array(z.string()).optional().describe("Additional query variations to expand search coverage"),

textMaxCharacters: lenientOptionalPositiveNumber().describe("Max characters for text extraction per result"),
enableText: lenientOptionalBoolean().describe("Include full page text per result. Off by default — for most agent workflows, prefer enableHighlights (token-efficient). Use enableText for deep analysis when you need full content."),
textMaxCharacters: lenientOptionalPositiveNumber().describe("Max characters for text extraction per result. Requires enableText: true (without it, this value is ignored)."),
contextMaxCharacters: lenientOptionalPositiveNumber().describe("Max characters for context string (not included by default)"),

enableSummary: lenientOptionalBoolean().describe("Enable summary generation for results"),
Expand Down Expand Up @@ -75,11 +76,14 @@ Returns: Search results with optional highlights, summaries, and subpage content
const exa = new Exa(config?.exaApiKey || process.env.EXA_API_KEY || '');

const contents: ExaAdvancedSearchRequest['contents'] = {
text: params.textMaxCharacters ? { maxCharacters: params.textMaxCharacters } : true,
...(params.maxAgeHours !== undefined ? { maxAgeHours: params.maxAgeHours } : { livecrawl: 'fallback' as const }),
...(params.livecrawlTimeout && { livecrawlTimeout: params.livecrawlTimeout }),
};

if (params.enableText) {
contents.text = params.textMaxCharacters ? { maxCharacters: params.textMaxCharacters } : true;
}

if (params.contextMaxCharacters) {
contents.context = { maxCharacters: params.contextMaxCharacters };
}
Expand All @@ -97,6 +101,12 @@ Returns: Search results with optional highlights, summaries, and subpage content
};
}

// Default to highlights when no content mode was explicitly requested.
// Matches web_search_exa and Exa's own guidance for agent workflows.
if (!params.enableText && !params.enableSummary && !params.enableHighlights && !params.contextMaxCharacters) {
contents.highlights = true;
}

if (params.subpages) {
contents.subpages = params.subpages;
}
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface ExaAdvancedSearchRequest {
numSentences?: number;
highlightsPerUrl?: number;
query?: string;
};
} | boolean;
livecrawl?: 'never' | 'fallback' | 'always' | 'preferred';
maxAgeHours?: number;
livecrawlTimeout?: number;
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/tools/webSearchAdvanced.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { searchResponse } from "../../fixtures/exaResponses.js";
import { FakeMcpServer } from "../../helpers/fakeMcpServer.js";

const { ExaMock, requestMock } = vi.hoisted(() => {
const requestMock = vi.fn();
class ExaMock {
request = requestMock;
}
return { ExaMock, requestMock };
});

vi.mock("exa-js", async (importOriginal) => ({
...(await importOriginal<typeof import("exa-js")>()),
Exa: ExaMock,
}));

vi.mock("agnost", () => ({
checkpoint: vi.fn(),
}));

describe("registerWebSearchAdvancedTool", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(console, "error").mockImplementation(() => undefined);
});

it("defaults to highlights only and omits text when no content mode is requested", async () => {
const { registerWebSearchAdvancedTool } = await import(
"../../../src/tools/webSearchAdvanced.js"
);
const server = new FakeMcpServer();
requestMock.mockResolvedValue(searchResponse);

registerWebSearchAdvancedTool(server as any, { exaApiKey: "test-key" });

await server.getTool("web_search_advanced_exa").handler({
query: "AI breakthroughs",
});

expect(requestMock).toHaveBeenCalledTimes(1);
const sentBody = requestMock.mock.calls[0][2];
expect(sentBody.contents).toEqual({
livecrawl: "fallback",
highlights: true,
});
});

it("opts into text via enableText without falling back to default highlights", async () => {
const { registerWebSearchAdvancedTool } = await import(
"../../../src/tools/webSearchAdvanced.js"
);
const server = new FakeMcpServer();
requestMock.mockResolvedValue(searchResponse);

registerWebSearchAdvancedTool(server as any, { exaApiKey: "test-key" });

await server.getTool("web_search_advanced_exa").handler({
query: "AI breakthroughs",
enableText: true,
});

const sentBody = requestMock.mock.calls[0][2];
expect(sentBody.contents).toEqual({
livecrawl: "fallback",
text: true,
});
});
});