SearXNG MCP Server

by tisDDM
Verified
#!/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, Tool } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import dotenv from "dotenv"; import { parse } from "yaml"; dotenv.config(); // Get environment variables for SearXNG configuration const SEARXNG_URL = process.env.SEARXNG_URL; const SEARXNG_USERNAME = process.env.SEARXNG_USERNAME; const SEARXNG_PASSWORD = process.env.SEARXNG_PASSWORD; const USE_RANDOM_INSTANCE = process.env.USE_RANDOM_INSTANCE !== "false"; // Default to true if not set // URL for the list of SearXNG instances const INSTANCES_LIST_URL = "https://raw.githubusercontent.com/searxng/searx-instances/refs/heads/master/searxinstances/instances.yml"; // Function to fetch and select a random SearXNG instance async function getRandomSearXNGInstance(): Promise<string> { try { console.error("[SearXNG] Fetching list of SearXNG instances..."); const response = await axios.get(INSTANCES_LIST_URL); const instancesData = parse(response.data); // Debug the structure console.error("[SearXNG] Instances data structure:", Object.keys(instancesData)); // Filter for standard internet instances (not onion or hidden) const standardInstances: string[] = []; // The instances.yml file has a structure where each key is a URL for (const [url, data] of Object.entries(instancesData)) { const instanceData = data as any; // Check if it's a standard instance (not hidden or onion) if ( instanceData && (!instanceData.comments || (!instanceData.comments.includes("hidden") && !instanceData.comments.includes("onion"))) && (!instanceData.network_type || instanceData.network_type === "normal") ) { standardInstances.push(url); } } console.error(`[SearXNG] Found ${standardInstances.length} standard instances`); if (standardInstances.length === 0) { throw new Error("No standard SearXNG instances found"); } // Select a random instance const randomInstance = standardInstances[Math.floor(Math.random() * standardInstances.length)]; console.error(`[SearXNG] Selected random instance: ${randomInstance}`); return randomInstance; } catch (error) { console.error("[SearXNG] Error fetching instances:", error); throw new Error("Failed to fetch SearXNG instances list"); } } // No need to determine the URL here, we'll do it in the run() method // Basic auth credentials are optional const hasBasicAuth = SEARXNG_USERNAME && SEARXNG_PASSWORD; // Interface for SearXNG search parameters interface SearchParams { q: string; language?: string; time_range?: string; categories?: string[]; engines?: string[]; format?: string; safesearch?: number; pageno?: number; } // Interface for SearXNG search result interface SearXNGResult { title: string; url: string; content: string; engine: string; score?: number; category?: string; pretty_url?: string; publishedDate?: string; } // Interface for SearXNG search response interface SearXNGResponse { query: string; number_of_results: number; results: SearXNGResult[]; answers?: string[]; corrections?: string[]; infoboxes?: any[]; suggestions?: string[]; unresponsive_engines?: string[]; } class SearXNGClient { private server: Server; private axiosInstance: any; private instanceUrl: string; constructor(instanceUrl?: string) { this.instanceUrl = instanceUrl || ""; this.server = new Server( { name: "searxngmcp", version: "0.2.0", }, { capabilities: { resources: {}, tools: {}, }, } ); // Initialize without axios instance - will be created during run() this.setupHandlers(); this.setupErrorHandling(); } private setupErrorHandling(): void { this.server.onerror = (error) => { console.error("[MCP Error]", error); }; process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupHandlers(): void { this.setupToolHandlers(); } private setupToolHandlers(): void { this.server.setRequestHandler(ListToolsRequestSchema, async () => { // Define available tools const tools: Tool[] = [ { name: "searxngsearch", description: "Perform web searches using SearXNG, a privacy-respecting metasearch engine. Returns relevant web content with customizable parameters.", inputSchema: { type: "object", properties: { query: { type: "string", description: "Search query" }, language: { type: "string", description: "Language code for search results (e.g., 'en', 'de', 'fr'). Default: 'en'", default: "en" }, time_range: { type: "string", enum: ["day", "week", "month", "year"], description: "Time range for search results. Options: 'day', 'week', 'month', 'year'. Default: null (no time restriction).", default: null }, categories: { type: "array", items: { type: "string" }, description: "Categories to search in (e.g., 'general', 'images', 'news'). Default: null (all categories).", default: null }, engines: { type: "array", items: { type: "string" }, description: "Specific search engines to use. Default: null (all available engines).", default: null }, safesearch: { type: "number", enum: [0, 1, 2], description: "Safe search level: 0 (off), 1 (moderate), 2 (strict). Default: 1 (moderate).", default: 1 }, pageno: { type: "number", description: "Page number for results. Must be minimum 1. Default: 1.", minimum: 1, default: 1 }, max_results: { type: "number", description: "Maximum number of search results to return. Range: 1-50. Default: 10.", default: 10, minimum: 1, maximum: 50 } }, required: ["query"] } } ]; return { tools }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (request.params.name !== "searxngsearch") { throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } const args = request.params.arguments ?? {}; // Validate required parameters if (!args.query || typeof args.query !== 'string') { throw new McpError( ErrorCode.InvalidParams, "Query parameter is required and must be a string" ); } // Prepare search parameters with defaults const searchParams: SearchParams = { q: args.query, format: 'json', language: typeof args.language === 'string' ? args.language : 'en', safesearch: typeof args.safesearch === 'number' ? args.safesearch : 1, pageno: typeof args.pageno === 'number' ? args.pageno : 1, }; // Add optional parameters if provided if (args.time_range && typeof args.time_range === 'string') searchParams.time_range = args.time_range; if (Array.isArray(args.categories)) searchParams.categories = args.categories; if (Array.isArray(args.engines)) searchParams.engines = args.engines; console.error(`[SearXNG] Searching for: ${args.query}`); // Make request to SearXNG const response = await this.axiosInstance.get('/search', { params: searchParams }); const searchResults: SearXNGResponse = response.data; // Limit results if max_results is specified const maxResults = typeof args.max_results === 'number' ? args.max_results : 10; const limitedResults = searchResults.results.slice(0, maxResults); // Construct a new response object with the limited results const finalResponse: SearXNGResponse = { ...searchResults, // Copy other fields like query, answers, suggestions etc. results: limitedResults, // Use the truncated results list number_of_results: limitedResults.length // Update the count to reflect the truncation }; // Return the modified JSON data return { content: [{ type: "text", text: JSON.stringify(finalResponse, null, 2) }] }; } catch (error: any) { console.error("[SearXNG Error]", error); if (axios.isAxiosError(error)) { // Handle authentication errors if (error.response?.status === 401) { return { content: [{ type: "text", text: "Authentication failed. Please check your SearXNG username and password." }], isError: true, }; } return { content: [{ type: "text", text: `SearXNG API error: ${error.response?.data?.message ?? error.message}` }], isError: true, }; } return { content: [{ type: "text", text: `Error: ${error.message}` }], isError: true, }; } }); } private formatResults(query: string, results: SearXNGResult[], fullResponse: SearXNGResponse): string { const output: string[] = []; output.push(`# Search Results for: ${query}`); output.push(`Found ${fullResponse.number_of_results} results\n`); // Add answers if available if (fullResponse.answers && fullResponse.answers.length > 0) { output.push(`## Answers`); fullResponse.answers.forEach(answer => { output.push(`- ${answer}`); }); output.push(''); } // Add suggestions if available if (fullResponse.suggestions && fullResponse.suggestions.length > 0) { output.push(`## Suggestions`); fullResponse.suggestions.forEach(suggestion => { output.push(`- ${suggestion}`); }); output.push(''); } // Add corrections if available if (fullResponse.corrections && fullResponse.corrections.length > 0) { output.push(`## Did you mean?`); fullResponse.corrections.forEach(correction => { output.push(`- ${correction}`); }); output.push(''); } // Format detailed search results output.push('## Results'); results.forEach((result, index) => { output.push(`\n### ${index + 1}. ${result.title}`); output.push(`URL: ${result.url}`); if (result.engine) output.push(`Engine: ${result.engine}`); if (result.category) output.push(`Category: ${result.category}`); if (result.publishedDate) output.push(`Published: ${result.publishedDate}`); output.push(`\n${result.content}`); }); // Add unresponsive engines if any if (fullResponse.unresponsive_engines && fullResponse.unresponsive_engines.length > 0) { output.push('\n## Unresponsive Engines'); output.push(fullResponse.unresponsive_engines.join(', ')); } return output.join('\n'); } async run(): Promise<void> { // Determine which SearXNG instance to use if (!this.instanceUrl) { if (SEARXNG_URL) { // Use the specified URL if provided this.instanceUrl = SEARXNG_URL; console.error(`[SearXNG] Using specified instance: ${this.instanceUrl}`); } else if (USE_RANDOM_INSTANCE) { // Only fetch random instance if no URL is specified and random instances are enabled console.error("[SearXNG] No URL specified, will use a random instance"); try { this.instanceUrl = await getRandomSearXNGInstance(); console.error(`[SearXNG] Using random instance: ${this.instanceUrl}`); } catch (error) { console.error("[SearXNG] Error getting random instance:", error); throw new Error("Failed to get a random SearXNG instance. Please provide SEARXNG_URL or fix the instance fetching issue."); } } else { // If no URL is specified and random instances are disabled, throw an error throw new Error("SEARXNG_URL environment variable is required when USE_RANDOM_INSTANCE is set to false"); } } // Create axios instance with the determined URL and auth if provided this.axiosInstance = axios.create({ baseURL: this.instanceUrl, headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, ...(hasBasicAuth && { auth: { username: SEARXNG_USERNAME!, password: SEARXNG_PASSWORD!, }, }), }); const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("SearXNG MCP server running on stdio"); console.error(`Connected to SearXNG instance at: ${this.instanceUrl}`); console.error(`Basic auth: ${hasBasicAuth ? 'Enabled' : 'Disabled'}`); console.error(`Random instance selection: ${USE_RANDOM_INSTANCE ? 'Enabled' : 'Disabled'}`); } } const server = new SearXNGClient(); server.run().catch(console.error);