@jharding_npm/mcp-server-searxng
Version:
MCP server for SearXNG meta search integration with enhanced error messaging
312 lines (311 loc) • 13 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';
// 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 WEB_SEARCH_TOOL = {
name: "web_search",
description: "Performs a web search using SearXNG, ideal for general queries, news, articles and online content. " +
"Supports multiple search categories, languages, time ranges (using exact strings 'day', 'week', 'month', 'year', not shorthand like '3d') and safe search filtering. " +
"Returns relevant results from multiple search engines combined.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query"
},
page: {
type: "number",
description: "Page number (default 1)",
default: 1
},
language: {
type: "string",
description: "Search language code (e.g. 'en', 'zh', 'jp', 'all')",
default: "all"
},
categories: {
type: "array",
items: {
type: "string",
enum: ["general", "news", "science", "files", "images", "videos", "music", "social media", "it"]
},
default: ["general"]
},
time_range: {
type: "string",
enum: ["all_time", "day", "week", "month", "year"],
description: "Time period for search results. Must be one of the exact strings: 'all_time', 'day', 'week', 'month', or 'year'. Shorthand formats like '3d' are not supported.",
default: "all_time"
},
safesearch: {
type: "number",
description: "0: None, 1: Moderate, 2: Strict",
default: 1
}
},
required: ["query"]
}
};
// Server implementation
const server = new Server({
name: "@jharding_npm/mcp-server-searxng",
version: "0.4.1",
description: "SearXNG meta search integration for MCP with enhanced error handling"
}, {
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);
const searchParams = {
q: params.query,
pageno: params.page || 1,
language: params.language || 'all',
categories: params.categories?.join(',') || 'general',
time_range: params.time_range === 'all_time' ? '' : (params.time_range || ''),
safesearch: params.safesearch ?? 1,
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 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(", ")}`
};
}
}
}
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 results = await searchWithFallback(args);
const formattedResults = results.results.map(formatSearchResult).join('\n\n');
logDebug(`Search successful, returning ${results.results.length} results`);
return {
content: [{
type: "text",
text: formattedResults
}],
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,
};
}
});
// 修改 runServer 為可選的運行
export async function runServer() {
const transport = new StdioServerTransport();
try {
// Log configuration details on startup
console.error("Starting SearXNG MCP Server...");
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);
}
}
runServer();
export { formatSearchResult, isWebSearchArgs, searchWithFallback, SEARXNG_INSTANCES };