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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,12 @@ Exa MCP works with most other clients, point them at `https://mcp.exa.ai/mcp`.
| --- | --- |
| `web_search_exa` | Search the web for any topic and get clean, ready-to-use content |
| `web_fetch_exa` | Read a webpage's full content as clean markdown from one or more URLs |
| `agent_run` | Run an [Exa Agent](https://docs.exa.ai/reference/agent-api-guide) for multi-step research, list-building, enrichment, and structured output (authenticated connections only) |

### Optional Tools (enable via the `tools` parameter)

| Tool | Description |
| --- | --- |
| `agent_run` | Run an [Exa Agent](https://docs.exa.ai/reference/agent-api-guide) for multi-step research, list-building, enrichment, and structured output |
| `web_search_advanced_exa` | Advanced search with filters, domains, dates, highlights, summaries, and subpage crawling |

Enable tools by appending them to the MCP URL (this will replace the defaults, so include all you want):
Expand All @@ -110,7 +110,7 @@ https://mcp.exa.ai/mcp?tools=web_search_advanced_exa
https://mcp.exa.ai/mcp?tools=web_search_exa,web_fetch_exa,agent_run
```

Exa Agent requires authentication (OAuth or an [API key](https://dashboard.exa.ai/api-keys)).
Exa Agent requires authentication (OAuth or an [API key](https://dashboard.exa.ai/api-keys)) and is on by default once you connect with either.

## Agent Skills

Expand Down
165 changes: 120 additions & 45 deletions api/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DEFAULT_MCP_MAX_DURATION_SECONDS, parsePositiveInteger } from "../src/t
import type { Ratelimit } from "@upstash/ratelimit";
import type { Redis } from "@upstash/redis";
import { isJwtToken, verifyOAuthToken } from "../src/utils/auth.js";
import { acceptsMcpResponses } from "../src/utils/mcpAccept.js";
import {
expandToolSelection,
requiresUserProvidedApiKey,
Expand Down Expand Up @@ -378,7 +379,10 @@ async function checkRateLimits(
* - DEBUG: Enable debug logging (true/false)
* - ENABLED_TOOLS: Comma-separated list of tools to enable
*
* Priority: x-api-key header > Authorization header > URL query parameter > environment variable.
* Priority: x-api-key header > plain Bearer API key > ?exaApiKey= > Bearer JWT > environment variable.
* An API key always outranks a JWT: MCP clients run OAuth discovery and attach a
* JWT even when the connector URL already carries the org's key. If upstream
* rejects that URL key, the JWT is verified and the tool call retried on it.
*
* ARCHITECTURE NOTE:
* The mcp-handler library creates a single server instance and doesn't pass
Expand All @@ -401,10 +405,14 @@ function getBearerToken(request: Request): string | undefined {
return undefined;
}

/**
* Extract configuration from request headers, URL, or environment variables.
* Priority: header > query parameter > environment variable.
*/
/** API key from `?exaApiKey=`, or undefined when absent, empty, or the URL is unparseable. */
function getApiKeyFromUrl(request: Request): string | undefined {
try {
return new URL(request.url).searchParams.get("exaApiKey") || undefined;
} catch {
return undefined;
}
}

interface RequestConfig {
exaApiKey?: string;
Expand All @@ -419,13 +427,56 @@ interface RequestConfig {
oauthAccessToken?: string;
/** True when a Bearer token was a JWT but failed OAuth verification (expired, bad sig, wrong issuer/audience). */
invalidOAuthJwt: boolean;
/**
* Unverified Bearer JWT that lost to a `?exaApiKey=` URL key. Verified only if
* upstream rejects that key, in which case the tool call is retried on it.
*/
fallbackJwt?: string;
apiKeyFallback?: () => Promise<boolean>;
agentCallWindowMs?: number;
mcpMaxDurationSeconds?: number;
}

/**
* Build the once-per-request fallback the tool-call wrapper invokes when
* upstream rejects the URL API key: verify the JWT the request also carried
* and, if it is good, switch `config` over to it so the retried call runs as
* the OAuth identity.
*/
function createApiKeyFallback(config: RequestConfig): (() => Promise<boolean>) | undefined {
const jwt = config.fallbackJwt;
if (!jwt) {
return undefined;
}
let attempted = false;
return async () => {
if (attempted) {
return false;
}
attempted = true;
const claims = await verifyOAuthToken(jwt);
if (!claims) {
console.error("[EXA-MCP] URL API key rejected upstream; fallback JWT invalid");
return false;
}
config.exaApiKey = undefined;
config.oauthAccessToken = jwt;
config.userProvidedApiKey = true;
config.authMethod = "oauth";
if (config.debug) {
console.log("[EXA-MCP] URL API key rejected upstream; retrying as oauth");
}
return true;
};
}

/**
* Extract configuration from request headers, URL, or environment variables.
* Priority: x-api-key header > OAuth JWT > plain Bearer API key > query parameter > environment variable.
* Priority: x-api-key header > plain Bearer API key > ?exaApiKey= > Bearer JWT (OAuth) >
* environment variable. A JWT is only consulted when no API key was supplied, so a key in
* the connector URL is never overridden by a token the client obtained through OAuth
* discovery. That JWT is kept as `fallbackJwt`, to be verified only if upstream rejects the
* URL key (see createApiKeyFallback).
*/
async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
let exaApiKey = process.env.EXA_API_KEY;
Expand All @@ -436,58 +487,54 @@ async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
let defaultSearchType: "auto" | "fast" | "instant" | undefined;
let oauthAccessToken: string | undefined;
let invalidOAuthJwt = false;
let fallbackJwt: string | undefined;
let agentCallWindowMs: number | undefined;

// 1. Check x-api-key header (highest priority)
const xApiKey = request.headers.get("x-api-key");
const bearerToken = xApiKey ? undefined : getBearerToken(request);
const apiKeyFromUrl = xApiKey ? undefined : getApiKeyFromUrl(request);
// A bearer JWT yields to a key in the URL; a plain bearer API key does not.
const bearerIsJwt = bearerToken !== undefined && isJwtToken(bearerToken);

if (xApiKey) {
// 1. x-api-key header (highest priority)
exaApiKey = xApiKey;
userProvidedApiKey = true;
authMethod = "api_key";
}

// 2. Check Authorization: Bearer header (fallback when no x-api-key)
if (!xApiKey) {
const bearerToken = getBearerToken(request);
if (bearerToken) {
// Distinguish JWT (OAuth) from plain API key
if (isJwtToken(bearerToken)) {
const claims = await verifyOAuthToken(bearerToken);
if (claims) {
oauthAccessToken = bearerToken;
exaApiKey = undefined;
userProvidedApiKey = true;
authMethod = "oauth";
} else {
// JWT verification failed — flag so the caller can return 401 with
// a WWW-Authenticate challenge instead of silently falling through to
// the env API key or free tier.
invalidOAuthJwt = true;
console.error("[EXA-MCP] Invalid OAuth JWT token");
}
} else {
// Plain API key in Bearer header
exaApiKey = bearerToken;
userProvidedApiKey = true;
authMethod = "api_key";
}
} else if (bearerToken && !bearerIsJwt) {
// 2. Plain API key in Authorization: Bearer header
exaApiKey = bearerToken;
userProvidedApiKey = true;
authMethod = "api_key";
} else if (apiKeyFromUrl) {
// 3. ?exaApiKey=YOUR_KEY (backwards compatible; the connector's configured key).
// A bearer JWT is not verified here: the key is the credential the connector
// owner configured. It is kept only as the fallback for a rejected key.
exaApiKey = apiKeyFromUrl;
userProvidedApiKey = true;
authMethod = "api_key";
fallbackJwt = bearerToken;
} else if (bearerToken) {
// 4. Bearer JWT from Exa OAuth.
const claims = await verifyOAuthToken(bearerToken);
if (claims) {
oauthAccessToken = bearerToken;
exaApiKey = undefined;
userProvidedApiKey = true;
authMethod = "oauth";
} else {
// JWT verification failed — flag so the caller can return 401 with
// a WWW-Authenticate challenge instead of silently falling through to
// the env API key or free tier.
invalidOAuthJwt = true;
console.error("[EXA-MCP] Invalid OAuth JWT token");
}
}

try {
const parsedUrl = new URL(request.url);
const params = parsedUrl.searchParams;

// 3. Check ?exaApiKey=YOUR_KEY (fallback for backwards compat, only if no header)
if (!xApiKey && !getBearerToken(request) && params.has("exaApiKey")) {
const keyFromUrl = params.get("exaApiKey");
if (keyFromUrl) {
exaApiKey = keyFromUrl;
userProvidedApiKey = true;
authMethod = "api_key";
}
}

// Support ?tools=tool1,tool2
if (params.has("tools")) {
const toolsParam = params.get("tools");
Expand Down Expand Up @@ -553,6 +600,7 @@ async function getConfigFromRequest(request: Request): Promise<RequestConfig> {
defaultSearchType,
oauthAccessToken,
invalidOAuthJwt,
fallbackJwt,
mcpMaxDurationSeconds: parsePositiveInteger(process.env.MCP_MAX_DURATION_SECONDS),
agentCallWindowMs: agentCallWindowMs ?? parsePositiveInteger(process.env.AGENT_CALL_WINDOW_MS),
};
Expand Down Expand Up @@ -682,6 +730,22 @@ async function processRequest(
options?: { forceOAuth?: boolean; resourcePath?: string },
): Promise<Response> {
const debug = process.env.DEBUG === "true";
if (request.method === "POST" && !acceptsMcpResponses(request.headers.get("accept"))) {
return withCors(
Response.json(
{
jsonrpc: "2.0",
error: {
code: -32000,
message:
"Not Acceptable: Client must accept both application/json and text/event-stream",
},
id: null,
},
{ status: 406 },
),
);
}
const body = request.method === "POST" ? await request.clone().text() : undefined;
if (body !== undefined && declaresJsonBody(request) && !isParsableJson(body)) {
return createParseErrorResponse();
Expand Down Expand Up @@ -746,9 +810,15 @@ async function processRequest(
stored: storedMcpClient,
userAgent,
});
config.apiKeyFallback = createApiKeyFallback(config);

if (config.debug) {
console.log(`[EXA-MCP] Request URL: ${request.url}`);
// Redact the API key from the logged URL; the request is only sanitized below.
const debugUrl = new URL(request.url);
if (debugUrl.searchParams.has("exaApiKey")) {
debugUrl.searchParams.set("exaApiKey", "[REDACTED]");
}
console.log(`[EXA-MCP] Request URL: ${debugUrl}`);
console.log(`[EXA-MCP] Enabled tools: ${config.enabledTools?.join(", ") || "default"}`);
console.log(`[EXA-MCP] Auth method: ${config.authMethod}`);
console.log(
Expand Down Expand Up @@ -815,6 +885,11 @@ async function processRequest(
// access without exposing it to downstream request instrumentation.
url.searchParams.delete("exaApiKey");
const sanitizedHeaders = new Headers(request.headers);
if (request.method === "POST") {
// Validated above; the transport's own Accept check is a substring match,
// so hand it the canonical form.
sanitizedHeaders.set("accept", "application/json, text/event-stream");
}
sanitizedHeaders.delete("x-api-key");
sanitizedHeaders.delete("authorization");
request = new Request(url.toString(), {
Expand Down
2 changes: 1 addition & 1 deletion api/well-known-mcp-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const configSchema = {
type: "string",
title: "Enabled Tools",
description:
"Comma-separated tools. When set, replaces defaults (web_search_exa, web_fetch_exa). agent_run requires OAuth or an API key.",
"Comma-separated tools. When set, replaces defaults (web_search_exa, web_fetch_exa, plus agent_run with OAuth or an API key). agent_run requires OAuth or an API key.",
examples: [
"web_search_advanced_exa",
"web_search_exa,web_fetch_exa,agent_run",
Expand Down
12 changes: 8 additions & 4 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
EXA_API_KEY=

# Comma-separated tools. When set, replaces the defaults.
# Defaults: web_search_exa, web_fetch_exa
# Optional: agent_run, web_search_advanced_exa
# Defaults: web_search_exa, web_fetch_exa, plus agent_run when EXA_API_KEY is set
# Optional: web_search_advanced_exa
# Alias: agent_tools → agent_run
# ENABLED_TOOLS=web_search_exa,web_fetch_exa,agent_run

# DEBUG=true
# DEFAULT_SEARCH_TYPE=auto # auto | fast | instant

# agent_run timing (optional)
# agent_run timing (optional): each call hands back the run ID for resumption
# once the call window passes.
# MCP_MAX_DURATION_SECONDS=800
# AGENT_CALL_WINDOW_MS=750000
# AGENT_CALL_WINDOW_MS=45000

# Exa API origin override (optional; defaults to https://api.exa.ai)
# EXA_API_BASE_URL=https://api.exa.ai
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@
"format": "prettier --write \"**/*.{js,jsx,ts,tsx}\"",
"format:check": "prettier --check \"**/*.{js,jsx,ts,tsx}\"",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"ci": "npm run format:check && npm run typecheck && npm run test",
"ci": "npm run format:check && npm run typecheck && npm run test && npm run test:integration",
"prepare": "npm run build",
"watch": "./node_modules/.bin/tsc --watch",
"dev": "tsx src/stdio-cli.ts",
Expand Down
16 changes: 11 additions & 5 deletions skills/exa-agent/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: Exa Agent
description: "Use Exa Agent for multi-step web research, list-building, enrichment, structured output, run continuation, and coverage validation. Exa Agent can access additional data providers: fiber, financial_datasets, similarweb, baselayer, affiliate, particle, and jinko."
description: "Use Exa Agent for multi-step web research, list-building, enrichment, structured output, run continuation, and coverage validation. Exa Agent can access additional data providers: fiber, financial_datasets, similarweb, baselayer, affiliate, particle, jinko, polymarket, and macrobond."
---

# Exa Agent Research
Expand All @@ -23,7 +23,9 @@ Use only the currently usable self-serve providers:
- `baselayer`: US business verification, officers, registrations, and KYB
- `affiliate`: product catalog search, pricing, brands, and merchant links
- `particle`: podcast transcript search with speaker attribution and timestamps
- `jinko`: travel destination discovery ranked by fare
- `jinko`: flight and hotel search with real-time pricing, plus travel destination discovery
- `polymarket`: prediction market odds, price history, order books, and trader positions
- `macrobond`: macroeconomic and financial time series, entity metadata, and release calendars

Do not suggest request-only providers unless the user explicitly says their Exa account already has them enabled.

Expand Down Expand Up @@ -143,18 +145,21 @@ Example with Exa Connect:
1. Run the agent
- Call `agent_run`.
- Omit `effort` to use the tool's `low` default. Choose `auto` or a higher effort only when the user asks for more depth or the task clearly requires it.
- `ultra` is the highest-effort tier; use it only when the user explicitly wants exhaustive results.
- Include `outputSchema` for structured work.
- Use `input.data` for known rows.
- Use `input.exclusion` for entities already returned or disallowed.
- Add `dataSources` only when one of the self-serve Exa Connect providers is clearly useful.
- Name the provider-specific data you want in both the query and the schema so Agent uses the provider instead of falling back to web search.
- Save the returned `id` when a later continuation may use `previousRunId`.
- If the response has `status: "running"`, call `agent_run` again with that `runId` until `outputReady` is true. This continuation is available for retained runs that outlive one MCP call.
- Zero Data Retention (ZDR) teams: new runs always stream, and output is only available on that live stream (not via `runId` resumption). The MCP call window is ~750 seconds; if a ZDR run cannot finish in one call, retry with lower effort or split the task. `previousRunId` is not available on ZDR.
- Zero Data Retention (ZDR) teams: new runs always stream, and output is only available on that live stream (not via `runId` resumption). If a ZDR run cannot finish within one MCP call window (45 seconds by default), retry with lower effort or split the task. `previousRunId` is not available on ZDR.

2. Read the result
- `agent_run` declares an MCP `outputSchema`; every completed/running/failed/cancelled result carries the same object as `structuredContent` and as a JSON text block. Read `structuredContent` when your client exposes it; otherwise parse the text.
- Result fields: `success`, `id`, `status` (`completed` | `running` | `failed` | `cancelled`), `outputReady`, `output` (completed runs only; may be null), optional `stopReason`, `usage`, `costDollars`, `error` (failed), `message` (running).
- Wait until `outputReady` is true (or status is failed/cancelled).
- Read both `output.structured` and `output.grounding`.
- Read `output.text`, `output.structured`, and `output.grounding`. `structured` follows your `outputSchema`; `grounding` carries per-field citations and confidence.
- Do not assume results are exhaustive just because the run completed.

3. Validate coverage
Expand Down Expand Up @@ -247,7 +252,8 @@ If a run fails to start:

If the run fails:

- Explain the failure from the returned terminal status.
- Explain the failure from the returned terminal status (`status: "failed"`, `stopReason`, and `error` when present).
- A text-only error without `structuredContent` means the tool could not report a run result; unless the text names an `agent_run_...` ID, the upstream state is unknown, so do not retry blindly.
- Create a corrected follow-up/new run only if the correction is clear.

If the run objective/schema is wrong, abort the streaming call. The server will attempt to cancel the upstream run; you will then need to create a new run with the corrected objective/schema.
Expand Down
Loading
Loading