diff --git a/src/tools/webSearchAdvanced.ts b/src/tools/webSearchAdvanced.ts index 7fbe6744..f7f1b210 100644 --- a/src/tools/webSearchAdvanced.ts +++ b/src/tools/webSearchAdvanced.ts @@ -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)"), @@ -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"), @@ -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 }; } @@ -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; } diff --git a/src/types.ts b/src/types.ts index 19c042de..c2f6fa89 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,7 +61,7 @@ export interface ExaAdvancedSearchRequest { numSentences?: number; highlightsPerUrl?: number; query?: string; - }; + } | boolean; livecrawl?: 'never' | 'fallback' | 'always' | 'preferred'; maxAgeHours?: number; livecrawlTimeout?: number; diff --git a/tests/unit/tools/webSearchAdvanced.test.ts b/tests/unit/tools/webSearchAdvanced.test.ts new file mode 100644 index 00000000..f318e273 --- /dev/null +++ b/tests/unit/tools/webSearchAdvanced.test.ts @@ -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()), + 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, + }); + }); +});