From 434e56a056922c268ff4aabb4098db8951310156 Mon Sep 17 00:00:00 2001 From: "exa-oss-publish[bot]" <332281605+exa-oss-publish[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2026 16:45:25 -0700 Subject: [PATCH] chore: update advanced search text defaults - Update `web_search_advanced_exa` to return full page text by default, omit it when highlights or summaries are requested, and include capped text when `textMaxCharacters` is set. - Share the `SearchType` definition across search tools and document exact, case-sensitive parsing of supported `auto`, `lite`, `fast`, and `instant` values. - Remove the deprecation notice from the `get_code_context_exa` tool description. - Clarify `agent_run` input descriptions for `systemPrompt`, `connectors`, and `limits` without changing their optionality. - Adjust the local `EXA_API_BASE_URL` binding used when constructing the Exa client. - Test search-type parsing, `web_search_exa` configured defaults, and `web_search_advanced_exa` schema validation and request forwarding in a new 301-line unit test file. GitOrigin-RevId: 3f2aec8d789ae637e4bb0ae5e3a3b3c69dbcd36a Co-authored-by: Kesku --- src/tools/agentRun.ts | 6 +- src/tools/config.ts | 2 +- src/tools/exaCode.ts | 2 +- src/tools/searchType.ts | 12 ++ src/tools/webSearch.ts | 3 +- src/tools/webSearchAdvanced.ts | 34 +++- tests/unit/tools/searchType.test.ts | 301 ++++++++++++++++++++++++++++ 7 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 tests/unit/tools/searchType.test.ts diff --git a/src/tools/agentRun.ts b/src/tools/agentRun.ts index c0044aa..ef53c6e 100644 --- a/src/tools/agentRun.ts +++ b/src/tools/agentRun.ts @@ -63,7 +63,7 @@ export const agentRunInputShape = { .describe( "ID returned by an earlier call. Use it to check or continue waiting for the same run. Do not start a duplicate run.", ), - systemPrompt: z.string().optional().describe("Optional system-level guidance for the Agent."), + systemPrompt: z.string().optional().describe("System-level guidance for the Agent."), outputSchema: recordSchema() .optional() .describe( @@ -85,7 +85,7 @@ export const agentRunInputShape = { .array(z.object({ provider: dataSourceProviderSchema })) .max(5) .optional() - .describe("Optional Exa Connect providers to enable for this run."), + .describe("Exa Connect providers to enable for this run."), previousRunId: agentRunIdSchema() .optional() .describe("ID of a completed prior run to use as context for a new run."), @@ -108,7 +108,7 @@ export const agentRunInputShape = { }) .optional() .describe( - "Optional per-run spend and duration limits for the metered auto and ultra efforts. Set it when the user names a cost or time limit.", + "Per-run spend and duration limits for the metered auto and ultra efforts. Set it when the user names a cost or time limit.", ), }; diff --git a/src/tools/config.ts b/src/tools/config.ts index 5e5f015..bce7293 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -49,7 +49,7 @@ export function createExaClient(config?: Record, tool?: string) * double); unset, exa-js keeps its default of https://api.exa.ai. */ function newExaClient(apiKey: string): Exa { - const baseUrl = process.env.EXA_API_BASE_URL; + let baseUrl = process.env.EXA_API_BASE_URL; return baseUrl ? new Exa(apiKey, baseUrl) : new Exa(apiKey); } diff --git a/src/tools/exaCode.ts b/src/tools/exaCode.ts index aa09fd9..f0591da 100644 --- a/src/tools/exaCode.ts +++ b/src/tools/exaCode.ts @@ -15,7 +15,7 @@ export function registerExaCodeTool( ): void { server.tool( "get_code_context_exa", - `[Deprecated: Use web_search_exa instead] Find code examples, documentation, and programming solutions. + `Find code examples, documentation, and programming solutions. Best for: Any programming question - API usage, library examples, code snippets, debugging help. Returns: Relevant code and documentation. diff --git a/src/tools/searchType.ts b/src/tools/searchType.ts index d8de6c3..2711249 100644 --- a/src/tools/searchType.ts +++ b/src/tools/searchType.ts @@ -1,7 +1,19 @@ +/** + * The search types the search tools send to the Exa API, shared by every + * place that accepts one: the `type` parameter of `web_search_advanced_exa` + * and a server's configured default for `web_search_exa` + * (`?defaultSearchType=` on HTTP, `DEFAULT_SEARCH_TYPE` on stdio). + * + * `fast` and `instant` are legacy but stay accepted and are forwarded as + * given, never rewritten to `lite`: `lite` serves fewer parameters and only + * cached content, so swapping it in would change what a caller gets. + */ + export const SEARCH_TYPES = ["auto", "lite", "fast", "instant"] as const; export type SearchType = (typeof SEARCH_TYPES)[number]; +/** `value` as a search type, or undefined when it names none (exact, case-sensitive match). */ export function parseSearchType(value: string | null | undefined): SearchType | undefined { return SEARCH_TYPES.find((type) => type === value); } diff --git a/src/tools/webSearch.ts b/src/tools/webSearch.ts index 098502d..32ef444 100644 --- a/src/tools/webSearch.ts +++ b/src/tools/webSearch.ts @@ -6,12 +6,13 @@ import { retryWithBackoff, formatToolError, withTimeout } from "../utils/errorHa import { sanitizeSearchNotices, sanitizeSearchResponse } from "../utils/exaResponseSanitizer.js"; import { lenientString, lenientOptionalNumber } from "./validation.js"; import { logSearchWarnings, withSearchNotices } from "./searchNotes.js"; +import type { SearchType } from "./searchType.js"; import { checkpoint } from "agnost"; type WebSearchConfig = { exaApiKey?: string; userProvidedApiKey?: boolean; - defaultSearchType?: "auto" | "lite" | "fast" | "instant"; + defaultSearchType?: SearchType; exaSource?: string; mcpSessionId?: string; }; diff --git a/src/tools/webSearchAdvanced.ts b/src/tools/webSearchAdvanced.ts index 1f96d25..3aaaefb 100644 --- a/src/tools/webSearchAdvanced.ts +++ b/src/tools/webSearchAdvanced.ts @@ -16,6 +16,7 @@ import { withNoticeMessages, withStructuredSearchNotices, } from "./searchNotes.js"; +import { SEARCH_TYPES } from "./searchType.js"; import { checkpoint } from "agnost"; /** @@ -79,6 +80,30 @@ export function liteUnsupportedParameters(request: ExaAdvancedSearchRequest): st return [...new Set(fields.map((field) => LITE_REJECTED_FIELD_PARAMETERS[field] ?? field))]; } +/** The parameters that decide which page text a search asks for. */ +type TextParameters = { + textMaxCharacters?: number; + enableHighlights?: boolean; + enableSummary?: boolean; +}; + +/** + * The `contents.text` option for a search: capped at `textMaxCharacters` when + * set, omitted when highlights or a summary stand in for the page, and the + * full page otherwise. + */ +export function requestedText( + params: TextParameters, +): ExaAdvancedSearchRequest["contents"]["text"] { + if (params.textMaxCharacters) { + return { maxCharacters: params.textMaxCharacters }; + } + if (params.enableHighlights || params.enableSummary) { + return undefined; + } + return true; +} + /** The error result for a `lite` search that sets parameters `lite` does not serve. */ function liteRefusal(parameters: readonly string[]) { const pronoun = parameters.length === 1 ? "it" : "them"; @@ -103,12 +128,12 @@ export function registerWebSearchAdvancedTool( 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 with page text, highlights, summaries or subpage content. Page text is the full page by default. When highlights or summaries are enabled, page text is omitted unless textMaxCharacters is set.`, { query: lenientString().describe("Search query - can be a question, statement, or keywords"), numResults: lenientOptionalNumber().describe("Number of results (1-100, default: 10)"), type: z - .enum(["auto", "lite", "fast", "instant"]) + .enum(SEARCH_TYPES) .optional() .describe( "Search type. 'auto' is the default and recommended type. It returns high-quality results and supports every parameter. 'lite' is the low-cost, low-latency type and serves cached content. It supports only query, numResults, category, includeDomains, excludeDomains, startPublishedDate, endPublishedDate, userLocation, moderation, textMaxCharacters and the highlights parameters. 'instant' and 'fast' are legacy types. Use 'lite' instead of 'instant'. Use 'lite' instead of 'fast', or 'auto' when the search needs a parameter 'lite' does not support.", @@ -173,7 +198,7 @@ Returns: Search results with optional highlights, summaries, and subpage content .describe("Additional query variations to expand search coverage"), textMaxCharacters: lenientOptionalPositiveNumber().describe( - "Max characters for text extraction per result", + "Max characters of page text per result. Set it to get page text alongside highlights or summaries.", ), contextMaxCharacters: lenientOptionalPositiveNumber().describe( "Max characters for context string (not included by default)", @@ -232,8 +257,9 @@ Returns: Search results with optional highlights, summaries, and subpage content ? {} : { livecrawl: "fallback" as const }; + const text = requestedText(params); const contents: ExaAdvancedSearchRequest["contents"] = { - text: params.textMaxCharacters ? { maxCharacters: params.textMaxCharacters } : true, + ...(text !== undefined && { text }), ...freshness, ...(params.livecrawlTimeout && { livecrawlTimeout: params.livecrawlTimeout }), }; diff --git a/tests/unit/tools/searchType.test.ts b/tests/unit/tools/searchType.test.ts new file mode 100644 index 0000000..ff4afaa --- /dev/null +++ b/tests/unit/tools/searchType.test.ts @@ -0,0 +1,301 @@ +/** + * Search type handling of the search tools: which types are accepted, the + * defaults, how a type is forwarded to the Exa API, and the parameters the + * `lite` type refuses. Synced to the public exa-mcp-server repo and run there + * too, so it asserts nothing hosted-only and holds both servers to one + * behaviour. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +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(), +})); + +const SEARCH_TYPES = ["auto", "lite", "fast", "instant"] as const; + +/** The body of the one /search request the tool sent. */ +function sentRequest(): Record { + expect(requestMock).toHaveBeenCalledTimes(1); + const [path, method, body] = requestMock.mock.calls[0]; + expect([path, method]).toEqual(["/search", "POST"]); + return body; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +describe("parseSearchType", () => { + it.each(SEARCH_TYPES)("accepts %s", async (type) => { + const { parseSearchType } = await import("../../../src/tools/searchType.js"); + + expect(parseSearchType(type)).toBe(type); + }); + + it.each([null, undefined, "", "FAST", " lite", "neural", "keyword", "deep", "deep-lite"])( + "names no search type for %o", + async (value) => { + const { parseSearchType } = await import("../../../src/tools/searchType.js"); + + expect(parseSearchType(value)).toBeUndefined(); + }, + ); + + it("lists the legacy types after the recommended ones", async () => { + const { SEARCH_TYPES: exported } = await import("../../../src/tools/searchType.js"); + + expect(exported).toEqual(SEARCH_TYPES); + }); +}); + +describe("web_search_exa search type", () => { + async function registerWebSearch(defaultSearchType?: (typeof SEARCH_TYPES)[number]) { + const { registerWebSearchTool } = await import("../../../src/tools/webSearch.js"); + const server = new FakeMcpServer(); + registerWebSearchTool(server as any, { exaApiKey: "test-key", defaultSearchType }); + return server.getTool("web_search_exa"); + } + + it("searches with auto when no default search type is configured", async () => { + const tool = await registerWebSearch(); + requestMock.mockResolvedValue(searchResponse); + + await tool.handler({ query: "AI breakthroughs" }); + + expect(sentRequest().type).toBe("auto"); + }); + + it.each(SEARCH_TYPES)("sends the configured %s default unchanged", async (type) => { + const tool = await registerWebSearch(type); + requestMock.mockResolvedValue(searchResponse); + + await tool.handler({ query: "category:company Exa", numResults: 3 }); + + // Every field here is one lite serves, so any configured type is valid. + expect(sentRequest()).toEqual({ + query: "Exa", + type, + numResults: 3, + category: "company", + contents: { highlights: true }, + }); + }); + + it("does not let the caller choose the search type", async () => { + const tool = await registerWebSearch("lite"); + + expect(Object.keys(tool.inputSchema as object)).not.toContain("type"); + }); +}); + +describe("web_search_advanced_exa search type", () => { + async function registerAdvancedTool() { + const { registerWebSearchAdvancedTool } = + await import("../../../src/tools/webSearchAdvanced.js"); + const server = new FakeMcpServer(); + registerWebSearchAdvancedTool(server as any, { exaApiKey: "test-key" }); + return server.getTool("web_search_advanced_exa"); + } + + function inputSchema(tool: { inputSchema?: unknown }) { + return z.object(tool.inputSchema as Record); + } + + it("advertises auto and lite and describes fast and instant as legacy", async () => { + const tool = await registerAdvancedTool(); + + const jsonSchema = zodToJsonSchema(inputSchema(tool), { strictUnions: true }) as { + properties: Record; + required?: string[]; + }; + expect(jsonSchema.properties.type.enum).toEqual([...SEARCH_TYPES]); + expect(jsonSchema.required ?? []).not.toContain("type"); + expect(jsonSchema.properties.type.description).toContain( + "'instant' and 'fast' are legacy types. Use 'lite' instead of 'instant'. Use 'lite' instead of 'fast', or 'auto' when the search needs a parameter 'lite' does not support.", + ); + }); + + it.each(["deep", "deep-lite", "neural", "FAST", ""])( + "rejects the %o type at schema validation", + async (type) => { + const tool = await registerAdvancedTool(); + + expect(inputSchema(tool).safeParse({ query: "q", type }).success).toBe(false); + }, + ); + + it("searches with auto when no type is given", async () => { + const tool = await registerAdvancedTool(); + requestMock.mockResolvedValue(searchResponse); + + await tool.handler({ query: "AI breakthroughs" }); + + expect(sentRequest()).toMatchObject({ + type: "auto", + contents: { text: true, livecrawl: "fallback" }, + }); + }); + + it("searches with auto when the server has a default search type configured", async () => { + const { registerWebSearchAdvancedTool } = + await import("../../../src/tools/webSearchAdvanced.js"); + const server = new FakeMcpServer(); + registerWebSearchAdvancedTool( + server as any, + { + exaApiKey: "test-key", + defaultSearchType: "fast", + } as any, + ); + requestMock.mockResolvedValue(searchResponse); + + await server.getTool("web_search_advanced_exa").handler({ query: "AI breakthroughs" }); + + expect(sentRequest().type).toBe("auto"); + }); + + it.each(["fast", "instant"])( + "still accepts the legacy %s type and sends it unchanged", + async (type) => { + const tool = await registerAdvancedTool(); + requestMock.mockResolvedValue(searchResponse); + + expect(inputSchema(tool).parse({ query: "q", type })).toMatchObject({ type }); + + await tool.handler({ query: "AI breakthroughs", type }); + + expect(sentRequest()).toMatchObject({ + type, + contents: { text: true, livecrawl: "fallback" }, + }); + }, + ); + + it.each(["auto", "fast", "instant"])( + "sends the parameters lite does not serve when the type is %s", + async (type) => { + const tool = await registerAdvancedTool(); + requestMock.mockResolvedValue(searchResponse); + + await tool.handler({ + query: "AI breakthroughs", + type, + enableSummary: true, + includeText: ["rust"], + }); + + expect(sentRequest()).toMatchObject({ + type, + includeText: ["rust"], + contents: { summary: true }, + }); + }, + ); + + it("sends a lite search with every parameter lite serves and no livecrawl default", async () => { + const tool = await registerAdvancedTool(); + requestMock.mockResolvedValue(searchResponse); + + const result = await tool.handler({ + query: "AI breakthroughs", + type: "lite", + numResults: 5, + category: "news", + includeDomains: ["example.com"], + excludeDomains: ["spam.example"], + startPublishedDate: "2026-01-01", + endPublishedDate: "2026-06-01", + userLocation: "US", + moderation: true, + textMaxCharacters: 500, + enableHighlights: true, + highlightsMaxCharacters: 1000, + highlightsQuery: "model releases", + }); + + expect((result as any).isError).toBeUndefined(); + expect(sentRequest()).toEqual({ + query: "AI breakthroughs", + type: "lite", + numResults: 5, + category: "news", + includeDomains: ["example.com"], + excludeDomains: ["spam.example"], + startPublishedDate: "2026-01-01", + endPublishedDate: "2026-06-01", + userLocation: "US", + moderation: true, + contents: { + text: { maxCharacters: 500 }, + highlights: { maxCharacters: 1000, query: "model releases" }, + }, + }); + }); + + it.each([ + [{ enableSummary: true }, "enableSummary"], + [{ enableSummary: true, summaryQuery: "key findings" }, "enableSummary"], + [{ contextMaxCharacters: 2000 }, "contextMaxCharacters"], + [{ maxAgeHours: 0 }, "maxAgeHours"], + [{ maxAgeHours: -1 }, "maxAgeHours"], + [{ livecrawlTimeout: 5000 }, "livecrawlTimeout"], + [{ subpages: 2 }, "subpages"], + [{ subpageTarget: ["docs"] }, "subpageTarget"], + [{ startCrawlDate: "2026-01-01" }, "startCrawlDate"], + [{ endCrawlDate: "2026-06-01" }, "endCrawlDate"], + [{ includeText: ["rust"] }, "includeText"], + [{ excludeText: ["rust"] }, "excludeText"], + [{ additionalQueries: ["rust release notes"] }, "additionalQueries"], + ])("refuses a lite search that sets %o without calling the API", async (params, parameter) => { + const tool = await registerAdvancedTool(); + + const result = await tool.handler({ query: "AI breakthroughs", type: "lite", ...params }); + + expect(requestMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + content: [ + { + type: "text", + text: `web_search_advanced_exa error: search type 'lite' does not support ${parameter}. Remove it, or set type to 'auto'.`, + }, + ], + isError: true, + }); + }); + + it("names every parameter lite does not serve, top-level ones first", async () => { + const tool = await registerAdvancedTool(); + + const result = await tool.handler({ + query: "AI breakthroughs", + type: "lite", + enableSummary: true, + maxAgeHours: 1, + includeText: ["rust"], + }); + + expect(requestMock).not.toHaveBeenCalled(); + expect((result as any).content[0].text).toBe( + "web_search_advanced_exa error: search type 'lite' does not support includeText, maxAgeHours, enableSummary. Remove them, or set type to 'auto'.", + ); + }); +});