UNPKG

@nitish-raj/searxng-mcp-bridge

Version:
258 lines (257 loc) 10.8 kB
#!/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 from 'axios'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; // Get package version const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const packageJsonPath = path.resolve(__dirname, '../package.json'); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); const PACKAGE_VERSION = packageJson.version; const isValidSearchArgs = (args) => { if (typeof args !== 'object' || args === null || typeof args.query !== 'string') { return false; } // Validate optional parameters if (args.language !== undefined && typeof args.language !== 'string') return false; if (args.categories !== undefined && !Array.isArray(args.categories)) return false; if (args.time_range !== undefined && typeof args.time_range !== 'string') return false; if (args.safesearch !== undefined && typeof args.safesearch !== 'number') return false; if (args.format !== undefined && typeof args.format !== 'string') return false; if (args.max_results !== undefined && typeof args.max_results !== 'number') return false; return true; }; // Mandatory: SearxNG instance URL from environment variable const SEARXNG_URL = process.env.SEARXNG_INSTANCE_URL; const DEBUG_MODE = process.env.SEARXNG_BRIDGE_DEBUG === 'true'; if (!SEARXNG_URL) { console.error('[SearxNG Bridge] ERROR: SEARXNG_INSTANCE_URL environment variable is not set. This is required.'); process.exit(1); } else { console.log(`[SearxNG Bridge] Using SearxNG instance URL: ${SEARXNG_URL}`); } class SearxngBridgeServer { server; axiosInstance; cache = new Map(); CACHE_TTL = 5 * 60 * 1000; // 5 minutes cache TTL MAX_RETRIES = 3; RETRY_DELAY = 1000; // 1 second constructor() { this.server = new Server({ name: 'searxng-bridge', version: PACKAGE_VERSION, description: 'MCP Server for SearxNG Bridge', }, { capabilities: { resources: {}, tools: {}, }, }); this.axiosInstance = axios.create({ baseURL: SEARXNG_URL, timeout: 15000, // 15s timeout headers: { // Add a common browser User-Agent to potentially avoid bot detection 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36' } }); this.setupToolHandlers(); this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); setInterval(() => this.cleanCache(), 60 * 1000); // Clean cache every minute } cleanCache() { const now = Date.now(); for (const [key, entry] of this.cache.entries()) { if (now - entry.timestamp > this.CACHE_TTL) { this.cache.delete(key); if (DEBUG_MODE) { console.log(`[SearxNG Bridge] Removed expired cache entry for: ${key}`); } } } } getCacheKey(params) { return JSON.stringify(params); } setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'search', description: 'Perform a search using the configured SearxNG instance', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'The search query string', }, language: { type: 'string', description: 'Language code for search results (e.g., "en-US", "fr", "de")', }, categories: { type: 'array', items: { type: 'string', }, description: 'Categories to search in (e.g., ["general", "images", "news"])', }, time_range: { type: 'string', description: 'Time range for results (e.g., "day", "week", "month", "year")', }, safesearch: { type: 'number', description: 'Safe search level (0: off, 1: moderate, 2: strict)', }, format: { type: 'string', description: 'Result format (default: "json", options: "json", "html")', }, max_results: { type: 'number', description: 'Maximum number of results to return', }, }, required: ['query'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name !== 'search') { throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`); } if (!isValidSearchArgs(request.params.arguments)) { throw new McpError(ErrorCode.InvalidParams, 'Invalid search arguments. Requires a "query" string.'); } const args = request.params.arguments; const searchParams = { q: args.query, format: args.format || 'json', }; if (args.language) searchParams.language = args.language; if (args.categories) searchParams.categories = args.categories.join(','); if (args.time_range) searchParams.time_range = args.time_range; if (args.safesearch !== undefined) searchParams.safesearch = args.safesearch; const cacheKey = this.getCacheKey(searchParams); const cachedResult = this.cache.get(cacheKey); if (cachedResult && Date.now() - cachedResult.timestamp < this.CACHE_TTL) { if (DEBUG_MODE) { console.log(`[SearxNG Bridge] Cache hit for query: ${args.query}`); } let results = cachedResult.data; if (args.max_results && results.results) { results = { ...results, results: results.results.slice(0, args.max_results) }; } return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], }; } // Not cached or expired, perform search return await this.performSearchWithRetry(searchParams, args.max_results, cacheKey); }); } async performSearchWithRetry(searchParams, maxResults, cacheKey) { let lastError; for (let attempt = 1; attempt <= this.MAX_RETRIES; attempt++) { try { if (DEBUG_MODE && attempt > 1) { console.log(`[SearxNG Bridge] Retry attempt ${attempt} for query: ${searchParams.q}`); } const response = await this.axiosInstance.get('/search', { params: searchParams, }); let results = response.data; if (maxResults && results.results) { results = { ...results, results: results.results.slice(0, maxResults) }; } if (cacheKey) { this.cache.set(cacheKey, { timestamp: Date.now(), data: response.data // Cache the full original response }); if (DEBUG_MODE) { console.log(`[SearxNG Bridge] Cached results for query: ${searchParams.q}`); } } return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], }; } catch (error) { lastError = error; if (attempt < this.MAX_RETRIES) { await new Promise(resolve => setTimeout(resolve, this.RETRY_DELAY * attempt)); } } } // All retries failed let errorMessage = `Failed to fetch search results from SearxNG instance at ${SEARXNG_URL} after ${this.MAX_RETRIES} attempts.`; if (axios.isAxiosError(lastError)) { errorMessage = `SearxNG request error (${SEARXNG_URL}): ${lastError.response?.data?.message || lastError.message}`; if (lastError.response?.status) { errorMessage += ` (Status: ${lastError.response.status})`; } } else if (lastError instanceof Error) { errorMessage = `Unexpected error while contacting ${SEARXNG_URL}: ${lastError.message}`; } return { content: [ { type: 'text', text: errorMessage, }, ], isError: true, }; } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error(`SearxNG Bridge MCP server v${PACKAGE_VERSION} running on stdio`); if (DEBUG_MODE) { console.error('[SearxNG Bridge] Debug mode enabled'); } } } const server = new SearxngBridgeServer(); server.run().catch(console.error);