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
137 lines (136 loc) • 6.22 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, ErrorHandler, logger, sanitization } from '../utils/index.js';
// --- Zod Schemas for Validation ---
const MessageSchema = z.object({
role: z.enum(['system', 'user', 'assistant']),
content: z.string(),
});
const PerplexityModelEnum = z.enum([
'sonar',
'sonar-pro',
'sonar-reasoning',
'sonar-reasoning-pro',
'sonar-deep-research',
]);
const ResponseFormatSchema = z.object({
type: z.enum(['json_object']),
}).describe("Defines the format of the model's output, such as JSON.");
const WebSearchOptionsSchema = z.object({
search_context_size: z.enum(['low', 'medium', 'high']).optional(),
search_domain_filter: z.array(z.string()).optional(),
search_recency_filter: z.enum(['last_day', 'last_week', 'last_month', 'last_year']).optional(),
search_after_date_filter: z.string().optional(),
search_before_date_filter: z.string().optional(),
}).describe("Options to control web search behavior.");
export const PerplexityChatCompletionRequestSchema = z.object({
model: PerplexityModelEnum,
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(),
top_k: z.number().int().min(0).optional(),
stream: z.boolean().optional().default(false),
presence_penalty: z.number().min(-2).max(2).optional(),
frequency_penalty: z.number().min(0).optional(),
return_images: z.boolean().optional(),
return_related_questions: z.boolean().optional(),
response_format: ResponseFormatSchema.optional(),
web_search_options: WebSearchOptionsSchema.optional(),
// Search-related parameters
search_domain_filter: z.array(z.string()).optional(),
search_recency_filter: z.string().optional(),
search_after_date_filter: z.string().optional(),
search_before_date_filter: z.string().optional(),
search_mode: z.enum(['web', 'academic']).optional(),
// Model-specific parameters
reasoning_effort: z.enum(['low', 'medium', 'high']).optional(), // For sonar-deep-research
});
// --- Async API Response Schemas ---
const UsageSchema = z.object({
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number(),
search_context_size: z.string().optional(),
citation_tokens: z.number().optional(),
// The docs use num_search_queries, but our cost tracker expects search_queries. We'll handle this mapping.
num_search_queries: z.number().optional(),
reasoning_tokens: z.number().optional(),
});
const ChoiceSchema = z.object({
index: z.number(),
finish_reason: z.string(),
message: MessageSchema,
delta: z.object({ role: z.string().optional(), content: z.string().optional() }).optional(),
});
const SearchResultSchema = z.object({
title: z.string(),
url: z.string().url(),
date: z.string().optional(),
});
const FinalResponsePayloadSchema = z.object({
id: z.string(),
model: z.string(),
created: z.number(),
usage: UsageSchema,
object: z.literal('chat.completion'),
choices: z.array(ChoiceSchema),
citations: z.array(z.string().url()).optional(),
search_results: z.array(SearchResultSchema).optional(),
});
class PerplexityApiService {
constructor() {
this.apiKey = config.perplexityApiKey;
if (!this.apiKey) {
throw new McpError(BaseErrorCode.CONFIGURATION_ERROR, 'PERPLEXITY_API_KEY environment variable is not set.', {});
}
this.axiosInstance = axios.create({
baseURL: config.perplexityApiBaseUrl,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
// Increase timeout for potentially long-running deep research tasks
timeout: config.perplexityPollingTimeoutMs,
});
}
async chatCompletion(requestData, context) {
const operation = 'PerplexityApiService.chatCompletion';
const sanitizedInput = sanitization.sanitizeForLogging(requestData);
if (requestData.stream === true) {
logger.warning(`[${operation}] Stream parameter is not supported and will be ignored.`, context);
requestData.stream = false;
}
return await ErrorHandler.tryCatch(async () => {
logger.info(`[${operation}] Initiating chat completion request`, { ...context, model: requestData.model });
logger.debug(`[${operation}] Request details`, { ...context, input: sanitizedInput });
const response = await this.axiosInstance.post('/chat/completions', requestData);
const finalResponse = response.data;
logger.info(`[${operation}] Received successful final response`, { ...context, responseId: finalResponse.id });
logger.debug(`[${operation}] Final response data`, { ...context, response: finalResponse });
const apiTier = requestData.reasoning_effort ?? finalResponse.usage.search_context_size;
const usageForCostTracker = {
...finalResponse.usage,
search_queries: finalResponse.usage.num_search_queries,
};
const estimatedCost = costTracker.calculatePerplexityCost(finalResponse.model, usageForCostTracker, apiTier, context);
if (estimatedCost !== null) {
logger.info(`[${operation}] Estimated API call cost: $${estimatedCost.toFixed(6)}`, { ...context, estimatedCost });
}
else {
logger.warning(`[${operation}] Could not estimate cost for model: ${finalResponse.model}`, context);
}
return finalResponse;
}, {
operation: operation,
context: context,
input: sanitizedInput,
errorCode: BaseErrorCode.INTERNAL_ERROR,
critical: false,
});
}
}
export const perplexityApiService = new PerplexityApiService();