Perplexity MCP Server

  • src
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from "@modelcontextprotocol/sdk/types.js"; import axios, { type AxiosInstance } from "axios"; const API_KEY = process.env.PERPLEXITY_API_KEY; if (!API_KEY) { throw new Error("PERPLEXITY_API_KEY environment variable is required"); } const VALID_MODELS = [ "sonar-deep-research", "sonar-reasoning-pro", "sonar-reasoning", "sonar-pro", "sonar", ]; const DEFAULT_MODEL = process.env.PERPLEXITY_MODEL || "sonar-pro"; // Model selection criteria based on user intent interface ModelSelectionCriteria { keywords: string[]; description: string; } // Define models and their intent detection criteria const MODEL_SELECTION_MAP: Record<string, ModelSelectionCriteria> = { "sonar-deep-research": { keywords: [ "deep research", "comprehensive", "thorough", "detailed analysis", "expert", "in-depth", ], description: "specialized for extensive research and expert-level analysis across domains", }, "sonar-reasoning-pro": { keywords: [ "reasoning", "logic", "solve", "mathematical", "technical", "complex problem", "figure out", ], description: "optimized for advanced logical reasoning and complex problem-solving", }, "sonar-reasoning": { keywords: ["reason", "think", "analyze", "deduce", "evaluate"], description: "designed for reasoning tasks with balanced performance", }, "sonar-pro": { keywords: [ "search", "find", "lookup", "information", "facts", "details", "latest", ], description: "general-purpose model with excellent search capabilities and citation density", }, sonar: { keywords: ["quick", "simple", "basic", "brief", "short"], description: "fast and efficient for straightforward queries", }, }; if (!VALID_MODELS.includes(DEFAULT_MODEL)) { throw new Error( `Invalid default model '${DEFAULT_MODEL}'. Valid models are: ${VALID_MODELS.join(", ")}`, ); } interface SearchResponse { choices: [ { message: { content: string; }; }, ]; } /** * Selects the most appropriate model based on the user's query text * @param query The user's search query * @returns An object containing the selected model and description */ function selectModelBasedOnIntent(query: string): { model: string; description: string; score: number; } { // Convert query to lowercase for case-insensitive matching const lowercaseQuery = query.toLowerCase(); // Calculate scores for each model based on keyword matches const modelScores = Object.entries(MODEL_SELECTION_MAP).map( ([model, criteria]) => { const score = criteria.keywords.reduce((total, keyword) => { // Add 1 to the score for each keyword that appears in the query return total + (lowercaseQuery.includes(keyword.toLowerCase()) ? 1 : 0); }, 0); return { model, score, description: criteria.description, }; }, ); // Sort models by score (descending) modelScores.sort((a, b) => b.score - a.score); // If no keywords matched or all scores are 0, use the default model if (modelScores[0].score === 0) { return { model: DEFAULT_MODEL, description: MODEL_SELECTION_MAP[DEFAULT_MODEL].description, score: 0, }; } // Return the model with the highest score return { model: modelScores[0].model, description: modelScores[0].description, score: modelScores[0].score, }; } // Maintain a list of allowed/blocked domains for search filters interface DomainFilters { allowedDomains: string[]; blockedDomains: string[]; } // Request validation interfaces interface SearchArgs { query: string; } interface DomainArgs { domain: string; action: "allow" | "block"; } interface RecencyArgs { filter: string; } interface ModelArgs { model?: string; } interface RequestParams { name: string; arguments?: Record<string, unknown>; _meta?: unknown; } interface McpRequest { params: RequestParams; method?: string; } const isValidSearchArgs = (args: unknown): args is SearchArgs => typeof args === "object" && args !== null && typeof (args as SearchArgs).query === "string" && (args as SearchArgs).query.trim().length > 0; const isValidDomainArgs = (args: unknown): args is DomainArgs => typeof args === "object" && args !== null && typeof (args as DomainArgs).domain === "string" && (args as DomainArgs).domain.trim().length > 0 && ["allow", "block"].includes((args as DomainArgs).action); const isValidRecencyArgs = (args: unknown): args is RecencyArgs => typeof args === "object" && args !== null && typeof (args as RecencyArgs).filter === "string" && ["hour", "day", "week", "month", "none"].includes( (args as RecencyArgs).filter, ); const isValidModelArgs = (args: unknown): args is ModelArgs => typeof args === "object" && args !== null && ((args as ModelArgs).model === undefined || (typeof (args as ModelArgs).model === "string" && VALID_MODELS.includes((args as ModelArgs).model as string))); class PerplexityServer { private server: Server; private axiosInstance: AxiosInstance; private domainFilters: DomainFilters = { allowedDomains: [], blockedDomains: [], }; private recencyFilter: string | null = null; private currentModel: string = DEFAULT_MODEL; private useAutoSelection = true; constructor() { this.server = new Server( { name: "perplexity-search-server", version: "0.1.0", }, { capabilities: { tools: {}, }, }, ); this.axiosInstance = axios.create({ baseURL: "https://api.perplexity.ai", headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json", }, }); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error("[MCP Error]", error); process.on("SIGINT", async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: "search", description: "Search the web using Perplexity AI", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query", }, }, required: ["query"], }, }, { name: "domain_filter", description: "Add a domain to allow or block in search results (max 3 domains per type)", inputSchema: { type: "object", properties: { domain: { type: "string", description: "Domain name without http:// or https:// (example: wikipedia.org)", }, action: { type: "string", enum: ["allow", "block"], description: "Whether to allow or block this domain", }, }, required: ["domain", "action"], }, }, { name: "recency_filter", description: "Set the time recency for search results", inputSchema: { type: "object", properties: { filter: { type: "string", enum: ["hour", "day", "week", "month", "none"], description: "Time window for search results (none to disable filtering)", }, }, required: ["filter"], }, }, { name: "clear_filters", description: "Clear all domain filters", inputSchema: { type: "object", properties: {}, }, }, { name: "list_filters", description: "List all current domain filters", inputSchema: { type: "object", properties: {}, }, }, { name: "model_info", description: "Get information about available models and optionally set a specific model", inputSchema: { type: "object", properties: { model: { type: "string", enum: VALID_MODELS, description: "Optional: Set a specific model instead of using automatic selection", }, }, }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { switch (request.params.name) { case "search": return this.handleSearch(request); case "domain_filter": return this.handleDomainFilter(request); case "recency_filter": return this.handleRecencyFilter(request); case "clear_filters": return this.handleClearFilters(); case "list_filters": return this.handleListFilters(); case "model_info": return this.handleModelInfo(request); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`, ); } }); } private async handleSearch(request: McpRequest) { if (!isValidSearchArgs(request.params.arguments)) { throw new McpError( ErrorCode.InvalidParams, "Invalid search arguments. Query must be a non-empty string.", ); } try { // Build search_domain_filter array based on configured domains const domainFilterArray: string[] = []; // Add allowed domains (up to 3) const allowedDomainsToUse = this.domainFilters.allowedDomains.slice(0, 3); for (const domain of allowedDomainsToUse) { domainFilterArray.push(domain); } // Add blocked domains (up to 3 if no allowed domains) const remainingSlots = 3 - domainFilterArray.length; const blockedDomainsToUse = this.domainFilters.blockedDomains.slice( 0, remainingSlots, ); for (const domain of blockedDomainsToUse) { domainFilterArray.push(`-${domain}`); } // Get the user query const query = (request.params.arguments as SearchArgs).query; // Get model selection based on intent const selection = selectModelBasedOnIntent(query); let description: string; // Determine which model to use if (this.useAutoSelection) { // Auto-select model based on intent this.currentModel = selection.model; description = selection.description; } else { // We're using a manually set model // But check if we have a strong match that should override if (selection.score >= 2 && selection.model !== this.currentModel) { // Strong intent match - override manual selection console.error( `Auto-overriding manual model selection due to strong intent match (score: ${selection.score})`, ); this.currentModel = selection.model; description = `${selection.description} (auto-selected based on query intent)`; } else { // Stick with manually selected model description = MODEL_SELECTION_MAP[this.currentModel].description; } } interface ApiParams { model: string; messages: { role: string; content: string; }[]; search_domain_filter?: string[]; search_recency_filter?: string; } const apiParams: ApiParams = { model: this.currentModel, messages: [ { role: "system", content: "You are a helpful assistant that searches the web for accurate information.", }, { role: "user", content: query, }, ], }; // Only add search_domain_filter if we have domains to filter if (domainFilterArray.length > 0) { apiParams.search_domain_filter = domainFilterArray; } // Add recency filter if set if (this.recencyFilter) { apiParams.search_recency_filter = this.recencyFilter; } const response = (await this.axiosInstance.post( "/chat/completions", apiParams, )) as { data: SearchResponse }; if (response.data.choices && response.data.choices.length > 0) { // Include model information at the start of the response const modelInfo = `[Using model: ${this.currentModel} - ${description}]\n\n`; return { content: [ { type: "text", text: modelInfo + response.data.choices[0].message.content, }, ], }; } throw new Error("No response content received"); } catch (error) { if (axios.isAxiosError(error)) { const errorMessage = error.response?.data?.error?.message || error.response?.data?.detail || error.message; console.error( "Full error:", JSON.stringify(error.response?.data, null, 2), ); return { content: [ { type: "text", text: `Perplexity API error: ${errorMessage}`, }, ], isError: true, }; } throw error; } } private handleDomainFilter(request: McpRequest) { if (!isValidDomainArgs(request.params.arguments)) { throw new McpError( ErrorCode.InvalidParams, 'Invalid domain filter arguments. Domain must be a non-empty string and action must be "allow" or "block".', ); } const { domain, action } = request.params.arguments as DomainArgs; // Clean the domain input - remove any protocol prefixes const cleanDomain = domain .replace(/^https?:\/\//, "") .split("/")[0] .trim(); if (action === "allow") { // Remove from blocked if it exists there this.domainFilters.blockedDomains = this.domainFilters.blockedDomains.filter((d) => d !== cleanDomain); // Add to allowed if not already there if (!this.domainFilters.allowedDomains.includes(cleanDomain)) { this.domainFilters.allowedDomains.push(cleanDomain); } return { content: [ { type: "text", text: `Added ${cleanDomain} to allowed domains. Search results will prioritize this domain.`, }, ], }; } // Remove from allowed if it exists there this.domainFilters.allowedDomains = this.domainFilters.allowedDomains.filter((d) => d !== cleanDomain); // Add to blocked if not already there if (!this.domainFilters.blockedDomains.includes(cleanDomain)) { this.domainFilters.blockedDomains.push(cleanDomain); } return { content: [ { type: "text", text: `Added ${cleanDomain} to blocked domains. Search results will exclude this domain.`, }, ], }; } private handleRecencyFilter(request: McpRequest) { if (!isValidRecencyArgs(request.params.arguments)) { throw new McpError( ErrorCode.InvalidParams, "Invalid recency filter argument. Filter must be one of: hour, day, week, month, none.", ); } const { filter } = request.params.arguments as RecencyArgs; if (filter === "none") { this.recencyFilter = null; return { content: [ { type: "text", text: "Recency filter has been disabled. Searches will include results from any time period.", }, ], }; } this.recencyFilter = filter; return { content: [ { type: "text", text: `Recency filter set to "${filter}". Searches will be limited to content from the last ${ filter === "hour" ? "hour" : filter === "day" ? "24 hours" : filter === "week" ? "7 days" : "30 days" }.`, }, ], }; } private handleClearFilters() { this.domainFilters = { allowedDomains: [], blockedDomains: [], }; this.recencyFilter = null; return { content: [ { type: "text", text: "All domain and recency filters have been cleared. Searches will use default Perplexity sources.", }, ], }; } private handleListFilters() { const allowedText = this.domainFilters.allowedDomains.length > 0 ? `Allowed domains: ${this.domainFilters.allowedDomains.join(", ")}` : "No allowed domains configured."; const blockedText = this.domainFilters.blockedDomains.length > 0 ? `Blocked domains: ${this.domainFilters.blockedDomains.join(", ")}` : "No blocked domains configured."; const recencyText = this.recencyFilter ? `Recency filter: ${this.recencyFilter} (limiting to content from the last ${ this.recencyFilter === "hour" ? "hour" : this.recencyFilter === "day" ? "24 hours" : this.recencyFilter === "week" ? "7 days" : "30 days" })` : "No recency filter configured."; const limitText = "Note: Perplexity API supports up to 3 domains total with priority given to allowed domains."; return { content: [ { type: "text", text: `Current filters:\n\n${allowedText}\n${blockedText}\n${recencyText}\n\n${limitText}`, }, ], }; } private handleModelInfo(request: McpRequest) { if (!isValidModelArgs(request.params.arguments)) { throw new McpError( ErrorCode.InvalidParams, "Invalid model argument. Model must be one of the valid models or not provided.", ); } // If a specific model is requested, set it and disable auto-selection const { model } = request.params.arguments as ModelArgs; if (model) { this.currentModel = model; this.useAutoSelection = false; } else { // If no model specified, reset to default and re-enable auto-selection this.currentModel = DEFAULT_MODEL; this.useAutoSelection = true; } // Build model information text const modelInfoLines: string[] = []; // Current model status if (model) { modelInfoLines.push(`Model has been set to: ${this.currentModel}`); modelInfoLines.push( "Auto-selection: DISABLED (will only switch if query has strong intent matching)", ); modelInfoLines.push( "To re-enable auto-selection, run model_info with no parameters", ); modelInfoLines.push(""); } else { modelInfoLines.push( `Current model: ${this.currentModel} (reset to default)`, ); modelInfoLines.push(`Default model (from environment): ${DEFAULT_MODEL}`); modelInfoLines.push( "Auto-selection: ENABLED (model will be selected based on query keywords)", ); modelInfoLines.push(""); } // List available models with descriptions modelInfoLines.push("Available models:"); for (const [modelName, criteria] of Object.entries(MODEL_SELECTION_MAP)) { modelInfoLines.push(`- ${modelName}: ${criteria.description}`); modelInfoLines.push(` Keywords: ${criteria.keywords.join(", ")}`); } // Instructions modelInfoLines.push(""); modelInfoLines.push( "To use automatic model selection: Call this tool with no parameters", ); modelInfoLines.push( 'To set a specific model: Use this tool with the "model" parameter', ); return { content: [ { type: "text", text: modelInfoLines.join("\n"), }, ], }; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error( `Perplexity Search MCP server running on stdio (default model: ${DEFAULT_MODEL})`, ); console.error("Automatic model selection enabled based on query intent"); } } const server = new PerplexityServer(); server.run().catch(console.error);