noodle-perplexity-mcp
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, transparent reasoning, and multimodal support with file attachments (P
203 lines (197 loc) • 12.5 kB
JavaScript
/**
* @fileoverview Defines the core logic, schemas, and types for the `perplexity_think_and_analyze` tool.
* This tool interfaces with the Perplexity API to provide logical reasoning and analytical thinking using the sonar-reasoning-pro model.
* @module src/mcp-server/tools/perplexityThinkAndAnalyze/logic
*/
import { z } from 'zod';
import { perplexityApiService } from '../../../services/index.js';
import { BaseErrorCode, McpError } from '../../../types-global/errors.js';
import { logger } from '../../../utils/index.js';
// 1. DEFINE Zod input and output schemas.
// File attachment schema for multimodal support
const FileInputSchema = z.object({
url: z.string().url().optional().describe("Public URL to the file (documents: PDF, DOC, DOCX, TXT, RTF; images: PNG, JPEG, WEBP, GIF)."),
base64: z.string().optional().describe("Base64 encoded file content (without data: prefix - the server will add the correct prefix automatically based on file type). Alternative to URL."),
file_name: z.string().optional().describe("Optional file name for better context. Required for base64 files to determine file type (e.g., 'document.pdf', 'image.png')."),
}).refine((data) => data.url || data.base64, { message: "Either 'url' or 'base64' must be provided for each file." }).describe("File attachment object. Provide either a public URL or base64 encoded content. Supported formats: PDF, DOC, DOCX, TXT, RTF (documents); PNG, JPEG, WEBP, GIF (images). Max 50MB per file.");
export const PerplexityThinkAndAnalyzeInputSchema = z.object({
query: z.string().min(1).describe("The query requiring logical reasoning, analysis, or step-by-step thinking."),
files: z.array(FileInputSchema).optional().describe("Optional array of file attachments to analyze alongside the query. Analyze code files, error logs, diagrams, or documentation while performing systematic reasoning. Each file needs either 'url' or 'base64'. Supported: PDF, DOC, DOCX, TXT, RTF, PNG, JPEG, WEBP, GIF (max 50MB per file)."),
return_related_questions: z.boolean().optional().default(false).describe("If true, the model will suggest related questions in its response. Defaults to false."),
search_recency_filter: z.string().optional().describe("Restricts the web search to a specific timeframe. Accepts 'day', 'week', 'month', 'year'."),
search_domain_filter: z.array(z.string()).max(20).optional().describe("Filter search results by domain (max 20). " +
"ALLOWLIST (include only): ['nasa.gov', 'wikipedia.org'] - use simple domain names without https:// or www. " +
"DENYLIST (exclude): ['-pinterest.com', '-reddit.com'] - prefix with '-' to exclude. " +
"URL-LEVEL: ['https://example.com/specific-page'] - complete URLs to target specific pages. " +
"SPECIAL: ['sec'] - search SEC filings (10-K, 10-Q, 8-K). " +
"Cannot mix allowlist and denylist modes. Main domains filter all subdomains automatically."),
search_after_date_filter: z.string().optional().describe("Filters search results to content published after a specific date (MM/DD/YYYY)."),
search_before_date_filter: z.string().optional().describe("Filters search results to content published before a specific date (MM/DD/YYYY)."),
search_mode: z.enum(['web', 'academic']).optional().describe("Set to 'academic' to prioritize scholarly sources."),
showThinking: z.boolean().optional().default(false).describe("If true, includes the model's internal reasoning process in the response. Defaults to false."),
}).describe("Perform logical reasoning, analysis, and step-by-step thinking using Perplexity's sonar-reasoning-pro model. Supports file attachments for code analysis, debugging, and document review. Best for complex problem-solving, mathematical calculations, code analysis, logical puzzles, and questions requiring structured thinking. (Ex. 'Analyze this error log file and identify the root cause', 'Review this algorithm implementation and suggest optimizations')");
const SearchResultSchema = z.object({
title: z.string().describe("The title of the search result."),
url: z.string().describe("The URL of the search result (may be empty for file attachments)."),
date: z.string().nullable().optional().describe("The publication date of the search result. Can be null."),
});
export const PerplexityThinkAndAnalyzeResponseSchema = z.object({
rawResultText: z.string().describe("The analytical response from the Perplexity model."),
responseId: z.string().describe("The unique identifier for the Perplexity API response."),
modelUsed: z.string().describe("The model that was used to generate the response."),
usage: z.object({
prompt_tokens: z.number(),
completion_tokens: z.number(),
total_tokens: z.number(),
}).describe("Token usage details for the API call."),
searchResults: z.array(SearchResultSchema).optional().describe("An array of web search results used to generate the response."),
});
// --- System Prompt ---
const SYSTEM_PROMPT = `You are an expert analytical AI using Perplexity's advanced reasoning capabilities. Your specialty is logical thinking, step-by-step analysis, and systematic problem-solving.
**Core Directives:**
1. **Structured Reasoning:** Break down complex problems into logical steps. Show your work and reasoning process clearly.
2. **Analytical Depth:** Go beyond surface-level answers. Analyze underlying principles, patterns, and relationships.
3. **Evidence-Based Logic:** Support analytical conclusions with credible sources and data when available.
4. **Multiple Approaches:** When appropriate, consider different analytical frameworks or problem-solving methods.
**Response Structure:**
1. **Clear Methodology:** Begin with your analytical approach or framework when tackling complex problems.
2. **Step-by-Step Process:** Present reasoning in logical sequence with clear transitions between steps.
3. **Evidence Integration:** Weave supporting evidence naturally into your analysis rather than listing sources separately.
4. **Conclusion with Implications:** End with clear conclusions and discuss broader implications or applications.
**Reasoning Style:**
- Methodical and systematic in approach
- Explicit about assumptions and limitations
- Quantitative when relevant data is available
- Considers edge cases and alternative interpretations
- Balances theoretical analysis with practical applications`;
/**
* 3. IMPLEMENT and export the core logic function.
* It must remain pure: its only concerns are its inputs and its return value or thrown error.
* @throws {McpError} If the logic encounters an unrecoverable issue.
*/
export async function perplexityThinkAndAnalyzeLogic(params, context) {
logger.debug("Executing perplexityThinkAndAnalyzeLogic...", { ...context, toolInput: params });
// Always use sonar-reasoning-pro for analytical thinking
const model = 'sonar-reasoning-pro';
// Construct user message - multimodal if files present, simple string otherwise
let userMessage;
if (params.files && params.files.length > 0) {
// Multimodal message with files
const contentArray = [
{ type: 'text', text: params.query }
];
// Helper function to detect if file is an image based on extension
const isImageFile = (fileName, url) => {
const name = (fileName || url || '').toLowerCase();
return name.endsWith('.png') || name.endsWith('.jpg') ||
name.endsWith('.jpeg') || name.endsWith('.webp') ||
name.endsWith('.gif');
};
// Helper function to get MIME type for images
const getImageMimeType = (fileName, url) => {
const name = (fileName || url || '').toLowerCase();
if (name.endsWith('.jpg') || name.endsWith('.jpeg'))
return 'image/jpeg';
if (name.endsWith('.webp'))
return 'image/webp';
if (name.endsWith('.gif'))
return 'image/gif';
return 'image/png'; // default
};
// Add each file to the content array
for (const file of params.files) {
let fileUrl;
let isImage;
if (file.url) {
// Use provided URL directly
fileUrl = file.url;
isImage = isImageFile(file.file_name, file.url);
}
else if (file.base64) {
// For base64: Determine if image or document based on file_name
isImage = isImageFile(file.file_name);
if (isImage) {
// Images need data URI format with MIME type
const mimeType = getImageMimeType(file.file_name);
fileUrl = `data:${mimeType};base64,${file.base64}`;
}
else {
// Documents need raw base64 (no prefix)
fileUrl = file.base64;
}
}
else {
// Shouldn't happen due to Zod validation, but handle gracefully
throw new McpError(BaseErrorCode.VALIDATION_ERROR, 'File must have either url or base64', { ...context });
}
// Use correct content type based on file type
const fileContent = isImage
? {
type: 'image_url',
image_url: { url: fileUrl }
}
: {
type: 'file_url',
file_url: { url: fileUrl }
};
// Only add file_name for documents (images don't support this per docs)
if (!isImage && file.file_name) {
fileContent.file_name = file.file_name;
}
contentArray.push(fileContent);
}
userMessage = { role: 'user', content: contentArray };
// Count images vs documents for logging
const imageCount = contentArray.filter(c => c.type === 'image_url').length;
const documentCount = contentArray.filter(c => c.type === 'file_url').length;
logger.info("Multimodal request with file attachments", {
...context,
fileCount: params.files.length,
imageCount,
documentCount,
hasBase64: params.files.some(f => f.base64),
hasUrls: params.files.some(f => f.url)
});
}
else {
// Simple text message (backward compatibility)
userMessage = { role: 'user', content: params.query };
}
const requestPayload = {
model,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
userMessage,
],
stream: false,
...(params.return_related_questions && { return_related_questions: params.return_related_questions }),
...(params.search_recency_filter && { search_recency_filter: params.search_recency_filter }),
...(params.search_domain_filter && { search_domain_filter: params.search_domain_filter }),
...(params.search_after_date_filter && { search_after_date_filter: params.search_after_date_filter }),
...(params.search_before_date_filter && { search_before_date_filter: params.search_before_date_filter }),
...(params.search_mode && { search_mode: params.search_mode }),
};
logger.info("Calling Perplexity API with sonar-reasoning-pro model", { ...context, model });
logger.debug("API Payload", { ...context, payload: requestPayload });
const response = await perplexityApiService.chatCompletion(requestPayload, context);
const rawResultText = response.choices?.[0]?.message?.content;
if (!rawResultText) {
logger.warning("Perplexity API returned empty content", { ...context, responseId: response.id });
throw new McpError(BaseErrorCode.SERVICE_UNAVAILABLE, 'Perplexity API returned an empty response.', { ...context, responseId: response.id });
}
const toolResponse = {
rawResultText,
responseId: response.id,
modelUsed: response.model,
usage: response.usage,
searchResults: response.search_results,
};
logger.info("Perplexity think and analyze logic completed successfully.", {
...context,
responseId: toolResponse.responseId,
model: toolResponse.modelUsed,
usage: toolResponse.usage,
searchResultCount: toolResponse.searchResults?.length ?? 0,
});
return toolResponse;
}