mcp-searxng
Version:
MCP server for SearXNG integration
312 lines (311 loc) • 14 kB
JavaScript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema, SetLevelRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
// Import modularized functionality
import { WEB_SEARCH_TOOL, SUGGESTIONS_TOOL, INSTANCE_INFO_TOOL, READ_URL_TOOL, LITE_WEB_SEARCH_TOOL, LITE_SUGGESTIONS_TOOL, LITE_INSTANCE_INFO_TOOL, LITE_READ_URL_TOOL, isSearXNGWebSearchArgs, isSearXNGSearchSuggestionsArgs, isSearXNGInstanceInfoArgs, } from "./types.js";
import { logMessage, setLogLevel, getCurrentLogLevel } from "./logging.js";
import { performWebSearch } from "./search.js";
import { performSearchSuggestions } from "./suggestions.js";
import { fetchInstanceInfo } from "./instance-info.js";
import { fetchAndConvertToMarkdown } from "./url-reader.js";
import { createConfigResource, createHelpResource } from "./resources.js";
import { createHttpServer, resolveBindHost } from "./http-server.js";
import { getSearxngInstances, redactSearxngInstanceUrl, } from "./searxng-instances.js";
import { initializeDiagnosticSanitizer, sanitizeErrorForTransport, } from "./diagnostic-sanitizer.js";
import { writeDiagnostic } from "./diagnostic-output.js";
import { parseStrictInteger } from "./env-int.js";
import { validateBrowserSolverEnvironment } from "./browser-solver-config.js";
import { packageVersion } from "./version.js";
// Type guard for URL reading args
export function isWebUrlReadArgs(args) {
if (typeof args !== "object" ||
args === null ||
!("url" in args) ||
typeof args.url !== "string") {
return false;
}
const urlArgs = args;
// Convert empty strings to undefined for optional string parameters
if (urlArgs.section === "")
urlArgs.section = undefined;
if (urlArgs.paragraphRange === "")
urlArgs.paragraphRange = undefined;
// Validate optional parameters
if (urlArgs.startChar !== undefined && (typeof urlArgs.startChar !== "number" || urlArgs.startChar < 0)) {
return false;
}
if (urlArgs.maxLength !== undefined && (typeof urlArgs.maxLength !== "number" || urlArgs.maxLength < 1)) {
return false;
}
if (urlArgs.section !== undefined && typeof urlArgs.section !== "string") {
return false;
}
if (urlArgs.paragraphRange !== undefined && typeof urlArgs.paragraphRange !== "string") {
return false;
}
if (urlArgs.readHeadings !== undefined && typeof urlArgs.readHeadings !== "boolean") {
return false;
}
return true;
}
function getFetchTimeoutMs(mcpServer) {
const rawValue = process.env.FETCH_TIMEOUT_MS;
if (rawValue === undefined || rawValue.trim() === "") {
return 10000;
}
const parsed = parseStrictInteger(rawValue);
if (parsed === undefined || parsed <= 0) {
logMessage(mcpServer, "warning", `Ignoring invalid FETCH_TIMEOUT_MS="${rawValue}". Expected a positive integer. Using default 10000.`);
return 10000;
}
return parsed;
}
function getDefaultUrlReadMaxChars(mcpServer) {
const rawValue = process.env.URL_READ_MAX_CHARS;
if (rawValue === undefined || rawValue.trim() === "") {
return undefined;
}
const parsed = parseStrictInteger(rawValue);
if (parsed === undefined || parsed <= 0) {
logMessage(mcpServer, "warning", `Ignoring invalid URL_READ_MAX_CHARS="${rawValue}". Expected a positive integer.`);
return undefined;
}
return parsed;
}
/**
* Creates and configures a new McpServer with all handlers registered.
* Called once per HTTP session, or once for STDIO mode.
*/
export function createMcpServer() {
const mcpServer = new McpServer({
name: "ihor-sokoliuk/mcp-searxng",
version: packageVersion,
}, {
capabilities: {
logging: {},
resources: {},
tools: {},
},
});
const server = mcpServer.server;
const useLiteTools = process.env.SEARXNG_LITE_TOOLS === "true";
const searchTool = useLiteTools ? LITE_WEB_SEARCH_TOOL : WEB_SEARCH_TOOL;
const suggestionsTool = useLiteTools ? LITE_SUGGESTIONS_TOOL : SUGGESTIONS_TOOL;
const instanceInfoTool = useLiteTools ? LITE_INSTANCE_INFO_TOOL : INSTANCE_INFO_TOOL;
const readUrlTool = useLiteTools ? LITE_READ_URL_TOOL : READ_URL_TOOL;
// List tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
logMessage(mcpServer, "debug", "Handling list_tools request");
return {
tools: [searchTool, suggestionsTool, instanceInfoTool, readUrlTool],
};
});
// Call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const { name, arguments: args } = request.params;
logMessage(mcpServer, "debug", `Handling call_tool request: ${name}`);
try {
if (name === "searxng_web_search") {
if (!isSearXNGWebSearchArgs(args)) {
throw new Error("Invalid arguments for web search");
}
const result = await performWebSearch(mcpServer, args.query, args.pageno, args.time_range, args.language, args.safesearch === undefined ? undefined : Number(args.safesearch), args.min_score, args.num_results, args.categories, args.engines, args.response_format);
return {
content: [
{
type: "text",
text: result,
},
],
};
}
else if (name === "searxng_search_suggestions") {
if (!isSearXNGSearchSuggestionsArgs(args)) {
throw new Error("Invalid arguments for search suggestions");
}
const suggestions = await performSearchSuggestions(mcpServer, args.query, args.language);
return {
content: [
{
type: "text",
text: JSON.stringify({ query: args.query, suggestions }, null, 2),
},
],
};
}
else if (name === "searxng_instance_info") {
if (!isSearXNGInstanceInfoArgs(args)) {
throw new Error("Invalid arguments for instance info");
}
const result = await fetchInstanceInfo(mcpServer, args.includeEngines, args.includeDisabled, args.category, args.refresh);
return {
content: [
{
type: "text",
text: result,
},
],
};
}
else if (name === "web_url_read") {
if (!isWebUrlReadArgs(args)) {
throw new Error("Invalid arguments for URL reading");
}
const defaultMaxLength = getDefaultUrlReadMaxChars(mcpServer);
const paginationOptions = {
startChar: args.startChar,
maxLength: args.maxLength ?? defaultMaxLength,
section: args.section,
paragraphRange: args.paragraphRange,
readHeadings: args.readHeadings,
};
const result = await fetchAndConvertToMarkdown(mcpServer, args.url, getFetchTimeoutMs(mcpServer), paginationOptions, extra.signal);
return {
content: [
{
type: "text",
text: result,
},
],
};
}
else {
throw new Error(`Unknown tool: ${name}`);
}
}
catch (error) {
const safeError = sanitizeErrorForTransport(error);
logMessage(mcpServer, "error", `Tool execution error: ${safeError.message}`, {
tool: name,
args: args,
error: safeError.stack,
});
throw safeError;
}
});
// Logging level handler
server.setRequestHandler(SetLevelRequestSchema, async (request) => {
const { level } = request.params;
logMessage(mcpServer, "info", `Setting log level to: ${level}`);
setLogLevel(mcpServer, level);
return {};
});
// List resources handler
server.setRequestHandler(ListResourcesRequestSchema, async () => {
logMessage(mcpServer, "debug", "Handling list_resources request");
return {
resources: [
{
uri: "config://server-config",
mimeType: "application/json",
name: "Server Configuration",
description: "Current server configuration and environment variables"
},
{
uri: "help://usage-guide",
mimeType: "text/markdown",
name: "Usage Guide",
description: "How to use the MCP SearXNG server effectively"
}
]
};
});
// List resource templates handler
// Returns empty list — required by MCP spec even when no templates exist
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
logMessage(mcpServer, "debug", "Handling list_resource_templates request");
return { resourceTemplates: [] };
});
// Read resource handler
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const { uri } = request.params;
logMessage(mcpServer, "debug", `Handling read_resource request for: ${uri}`);
switch (uri) {
case "config://server-config":
return {
contents: [
{
uri: uri,
mimeType: "application/json",
text: createConfigResource(mcpServer)
}
]
};
case "help://usage-guide":
return {
contents: [
{
uri: uri,
mimeType: "text/markdown",
text: createHelpResource()
}
]
};
default:
throw sanitizeErrorForTransport(new Error(`Unknown resource: ${uri}`));
}
});
return mcpServer;
}
// Main function
export async function main() {
initializeDiagnosticSanitizer();
const browserSolverIssue = validateBrowserSolverEnvironment();
if (browserSolverIssue) {
throw new Error(browserSolverIssue);
}
// Check for HTTP transport mode
const httpPort = process.env.MCP_HTTP_PORT;
if (httpPort) {
const port = parseStrictInteger(httpPort);
if (port === undefined || port < 1 || port > 65535) {
writeDiagnostic("error", `Invalid HTTP port: ${httpPort}. Must be between 1-65535.`);
process.exit(1);
}
const host = resolveBindHost(process.env.MCP_HTTP_HOST);
writeDiagnostic("log", `Starting HTTP transport on ${host}:${port}`);
const app = await createHttpServer(createMcpServer, port);
const httpServer = app.listen(port, host, () => {
writeDiagnostic("log", `HTTP server listening on ${host}:${port}`);
// Health/MCP URLs shown as localhost for developer convenience
writeDiagnostic("log", `Health check: http://localhost:${port}/health`);
writeDiagnostic("log", `MCP endpoint: http://localhost:${port}/mcp`);
});
// Handle graceful shutdown
const shutdown = (signal) => {
writeDiagnostic("log", `Received ${signal}. Shutting down HTTP server...`);
httpServer.close(() => {
writeDiagnostic("log", "HTTP server closed");
process.exit(0);
});
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
else {
// Default STDIO transport — single session, single server
const mcpServer = createMcpServer();
// Show helpful message when running in terminal
if (process.stdin.isTTY) {
writeDiagnostic("error", `🔍 MCP SearXNG Server v${packageVersion} - Ready`);
const searxngInstances = getSearxngInstances();
if (searxngInstances.length > 0) {
writeDiagnostic("error", `🌐 SearXNG URLs: ${searxngInstances.map(redactSearxngInstanceUrl).join("; ")}`);
}
else {
writeDiagnostic("error", "⚠️ SEARXNG_URL not set — configure it before using search tools");
}
writeDiagnostic("error", "📡 Waiting for MCP client connection via STDIO...\n");
}
const transport = new StdioServerTransport();
await mcpServer.connect(transport);
// Log after connection is established
logMessage(mcpServer, "info", `MCP SearXNG Server v${packageVersion} connected via STDIO`);
logMessage(mcpServer, "info", `Log level: ${getCurrentLogLevel(mcpServer)}`);
logMessage(mcpServer, "info", `Environment: ${process.env.NODE_ENV || 'development'}`);
const searxngInstances = getSearxngInstances();
logMessage(mcpServer, "info", `SearXNG URLs: ${searxngInstances.length > 0
? searxngInstances.map(redactSearxngInstanceUrl).join("; ")
: "not configured"}`);
}
}