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
JavaScript
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();