UNPKG

perplexity-mcp-server

Version:

A Perplexity API Model Context Protocol (MCP) server that unlocks Perplexity's search-augmented AI capabilities for LLM agents. Features robust error handling, secure input validation, and transparent reasoning with the showThinking parameter. Built with

149 lines (148 loc) • 8.78 kB
import axios from 'axios'; import { z } from 'zod'; import { config } from '../config/index.js'; import { BaseErrorCode, McpError } from '../types-global/errors.js'; import { costTracker } from '../utils/costTracker.js'; import { ErrorHandler } from '../utils/errorHandler.js'; import { logger } from '../utils/logger.js'; import { sanitization } from '../utils/sanitization.js'; // --- Zod Schemas for Validation --- const MessageSchema = z.object({ role: z.enum(['system', 'user', 'assistant']), content: z.string(), }); const WebSearchOptionsSchema = z.object({ search_context_size: z.enum(['low', 'medium', 'high']).optional(), }).optional(); const ResponseFormatSchema = z.object({ // Define structure if known, otherwise allow any object // Example: type: z.literal('json_object').optional() }).optional(); export const PerplexityChatCompletionRequestSchema = z.object({ model: z.string().min(1), messages: z.array(MessageSchema).min(1), max_tokens: z.number().int().positive().optional(), temperature: z.number().min(0).max(2).optional(), top_p: z.number().min(0).max(1).optional(), search_domain_filter: z.array(z.string()).optional(), return_images: z.boolean().optional(), return_related_questions: z.boolean().optional(), search_recency_filter: z.string().optional(), // Consider enum if specific values known top_k: z.number().int().min(0).optional(), stream: z.boolean().optional().default(false), // Default to false as we handle full responses presence_penalty: z.number().min(-2).max(2).optional(), frequency_penalty: z.number().min(0).optional(), // API doc says >0, but default is 1, allowing 0 might be safer response_format: ResponseFormatSchema, web_search_options: WebSearchOptionsSchema, }); class PerplexityApiService { constructor() { this.apiBaseUrl = 'https://api.perplexity.ai'; this.apiKey = config.perplexityApiKey; if (!this.apiKey) { // Throw immediately during instantiation if API key is missing throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, 'PERPLEXITY_API_KEY environment variable is not set.', {} // No context available yet ); } this.axiosInstance = axios.create({ baseURL: this.apiBaseUrl, headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json', 'Accept': 'application/json', }, timeout: 60000, // 60 second timeout }); } /** * Calls the Perplexity Chat Completions API. * @param requestData - The validated request data conforming to PerplexityChatCompletionRequest. * @param context - The request context for logging and error tracking. * @returns The Perplexity API response. * @throws {McpError} If the API call fails or returns an error. */ async chatCompletion(requestData, context) { const operation = 'PerplexityApiService.chatCompletion'; const sanitizedInput = sanitization.sanitizeForLogging(requestData); logger.info(`[${operation}] Initiating chat completion request`, { ...context, model: requestData.model }); logger.debug(`[${operation}] Request details`, { ...context, input: sanitizedInput }); // Ensure stream is false, as this service handles full responses if (requestData.stream === true) { logger.warn(`[${operation}] Stream parameter was true, forcing to false for non-streaming service`, context); requestData.stream = false; } return await ErrorHandler.tryCatch(async () => { try { const response = await this.axiosInstance.post('/chat/completions', requestData // Send validated data ); logger.info(`[${operation}] Received successful response from Perplexity API`, { ...context, model: requestData.model, responseId: response.data.id }); logger.debug(`[${operation}] Response data`, { ...context, response: response.data }); // Log full response at debug level // Calculate and log cost const estimatedCost = costTracker.calculatePerplexityCost(response.data.model, response.data.usage, null, // searchMode - not directly available, pass null context); if (estimatedCost !== null) { logger.info(`[${operation}] Estimated API call cost: $${estimatedCost.toFixed(6)}`, { ...context, estimatedCost }); } else { logger.warn(`[${operation}] Could not estimate cost for model: ${response.data.model}`, context); } return response.data; } catch (error) { let errorCode = BaseErrorCode.INTERNAL_ERROR; // Default to internal error let errorMessage = 'Perplexity API request failed.'; if (axios.isAxiosError(error)) { const axiosError = error; errorMessage = `Perplexity API Error: ${axiosError.response?.status} ${axiosError.response?.statusText}. ${axiosError.response?.data?.error?.message || axiosError.message}`; if (axiosError.code === 'ECONNABORTED' || axiosError.message.includes('timeout')) { errorCode = BaseErrorCode.TIMEOUT; // Corrected: TIMEOUT_ERROR -> TIMEOUT errorMessage = 'Perplexity API request timed out.'; } else if (!axiosError.response) { // Use SERVICE_UNAVAILABLE for generic network issues where the API might be unreachable errorCode = BaseErrorCode.SERVICE_UNAVAILABLE; errorMessage = `Perplexity API network error: ${axiosError.message}`; } else if (axiosError.response.status === 401) { errorCode = BaseErrorCode.UNAUTHORIZED; // Corrected: AUTHENTICATION_ERROR -> UNAUTHORIZED errorMessage = 'Perplexity API authentication failed. Check API key.'; } else if (axiosError.response.status === 403) { errorCode = BaseErrorCode.FORBIDDEN; // Added check for 403 Forbidden errorMessage = 'Perplexity API access forbidden. Check permissions or plan.'; } else if (axiosError.response.status === 429) { errorCode = BaseErrorCode.RATE_LIMITED; // Corrected: RATE_LIMIT_ERROR -> RATE_LIMITED errorMessage = 'Perplexity API rate limit exceeded.'; } else if (axiosError.response.status >= 400 && axiosError.response.status < 500) { errorCode = BaseErrorCode.VALIDATION_ERROR; // Keep as VALIDATION_ERROR for client-side errors errorMessage = `Perplexity API client error (${axiosError.response.status}): ${axiosError.response?.data?.error?.message || axiosError.message}`; } logger.error(`[${operation}] Axios error details`, { ...context, status: axiosError.response?.status, data: axiosError.response?.data, message: axiosError.message }); } else if (error instanceof Error) { errorMessage = `Unexpected error during Perplexity API call: ${error.message}`; errorCode = BaseErrorCode.INTERNAL_ERROR; // Default for unexpected errors } // Combine context and original error into details const errorDetails = { ...(context || {}), // Spread context if it exists originalError: error instanceof Error ? { name: error.name, message: error.message } : String(error), // Optionally include more details from axios error if needed axiosResponseStatus: axios.isAxiosError(error) ? error.response?.status : undefined, }; // Throw standardized McpError with 3 arguments throw new McpError(errorCode, errorMessage, errorDetails); } }, { operation: operation, // Use named property for clarity context: context, input: sanitizedInput, // Log sanitized input on error errorCode: BaseErrorCode.INTERNAL_ERROR, // Corrected default error code critical: false, // API errors are usually not critical to the server itself }); } } // Export a singleton instance export const perplexityApiService = new PerplexityApiService();