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
20 changes: 5 additions & 15 deletions api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createMcpHandler } from "mcp-handler";
import type { Implementation } from "@modelcontextprotocol/sdk/types.js";
import { initializeMcpServer, type McpConfig } from "../src/mcp-handler.js";
import { DEFAULT_MCP_MAX_DURATION_SECONDS, parsePositiveInteger } from "../src/tools/agentRun.js";
import { parseSearchType, type SearchType } from "../src/tools/searchType.js";
import type { Ratelimit } from "@upstash/ratelimit";
import type { Redis } from "@upstash/redis";
import { isJwtToken, verifyOAuthToken } from "../src/utils/auth.js";
Expand Down Expand Up @@ -417,7 +418,7 @@ interface RequestConfig {
exaSource?: string;
mcpSessionId?: string;
mcpClient?: McpClientMetadata;
defaultSearchType?: "auto" | "fast" | "instant";
defaultSearchType?: SearchType;
oauthAccessToken?: string;
/** True when a Bearer token was a JWT but failed OAuth verification (expired, bad sig, wrong issuer/audience). */
invalidOAuthJwt: boolean;
Expand All @@ -435,7 +436,7 @@ async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
let debug = process.env.DEBUG === "true";
let userProvidedApiKey = false;
let authMethod: "oauth" | "api_key" | "free_tier" = "free_tier";
let defaultSearchType: "auto" | "fast" | "instant" | undefined;
let defaultSearchType: SearchType | undefined;
let oauthAccessToken: string | undefined;
let invalidOAuthJwt = false;
let agentCallWindowMs: number | undefined;
Expand Down Expand Up @@ -511,13 +512,7 @@ async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
// Support ?agentCallWindowMs
agentCallWindowMs = parsePositiveInteger(params.get("agentCallWindowMs") ?? undefined);

// Support ?defaultSearchType
if (params.has("defaultSearchType")) {
const dst = params.get("defaultSearchType");
if (dst === "auto" || dst === "fast" || dst === "instant") {
defaultSearchType = dst;
}
}
defaultSearchType = parseSearchType(params.get("defaultSearchType"));
} catch (error) {
// URL parsing failed, will use env vars
if (debug) {
Expand All @@ -534,12 +529,7 @@ async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
);
}

if (!defaultSearchType && process.env.DEFAULT_SEARCH_TYPE) {
const dst = process.env.DEFAULT_SEARCH_TYPE;
if (dst === "auto" || dst === "fast" || dst === "instant") {
defaultSearchType = dst;
}
}
defaultSearchType ??= parseSearchType(process.env.DEFAULT_SEARCH_TYPE);

const exaSource = request.headers.get("x-exa-source") || undefined;
const mcpSessionId = request.headers.get("MCP-Session-Id") || undefined;
Expand Down
3 changes: 2 additions & 1 deletion src/mcp-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";

