diff --git a/package-lock.json b/package-lock.json index c9637d6..e822692 100644 --- a/package-lock.json +++ b/package-lock.json @@ -848,7 +848,6 @@ "version": "20.17.8", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -1483,7 +1482,6 @@ "node_modules/express": { "version": "5.1.0", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -3243,7 +3241,6 @@ "node_modules/zod": { "version": "3.24.2", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/index.ts b/src/index.ts index 337330a..04e0112 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 @@ -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 }, @@ -112,7 +114,12 @@ export default function ({ config }: { config: z.infer }) { 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(', ')}`); } diff --git a/src/tools/answer.ts b/src/tools/answer.ts new file mode 100644 index 0000000..157a142 --- /dev/null +++ b/src/tools/answer.ts @@ -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( + 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, + }; + } + } + ); +} diff --git a/src/tools/config.ts b/src/tools/config.ts index 7c37657..f7b7747 100644 --- a/src/tools/config.ts +++ b/src/tools/config.ts @@ -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 diff --git a/src/tools/linkedInSearch.ts b/src/tools/linkedInSearch.ts index 68e1767..dc1b3a4 100644 --- a/src/tools/linkedInSearch.ts +++ b/src/tools/linkedInSearch.ts @@ -33,17 +33,22 @@ 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: { @@ -51,7 +56,7 @@ export function registerLinkedInSearchTool(server: McpServer, config?: { exaApiK }, livecrawl: 'preferred' }, - includeDomains: ["linkedin.com"] + includeDomains: includeDomains }; logger.log("Sending request to Exa API for LinkedIn search"); diff --git a/src/types.ts b/src/types.ts index 7fd573b..3106865 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; // 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; } \ No newline at end of file