@jharding_npm/mcp-server-searxng
Version:
MCP server for SearXNG meta search integration with enhanced error messaging
455 lines (451 loc) • 19 kB
JavaScript
import fetch from 'node-fetch';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
import { Agent as HttpsAgent } from 'node:https';
import { Agent as HttpAgent } from 'node:http';
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const { version } = require('./package.json');
// Add console error wrapper
function logError(message, error) {
console.error(`Error: ${message}`, error ? `\n${error}` : '');
}
// Add debug logging function that can be enabled via environment variable
const DEBUG = process.env.MCP_SEARXNG_DEBUG === 'true';
function logDebug(message, data) {
if (DEBUG) {
console.error(`Debug: ${message}`, data ? `\n${JSON.stringify(data, null, 2)}` : '');
}
}
// Primary SearXNG instances for fallback
const SEARXNG_INSTANCES = process.env.SEARXNG_INSTANCES
? process.env.SEARXNG_INSTANCES.split(',')
: ['http://localhost:8080'];
// HTTP headers with user agent from env
const USER_AGENT = process.env.SEARXNG_USER_AGENT || 'MCP-SearXNG/1.0';
// Add HTTP/HTTPS agent configuration
const httpsAgent = new HttpsAgent({
rejectUnauthorized: process.env.NODE_TLS_REJECT_UNAUTHORIZED !== '0'
});
const httpAgent = new HttpAgent();
const PARAMETER_HELP = `
# How to get a specific range of results
- To get results 1-10: set offset=0, max_results=10
- To get results 11-20: set offset=10, max_results=10
- To get results 40-43: set offset=39, max_results=4
# Common mistakes
- Do NOT use 'page' for pagination. Use 'offset' and 'max_results'.
- 'offset' is zero-based: offset=0 means start from the first result.
- 'max_results' is the number of results you want to get (not the last result number).
# Typical values
- offset: 0, 10, 20, 39, etc. (zero-based)
- max_results: 1-100 (how many results to return)
- content_length: 50-1000 (max characters per result's content)
# Example
To get results 40-43, use: { "offset": 39, "max_results": 4 }
`;
const WEB_SEARCH_TOOL = {
name: "web_search",
description: "Performs a web search using SearXNG and returns structured JSON results.\n" +
"\n" +
"# IMPORTANT: Pagination is offset-based, NOT page-based.\n" +
"To get a specific range of results, set 'offset' to the zero-based index of the first result you want, and 'max_results' to how many results you want.\n" +
"For example, to get results 40-43, set offset=39 and max_results=4.\n" +
PARAMETER_HELP,
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query (what you want to search for). Example: 'climate change'"
},
max_results: {
type: "number",
description: "Maximum number of results to return. Typical values: 1-100. Example: max_results=10 returns 10 results. To get results 40-43, set max_results=4 and offset=39.",
default: 10,
minimum: 1,
maximum: 100
},
offset: {
type: "number",
description: "Number of results to skip (zero-based). Typical values: 0, 10, 20, 39, etc. Example: offset=39 with max_results=4 returns results 40-43. Do NOT use 'page' for pagination.",
default: 0,
minimum: 0
},
content_length: {
type: "number",
description: "Maximum characters per result content snippet. Typical values: 50-1000. Example: content_length=100 limits each result's content to 100 characters.",
default: 200,
minimum: 50,
maximum: 1000
},
page: {
type: "number",
description: "(Advanced) Page number. Do NOT use for pagination. Use 'offset' and 'max_results' instead.",
default: 1
},
language: {
type: "string",
description: "Search language code (e.g. 'en', 'zh', 'jp', 'all'). Default: 'all'",
default: "all"
},
time_range: {
type: "string",
enum: ["all_time", "day", "week", "month", "year"],
description: "Time period for search results. Must be one of: 'all_time', 'day', 'week', 'month', 'year'.",
default: "all_time"
},
safesearch: {
type: "number",
description: "0: None, 1: Moderate, 2: Strict. Default: 1",
default: 1
}
},
required: ["query"]
}
};
const serverConfig = {
name: "@jharding_npm/mcp-server-searxng",
version,
description: "SearXNG meta search integration for MCP with enhanced error handling and parameter control"
};
const server = new Server(serverConfig, {
capabilities: {
tools: {},
},
});
// Helper function to try different instances
async function searchWithFallback(params) {
if (SEARXNG_INSTANCES.length === 0) {
throw new Error("No SearXNG instances configured. Please set the SEARXNG_INSTANCES environment variable.");
}
logDebug("Search parameters", params);
// Handle offset by converting to page number
let pageNumber = params.page || 1;
if (params.offset && params.offset > 0) {
const resultsPerPage = params.max_results || 10;
pageNumber = Math.floor(params.offset / resultsPerPage) + 1;
}
const searchParams = {
q: params.query,
pageno: pageNumber,
language: params.language || 'all',
time_range: params.time_range === 'all_time' ? '' : (params.time_range || ''),
safesearch: params.safesearch ?? 0,
format: 'json'
};
const errors = [];
for (const instance of SEARXNG_INSTANCES) {
try {
const searchUrl = new URL('/search', instance);
logDebug(`Attempting search with instance: ${instance}`);
const response = await fetch(searchUrl.toString(), {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
'User-Agent': USER_AGENT
},
agent: searchUrl.protocol === 'https:' ? httpsAgent : httpAgent,
body: new URLSearchParams(Object.entries(searchParams).reduce((acc, [key, value]) => {
acc[key] = String(value); // Convert all values to strings for URLSearchParams
return acc;
}, {})).toString()
});
if (!response.ok) {
// Try to get detailed error information from the response
let errorText;
try {
errorText = await response.text();
}
catch {
errorText = 'No response body available';
}
const errorMsg = `${instance} returned HTTP ${response.status} ${response.statusText}. Response: ${errorText.substring(0, 200)}`;
logError(errorMsg);
errors.push(errorMsg);
continue;
}
const data = await response.json();
if (!data.results?.length) {
const errorMsg = `${instance} returned no results`;
logError(errorMsg);
errors.push(errorMsg);
continue;
}
logDebug(`Search successful with ${instance}, found ${data.results.length} results`);
return data;
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorMsg = `Failed to connect to ${instance}: ${errorMessage}`;
logError(errorMsg, error);
errors.push(errorMsg);
continue;
}
}
// Provide detailed error information about all failed attempts
const errorDetails = errors.map((err, i) => ` [${i + 1}] ${err}`).join("\n");
throw new Error(`All SearXNG instances failed. Please ensure SearXNG is running on one of these instances: ${SEARXNG_INSTANCES.join(', ')}\n\nDetails:\n${errorDetails}`);
}
function formatSearchResult(result) {
const parts = [
`Title: ${result.title}`,
`URL: ${result.url}`
];
if (result.content) {
parts.push(`Content: ${result.content}`);
}
if (result.engine) {
parts.push(`Source: ${result.engine}`);
}
return parts.join('\n');
}
function formatStructuredSearchResult(result, contentLength = 200) {
const structuredResult = {
title: result.title || '',
url: result.url || '',
};
if (result.content) {
const content = result.content.toString();
if (content.length > contentLength) {
// Try to truncate at sentence boundaries when possible
const sentences = content.split(/[.!?]+/).filter((s) => s.trim().length > 0).map((s) => s.trim());
let truncated = '';
for (const sentence of sentences) {
if ((truncated + sentence + '. ').length <= contentLength) {
truncated += sentence + '. ';
}
else {
break;
}
}
// If no complete sentences fit, just truncate at character limit
if (truncated.length === 0) {
truncated = content.substring(0, contentLength - 3) + '...';
}
structuredResult.content = truncated.trim();
}
else {
structuredResult.content = content;
}
}
if (result.score !== undefined) {
structuredResult.score = Number(result.score);
}
if (result.category) {
structuredResult.category = result.category;
}
else if (result.engine) {
// Map engine to category if category not provided
structuredResult.category = result.engine;
}
if (result.engine) {
structuredResult.engine = result.engine;
}
if (result.publishedDate || result.published_date) {
structuredResult.publishedDate = result.publishedDate || result.published_date;
}
return structuredResult;
}
function buildStructuredResponse(data, query, params, startTime) {
const endTime = startTime ? Date.now() : undefined;
const timeTaken = startTime && endTime ? (endTime - startTime) / 1000 : undefined;
const contentLength = params.content_length || 200;
const maxResults = params.max_results || 10;
const offset = params.offset || 0;
// Apply content length formatting to each result
let structuredResults = data.results.map((result) => formatStructuredSearchResult(result, contentLength));
// Apply offset and max_results directly
structuredResults = structuredResults.slice(offset, offset + maxResults);
const metadata = {
total_results: data.number_of_results || data.results.length,
query: query,
};
if (timeTaken !== undefined) {
metadata.time_taken = timeTaken;
}
return {
results: structuredResults,
metadata: metadata,
};
}
function isWebSearchArgs(args) {
if (typeof args !== "object" || args === null) {
return { valid: false, error: "Arguments must be an object" };
}
if (!("query" in args)) {
return { valid: false, error: "Missing required parameter: 'query'" };
}
if (typeof args.query !== "string") {
return { valid: false, error: "Parameter 'query' must be a string" };
}
// Add more specific validations for optional parameters
const typedArgs = args;
if (typedArgs.page !== undefined &&
(typeof typedArgs.page !== "number" || isNaN(Number(typedArgs.page)))) {
return { valid: false, error: "Parameter 'page' must be a valid number" };
}
if (typedArgs.language !== undefined && typeof typedArgs.language !== "string") {
return { valid: false, error: "Parameter 'language' must be a string" };
}
if (typedArgs.time_range !== undefined) {
const validTimeRanges = ["all_time", "day", "week", "month", "year"];
if (typeof typedArgs.time_range !== "string" || !validTimeRanges.includes(typedArgs.time_range)) {
return {
valid: false,
error: `Parameter 'time_range' must be one of the exact strings: ${validTimeRanges.join(", ")}. Shorthand formats like '3d' are not supported.`
};
}
}
if (typedArgs.safesearch !== undefined &&
(typeof typedArgs.safesearch !== "number" ||
![0, 1, 2].includes(typedArgs.safesearch))) {
return {
valid: false,
error: "Parameter 'safesearch' must be a number (0: None, 1: Moderate, 2: Strict)"
};
}
if (typedArgs.categories !== undefined) {
if (!Array.isArray(typedArgs.categories)) {
return { valid: false, error: "Parameter 'categories' must be an array" };
}
const validCategories = ["general", "news", "science", "files", "images", "videos", "music", "social media", "it"];
for (const category of typedArgs.categories) {
if (typeof category !== "string" || !validCategories.includes(category)) {
return {
valid: false,
error: `Invalid category: '${category}'. Must be one of: ${validCategories.join(", ")}`
};
}
}
}
if (typedArgs.max_results !== undefined) {
if (typeof typedArgs.max_results !== "number" ||
typedArgs.max_results < 1 ||
typedArgs.max_results > 100) {
return {
valid: false,
error: "Parameter 'max_results' must be a number between 1 and 100"
};
}
}
if (typedArgs.offset !== undefined) {
if (typeof typedArgs.offset !== "number" || typedArgs.offset < 0) {
return {
valid: false,
error: "Parameter 'offset' must be a number >= 0"
};
}
}
if (typedArgs.content_length !== undefined) {
if (typeof typedArgs.content_length !== "number" ||
typedArgs.content_length < 50 ||
typedArgs.content_length > 1000) {
return {
valid: false,
error: "Parameter 'content_length' must be a number between 50 and 1000"
};
}
}
return { valid: true };
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WEB_SEARCH_TOOL]
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
logDebug('Tool request received', { name, args });
if (name !== "web_search") {
const errorMsg = `Invalid tool: expected 'web_search', got '${name}'`;
logError(errorMsg);
return {
content: [{ type: "text", text: errorMsg }],
isError: true,
};
}
if (!args) {
const errorMsg = "Missing arguments for web_search";
logError(errorMsg);
return {
content: [{ type: "text", text: errorMsg }],
isError: true,
};
}
// Validate arguments with improved validation
const validation = isWebSearchArgs(args);
if (!validation.valid) {
const errorMsg = validation.error || "Invalid arguments for web_search";
logError(errorMsg, args);
// For time_range parameter specifically, provide more helpful guidance
if (errorMsg.includes("time_range") && args.time_range) {
const receivedValue = String(args.time_range);
logDebug(`Invalid time_range value received: "${receivedValue}"`, {
received: receivedValue,
validValues: ["all_time", "day", "week", "month", "year"]
});
// Return enhanced error message with examples
return {
content: [{
type: "text",
text: `${errorMsg}\n\nYou provided: "${receivedValue}"\nValid examples: "day" (not "1d"), "week" (not "7d"), "month" (not "30d")`
}],
isError: true,
};
}
return {
content: [{ type: "text", text: errorMsg }],
isError: true,
};
}
const startTime = Date.now();
const results = await searchWithFallback(args);
// Handle structured search response
const structuredResponse = buildStructuredResponse(results, args.query, args, startTime);
logDebug(`Search successful, returning ${structuredResponse.results.length} results`);
return {
content: [{
type: "text",
text: JSON.stringify(structuredResponse, null, 2)
}],
isError: false,
};
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logError('Search failed', error);
// Send detailed error message back to the client
return {
content: [{
type: "text",
text: `Search failed: ${errorMessage}`
}],
isError: true,
};
}
});
// Modified runServer to be optionally runnable
export async function runServer() {
const transport = new StdioServerTransport();
try {
// Log configuration details on startup
console.error("Starting SearXNG MCP Server...");
console.error(`Version: ${serverConfig.version}`);
console.error(`SEARXNG_INSTANCES: ${SEARXNG_INSTANCES.join(", ")}`);
console.error(`TLS Verification: ${process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? 'Disabled' : 'Enabled'}`);
console.error(`Debug Mode: ${DEBUG ? 'Enabled' : 'Disabled'}`);
await server.connect(transport);
console.error("SearXNG Search MCP Server running on stdio");
}
catch (error) {
logError('Fatal error running server', error);
process.exit(1);
}
}
// Always run the server when this file is executed (robust for ESM CLI)
runServer();
export { formatSearchResult, formatStructuredSearchResult, buildStructuredResponse, isWebSearchArgs, searchWithFallback, SEARXNG_INSTANCES };