// Import tool implementations
import { registerWebSearchTool } from "./tools/webSearch.js";
import type { SearchType } from "./tools/searchType.js";
import { registerCompanyResearchTool } from "./tools/companyResearch.js";
import { registerWebFetchTool } from "./tools/webFetch.js";
import { registerPeopleSearchTool } from "./tools/peopleSearch.js";
Expand Down Expand Up @@ -32,7 +33,7 @@ export interface McpConfig {
exaSource?: string;
mcpSessionId?: string;
mcpClient?: unknown;
defaultSearchType?: "auto" | "fast" | "instant";
defaultSearchType?: SearchType;
oauthAccessToken?: string;
agentCallWindowMs?: number;
mcpMaxDurationSeconds?: number;
Expand Down
6 changes: 1 addition & 5 deletions src/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { initializeMcpServer, type McpConfig } from "./mcp-handler.js";
import { expandToolSelection } from "./toolRegistry.js";
import { log } from "./utils/logger.js";
import { parsePositiveInteger } from "./tools/agentRun.js";
import { parseSearchType } from "./tools/searchType.js";

// Reads EXA_API_KEY, ENABLED_TOOLS / TOOLS, DEBUG, DEFAULT_SEARCH_TYPE from env.
// HTTP/Vercel entry point lives in api/mcp.ts; CLI bootstrap lives in src/stdio-cli.ts.
Expand All @@ -21,11 +22,6 @@ function parseTools(value: string | undefined): string[] | undefined {
return tools.length > 0 ? tools : undefined;
}

function parseSearchType(value: string | undefined): "auto" | "fast" | "instant" | undefined {
if (value === "auto" || value === "fast" || value === "instant") return value;
return undefined;
}

export function buildConfigFromEnv(env: NodeJS.ProcessEnv = process.env): McpConfig {
const exaApiKey = env.EXA_API_KEY;
return {
Expand Down
7 changes: 7 additions & 0 deletions src/tools/searchType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const SEARCH_TYPES = ["auto", "lite", "fast", "instant"] as const;

export type SearchType = (typeof SEARCH_TYPES)[number];

export function parseSearchType(value: string | null | undefined): SearchType | undefined {
return SEARCH_TYPES.find((type) => type === value);
}
63 changes: 53 additions & 10 deletions tests/unit/api/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,24 +584,67 @@ describe("api/mcp handler", () => {
expect(initializeMcpServerMock).toHaveBeenCalled();
});

it("accepts instant as a defaultSearchType query parameter", async () => {
it.each(["auto", "lite", "fast", "instant"])(
"accepts %s as a defaultSearchType query parameter",
async (type) => {
const { config } = await callHandleRequest(
new Request(`https://mcp.exa.ai/mcp?defaultSearchType=${type}`),
);

expect(config).toMatchObject({ defaultSearchType: type });
},
);

it.each(["", "FAST", "neural", "deep"])(
"ignores the defaultSearchType query parameter %o, which names no search type",
async (type) => {
const { config } = await callHandleRequest(
new Request(`https://mcp.exa.ai/mcp?defaultSearchType=${type}`),
);

expect(config).toBeDefined();
expect(config!.defaultSearchType).toBeUndefined();
},
);

it.each(["auto", "lite", "fast", "instant"])(
"falls back to DEFAULT_SEARCH_TYPE=%s from the environment",
async (type) => {
process.env.DEFAULT_SEARCH_TYPE = type;

const { config } = await callHandleRequest(new Request("https://mcp.exa.ai/mcp"));

expect(config).toMatchObject({ defaultSearchType: type });
},
);

it("prefers ?defaultSearchType over DEFAULT_SEARCH_TYPE", async () => {
process.env.DEFAULT_SEARCH_TYPE = "fast";

const { config } = await callHandleRequest(
new Request("https://mcp.exa.ai/mcp?defaultSearchType=instant"),
new Request("https://mcp.exa.ai/mcp?defaultSearchType=lite"),
);

expect(config).toMatchObject({
defaultSearchType: "instant",
});
expect(config).toMatchObject({ defaultSearchType: "lite" });
});

it("falls back to DEFAULT_SEARCH_TYPE from the environment", async () => {
process.env.DEFAULT_SEARCH_TYPE = "instant";
it("falls back to DEFAULT_SEARCH_TYPE when ?defaultSearchType is invalid", async () => {
process.env.DEFAULT_SEARCH_TYPE = "lite";

const { config } = await callHandleRequest(
new Request("https://mcp.exa.ai/mcp?defaultSearchType=deep"),
);

expect(config).toMatchObject({ defaultSearchType: "lite" });
});

it("ignores a DEFAULT_SEARCH_TYPE that names no search type", async () => {
process.env.DEFAULT_SEARCH_TYPE = "deep";

const { config } = await callHandleRequest(new Request("https://mcp.exa.ai/mcp"));

expect(config).toMatchObject({
defaultSearchType: "instant",
});
expect(config).toBeDefined();
expect(config!.defaultSearchType).toBeUndefined();
});

it("uses agentCallWindowMs from the query parameter before the environment", async () => {
Expand Down
26 changes: 19 additions & 7 deletions tests/unit/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,27 @@ describe("Stdio entrypoint", () => {
expect(config.mcpSessionId).toBe("session-1");
});

it("buildConfigFromEnv accepts instant as a default search type", async () => {
const { buildConfigFromEnv } = await import("../../src/stdio.js");
it.each(["auto", "lite", "fast", "instant"])(
"buildConfigFromEnv accepts %s as a default search type",
async (type) => {
const { buildConfigFromEnv } = await import("../../src/stdio.js");

const config = buildConfigFromEnv({
DEFAULT_SEARCH_TYPE: "instant",
});
const config = buildConfigFromEnv({ DEFAULT_SEARCH_TYPE: type });

expect(config.defaultSearchType).toBe("instant");
});
expect(config.defaultSearchType).toBe(type);
},
);

it.each(["", "FAST", "neural", "deep"])(
"buildConfigFromEnv ignores the default search type %o, which names no search type",
async (type) => {
const { buildConfigFromEnv } = await import("../../src/stdio.js");

const config = buildConfigFromEnv({ DEFAULT_SEARCH_TYPE: type });

expect(config.defaultSearchType).toBeUndefined();
},
);

it("main() wires the McpServer through stdio transport with env-derived config", async () => {
const { main } = await import("../../src/stdio.js");
Expand Down
Loading