markov-exa-mcp-server
Version:
A Model Context Protocol server with Exa for web search, academic paper search, and Twitter/X.com search. Provides real-time web searches with configurable tool selection, allowing users to enable or disable specific search capabilities. Supports customiz
420 lines (419 loc) • 19 kB
JavaScript
import { z } from "zod";
import axios from "axios";
import { API_CONFIG } from "./config.js";
import { createRequestLogger } from "../utils/logger.js";
// Zod schemas for validation
const entityTypeSchema = z.enum(['company', 'person', 'article', 'research_paper', 'custom']);
const entityConfigSchema = z.object({
type: entityTypeSchema,
customType: z.string().optional()
}).refine(data => data.type !== 'custom' || (data.type === 'custom' && data.customType), { message: "customType is required when type is 'custom'" });
const searchConfigSchema = z.object({
query: z.string().describe("Search query to find matching items"),
count: z.number().describe("Number of results to retrieve"),
criteria: z.array(z.object({
description: z.string()
})).optional().describe("Verification criteria for results"),
behavior: z.enum(['append', 'replace']).optional().describe("How to handle new results")
});
const enrichmentConfigSchema = z.object({
description: z.string().describe("What data to extract"),
format: z.enum(['text', 'number', 'boolean', 'json']).describe("Expected output format"),
schema: z.any().optional().describe("JSON schema for structured output (when format is 'json')")
});
export function registerWebsetsTools(server, config) {
const getApiKey = () => config?.exaApiKey || process.env.EXA_API_KEY;
const createAxiosInstance = (apiKey) => {
return axios.create({
baseURL: API_CONFIG.WEBSETS_ENDPOINT,
headers: {
'accept': 'application/json',
'content-type': 'application/json',
'x-api-key': apiKey
},
timeout: API_CONFIG.REQUEST_TIMEOUT
});
};
// Tool to create a new webset
server.tool("websets_create_exa", "Create a new Webset to automatically search, verify, and enrich web data. Returns a webset ID that can be used to track progress and retrieve results.", {
search: searchConfigSchema.optional().describe("Initial search configuration"),
enrichments: z.array(enrichmentConfigSchema).optional()
.describe("Data extraction configurations to run on each item"),
entity: entityConfigSchema.optional()
.describe("Entity type configuration for the webset"),
metadata: z.record(z.any()).optional()
.describe("Custom metadata to attach to the webset"),
webhookUrl: z.string().url().optional()
.describe("URL to receive webhook notifications for events")
}, async ({ search, enrichments, entity, metadata, webhookUrl }) => {
const requestId = `websets_create-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'websets_create_exa');
try {
const apiKey = getApiKey();
if (!apiKey) {
logger.error('No API key found');
return {
content: [{
type: "text",
text: "EXA_API_KEY environment variable is not set"
}],
isError: true
};
}
const axiosInstance = createAxiosInstance(apiKey);
const requestBody = {
...(search && { search }),
...(enrichments && { enrichments }),
...(entity && { entity }),
...(metadata && { metadata }),
...(webhookUrl && { webhookUrl })
};
logger.log(`Creating webset with config: ${JSON.stringify(requestBody, null, 2)}`);
const response = await axiosInstance.post('/websets', requestBody);
logger.log(`Webset created successfully - ID: ${response.data.id}`);
logger.complete();
return {
content: [{
type: "text",
text: JSON.stringify({
websetId: response.data.id,
status: response.data.status,
message: "Webset created successfully. Use 'websets_get_exa' to check status or 'websets_items_list_exa' to retrieve items.",
webset: response.data
}, null, 2)
}]
};
}
catch (error) {
logger.error(error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.message || error.message;
const statusCode = error.response?.status;
// Handle specific case for Pro account requirement
if (errorMessage.includes('does not have access to the API') || errorMessage.includes('Upgrade to a Pro plan')) {
return {
content: [{
type: "text",
text: `Websets functionality requires an Exa Pro subscription ($449/month). Your current account tier does not have access to the Websets API. Consider using a combination of web_search_exa and contents_exa tools for similar functionality, or upgrade your Exa account at https://dashboard.exa.ai/`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to create webset (${statusCode}): ${errorMessage}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to create webset: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
// Tool to list websets
server.tool("websets_list_exa", "List all websets with pagination support. Returns websets in reverse chronological order.", {
limit: z.number().optional().describe("Number of results per page (default: 20, max: 100)"),
cursor: z.string().optional().describe("Pagination cursor from previous response"),
status: z.enum(['idle', 'running', 'canceled']).optional()
.describe("Filter by webset status")
}, async ({ limit = 20, cursor, status }) => {
const requestId = `websets_list-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'websets_list_exa');
try {
const apiKey = getApiKey();
if (!apiKey) {
logger.error('No API key found');
return {
content: [{
type: "text",
text: "EXA_API_KEY environment variable is not set"
}],
isError: true
};
}
const axiosInstance = createAxiosInstance(apiKey);
const params = { limit };
if (cursor)
params.cursor = cursor;
if (status)
params.status = status;
logger.log(`Listing websets with params: ${JSON.stringify(params)}`);
const response = await axiosInstance.get('/websets', { params });
logger.log(`Retrieved ${response.data.data.length} websets`);
logger.complete();
return {
content: [{
type: "text",
text: JSON.stringify({
websets: response.data.data,
hasMore: response.data.hasMore,
cursor: response.data.cursor,
count: response.data.data.length
}, null, 2)
}]
};
}
catch (error) {
logger.error(error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.message || error.message;
return {
content: [{
type: "text",
text: `Failed to list websets: ${errorMessage}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to list websets: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
// Tool to get webset details
server.tool("websets_get_exa", "Get details of a specific webset including status, item count, and current operations.", {
websetId: z.string().describe("The webset ID to retrieve"),
expand: z.enum(['items']).optional()
.describe("Expand the response to include the latest 100 items")
}, async ({ websetId, expand }) => {
const requestId = `websets_get-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'websets_get_exa');
try {
const apiKey = getApiKey();
if (!apiKey) {
logger.error('No API key found');
return {
content: [{
type: "text",
text: "EXA_API_KEY environment variable is not set"
}],
isError: true
};
}
const axiosInstance = createAxiosInstance(apiKey);
const params = {};
if (expand)
params.expand = expand;
logger.log(`Getting webset ${websetId}`);
const response = await axiosInstance.get(`/websets/${websetId}`, { params });
logger.log(`Retrieved webset - Status: ${response.data.status}, Items: ${response.data.itemCount || 0}`);
logger.complete();
return {
content: [{
type: "text",
text: JSON.stringify(response.data, null, 2)
}]
};
}
catch (error) {
logger.error(error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.message || error.message;
return {
content: [{
type: "text",
text: `Failed to get webset: ${errorMessage}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to get webset: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
// Tool to list items in a webset
server.tool("websets_items_list_exa", "List items in a webset with pagination. Items are available immediately as they're discovered.", {
websetId: z.string().describe("The webset ID to list items from"),
limit: z.number().optional().describe("Number of items per page (default: 20, max: 100)"),
cursor: z.string().optional().describe("Pagination cursor from previous response"),
enriched: z.boolean().optional().describe("Filter for only enriched items")
}, async ({ websetId, limit = 20, cursor, enriched }) => {
const requestId = `websets_items_list-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'websets_items_list_exa');
try {
const apiKey = getApiKey();
if (!apiKey) {
logger.error('No API key found');
return {
content: [{
type: "text",
text: "EXA_API_KEY environment variable is not set"
}],
isError: true
};
}
const axiosInstance = createAxiosInstance(apiKey);
const params = { limit };
if (cursor)
params.cursor = cursor;
if (enriched !== undefined)
params.enriched = enriched;
logger.log(`Listing items for webset ${websetId}`);
const response = await axiosInstance.get(`/websets/${websetId}/items`, { params });
logger.log(`Retrieved ${response.data.data.length} items`);
logger.complete();
return {
content: [{
type: "text",
text: JSON.stringify({
items: response.data.data,
hasMore: response.data.hasMore,
cursor: response.data.cursor,
count: response.data.data.length
}, null, 2)
}]
};
}
catch (error) {
logger.error(error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.message || error.message;
return {
content: [{
type: "text",
text: `Failed to list items: ${errorMessage}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to list items: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
// Tool to create additional search on existing webset
server.tool("websets_search_create_exa", "Create an additional search on an existing webset. New results will be added to the webset.", {
websetId: z.string().describe("The webset ID to add search to"),
search: searchConfigSchema.describe("Search configuration")
}, async ({ websetId, search }) => {
const requestId = `websets_search_create-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'websets_search_create_exa');
try {
const apiKey = getApiKey();
if (!apiKey) {
logger.error('No API key found');
return {
content: [{
type: "text",
text: "EXA_API_KEY environment variable is not set"
}],
isError: true
};
}
const axiosInstance = createAxiosInstance(apiKey);
const requestBody = {
websetId,
search
};
logger.log(`Creating search for webset ${websetId}`);
const response = await axiosInstance.post(`/websets/${websetId}/searches`, requestBody);
logger.log(`Search created successfully - ID: ${response.data.id}`);
logger.complete();
return {
content: [{
type: "text",
text: JSON.stringify({
searchId: response.data.id,
status: response.data.status,
message: "Search created successfully. Results will be added to the webset as they're discovered.",
search: response.data
}, null, 2)
}]
};
}
catch (error) {
logger.error(error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.message || error.message;
return {
content: [{
type: "text",
text: `Failed to create search: ${errorMessage}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to create search: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
// Tool to cancel a webset or its operations
server.tool("websets_cancel_exa", "Cancel all running operations on a webset. This will stop any active searches or enrichments.", {
websetId: z.string().describe("The webset ID to cancel operations for")
}, async ({ websetId }) => {
const requestId = `websets_cancel-${Date.now()}-${Math.random().toString(36).substring(2, 7)}`;
const logger = createRequestLogger(requestId, 'websets_cancel_exa');
try {
const apiKey = getApiKey();
if (!apiKey) {
logger.error('No API key found');
return {
content: [{
type: "text",
text: "EXA_API_KEY environment variable is not set"
}],
isError: true
};
}
const axiosInstance = createAxiosInstance(apiKey);
logger.log(`Canceling operations for webset ${websetId}`);
const response = await axiosInstance.post(`/websets/${websetId}/cancel`);
logger.log(`Operations canceled successfully`);
logger.complete();
return {
content: [{
type: "text",
text: JSON.stringify({
websetId,
message: "All running operations have been canceled",
response: response.data
}, null, 2)
}]
};
}
catch (error) {
logger.error(error);
if (axios.isAxiosError(error)) {
const errorMessage = error.response?.data?.message || error.message;
return {
content: [{
type: "text",
text: `Failed to cancel operations: ${errorMessage}`
}],
isError: true
};
}
return {
content: [{
type: "text",
text: `Failed to cancel operations: ${error instanceof Error ? error.message : String(error)}`
}],
isError: true
};
}
});
}