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
3 changes: 0 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerLinkedInSearchTool } from "./tools/linkedInSearch.js";
import { registerDeepResearchStartTool } from "./tools/deepResearchStart.js";
import { registerDeepResearchCheckTool } from "./tools/deepResearchCheck.js";
import { registerExaCodeTool } from "./tools/exaCode.js";
import { registerAnswerTool } from "./tools/answer.js";
import { log } from "./utils/logger.js";

// Configuration schema for the EXA API key and tool selection
Expand All @@ -26,6 +27,7 @@ export const stateless = true;
const availableTools = {
'web_search_exa': { name: 'Web Search (Exa)', description: 'Real-time web search using Exa AI', enabled: true },
'get_code_context_exa': { name: 'Code Context Search', description: 'Search for code snippets, examples, and documentation from open source repositories', enabled: true },
'answer_exa': { name: 'Answer', description: 'Get LLM-powered answers to questions with search-backed citations', enabled: true },
'crawling_exa': { name: 'Web Crawling', description: 'Extract content from specific URLs', enabled: false },
'deep_researcher_start': { name: 'Deep Researcher Start', description: 'Start a comprehensive AI research task', enabled: false },
'deep_researcher_check': { name: 'Deep Researcher Check', description: 'Check status and retrieve results of research task', enabled: false },
Expand Down Expand Up @@ -112,7 +114,12 @@ export default function ({ config }: { config: z.infer<typeof configSchema> }) {
registerExaCodeTool(server, config);
registeredTools.push('get_code_context_exa');
}


if (shouldRegisterTool('answer_exa')) {
registerAnswerTool(server, config);
registeredTools.push('answer_exa');
}

if (config.debug) {
log(`Registered ${registeredTools.length} tools: ${registeredTools.join(', ')}`);
}
Expand Down
123 changes: 123 additions & 0 deletions src/tools/answer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { z } from "zod";
import axios from "axios";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { API_CONFIG } from "./config.js";
import { AnswerRequest, AnswerResponse } from "../types.js";
import { createRequestLogger } from "../utils/logger.js";

// JSON Schema validator - validates that the outputSchema is a valid JSON schema
const jsonSchemaValidator = z.object({
type: z.string().describe("The JSON schema type (e.g., 'object', 'string', 'array')"),
description: z.string().optional(),
properties: z.record(z.any()).optional(),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
enum: z.array(z.any()).optional(),
items: z.any().optional(),
}).passthrough(); // Allow additional JSON schema properties

/**
* Register the Answer tool for Exa AI
*
* This tool provides LLM-powered answers to questions informed by Exa search results.
* It performs a search, analyzes the results, and generates either:
* 1. A direct answer for specific queries (e.g., "What is the capital of France?" -> "Paris")
* 2. A detailed summary with citations for open-ended queries (e.g., "What is the state of AI in healthcare?")
*
* The response includes the generated answer, citations (sources), and cost information.
*/
export function registerAnswerTool(server: McpServer, config?: { exaApiKey?: string }): void {
server.tool(
"answer_exa",
"Get an LLM-powered answer to a question informed by Exa search results. Performs a search and uses an LLM to generate either a direct answer for specific queries or a detailed summary with citations for open-ended queries. Returns the answer, sources used, and cost information. Supports optional JSON schema output for structured responses.",
{
query: z.string().min(1).describe("The question or query to answer"),
includeText: z.boolean().optional().describe("If true, include full text content in the citations (default: false). Increases token consumption significantly. Only use when the full text of citations is necessary to put answers into context."),
outputSchema: jsonSchemaValidator.optional().describe("Optional JSON schema to structure the answer response. Must be a valid JSON schema with at minimum a 'type' property. Example: { type: 'object', properties: { response: { type: 'string', enum: ['yes', 'no', 'maybe'] } }, required: ['response'] }")
},
async ({ query, includeText, outputSchema }) => {
const requestId = `answer_exa-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'answer_exa');

logger.start(query);

try {
// Create a fresh axios instance for each request
const axiosInstance = axios.create({
baseURL: API_CONFIG.BASE_URL,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'x-api-key': config?.exaApiKey || process.env.EXA_API_KEY || ''
},
timeout: 25000
});

const answerRequest: AnswerRequest = {
query,
stream: false, // MCP tools don't support streaming
text: includeText || false,
...(outputSchema && { outputSchema })
};

logger.log("Sending request to Exa Answer API");

const response = await axiosInstance.post<AnswerResponse>(
API_CONFIG.ENDPOINTS.ANSWER,
answerRequest,
{ timeout: 25000 }
);

logger.log("Received response from Exa Answer API");

if (!response.data || !response.data.answer) {
logger.log("Warning: Empty or invalid response from Exa Answer API");
return {
content: [{
type: "text" as const,
text: "Failed to generate an answer. Please try rephrasing your query."
}]
};
}

logger.log(`Answer generated with ${response.data.citations?.length || 0} citations`);

const result = {
content: [{
type: "text" as const,
text: JSON.stringify(response.data, null, 2)
}]
};

logger.complete();
return result;
} catch (error) {
logger.error(error);

if (axios.isAxiosError(error)) {
// Handle Axios errors specifically
const statusCode = error.response?.status || 'unknown';
const errorMessage = error.response?.data?.message || error.message;

logger.log(`Axios error (${statusCode}): ${errorMessage}`);
return {
content: [{
type: "text" as const,
text: `Answer error (${statusCode}): ${errorMessage}`
}],
isError: true,
};
}

// Handle generic errors
return {
content: [{
type: "text" as const,
text: `Answer error: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true,
};
}
}
);
}
3 changes: 2 additions & 1 deletion src/tools/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ export const API_CONFIG = {
ENDPOINTS: {
SEARCH: '/search',
RESEARCH_TASKS: '/research/v0/tasks',
CONTEXT: '/context'
CONTEXT: '/context',
ANSWER: '/answer'
},
DEFAULT_NUM_RESULTS: 8,
DEFAULT_MAX_CHARACTERS: 2000
Expand Down
15 changes: 10 additions & 5 deletions src/tools/linkedInSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,25 +33,30 @@ export function registerLinkedInSearchTool(server: McpServer, config?: { exaApiK
});

let searchQuery = query;
let includeDomains: string[];

if (searchType === "profiles") {
searchQuery = `${query} LinkedIn profile`;
searchQuery = `${query}`;
includeDomains = ["linkedin.com/in"];
} else if (searchType === "companies") {
searchQuery = `${query} LinkedIn company`;
searchQuery = `${query}`;
includeDomains = ["linkedin.com/company"];
} else {
searchQuery = `${query} LinkedIn`;
searchQuery = `${query}`;
includeDomains = ["linkedin.com"];
}

const searchRequest: ExaSearchRequest = {
query: searchQuery,
type: "neural",
type: "keyword",
numResults: numResults || API_CONFIG.DEFAULT_NUM_RESULTS,
contents: {
text: {
maxCharacters: API_CONFIG.DEFAULT_MAX_CHARACTERS
},
livecrawl: 'preferred'
},
includeDomains: ["linkedin.com"]
includeDomains: includeDomains
};

logger.log("Sending request to Exa API for LinkedIn search");
Expand Down
53 changes: 53 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,4 +152,57 @@ export interface ExaCodeResponse {
searchTime: number;
outputTokens?: number;
traces?: any;
}

// Shared cost structure used across multiple endpoints
export interface CostDollars {
total: number;
breakDown?: Array<{
search?: number;
contents?: number;
breakdown?: {
keywordSearch?: number;
neuralSearch?: number;
contentText?: number;
contentHighlight?: number;
contentSummary?: number;
};
}>;
perRequestPrices?: {
neuralSearch_1_25_results?: number;
neuralSearch_26_100_results?: number;
neuralSearch_100_plus_results?: number;
keywordSearch_1_100_results?: number;
keywordSearch_100_plus_results?: number;
};
perPagePrices?: {
contentText?: number;
contentHighlight?: number;
contentSummary?: number;
};
}

// Answer API Types
export interface AnswerRequest {
query: string;
stream?: boolean;
text?: boolean;
outputSchema?: Record<string, any>; // Any valid JSON schema
}

export interface Citation {
id: string;
url: string;
title: string;
author: string | null;
publishedDate: string | null;
text?: string;
image?: string;
favicon?: string;
}

export interface AnswerResponse {
answer: string;
citations: Citation[];
costDollars: CostDollars;
}