mcp-searxng
Version:
MCP server for SearXNG integration
212 lines (211 loc) • 7.59 kB
JavaScript
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 { NodeHtmlMarkdown } from "node-html-markdown";
// Use a static version string that will be updated by the version script
const packageVersion = "0.5.0";
const WEB_SEARCH_TOOL = {
name: "searxng_web_search",
description: "Performs a web search using the SearXNG API, ideal for general queries, news, articles, and online content. " +
"Use this for broad information gathering, recent events, or when you need diverse web sources.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "The search query. This is the main input for the web search",
},
pageno: {
type: "number",
description: "Search page number (starts at 1)",
default: 1,
},
time_range: {
type: "string",
description: "Time range of search (day, month, year)",
enum: ["day", "month", "year"],
},
language: {
type: "string",
description: "Language code for search results (e.g., 'en', 'fr', 'de'). Default is instance-dependent.",
default: "all",
},
safesearch: {
type: "string",
description: "Safe search filter level (0: None, 1: Moderate, 2: Strict)",
enum: ["0", "1", "2"],
default: "0",
},
},
required: ["query"],
},
};
const READ_URL_TOOL = {
name: "web_url_read",
description: "Read the content from an URL. " +
"Use this for further information retrieving to understand the content of each URL.",
inputSchema: {
type: "object",
properties: {
url: {
type: "string",
description: "URL",
},
},
required: ["url"],
},
};
// Server implementation
const server = new Server({
name: "ihor-sokoliuk/mcp-searxng",
version: packageVersion,
}, {
capabilities: {
resources: {},
tools: {
searxng_web_search: {
description: WEB_SEARCH_TOOL.description,
schema: WEB_SEARCH_TOOL.inputSchema,
},
web_url_read: {
description: READ_URL_TOOL.description,
schema: READ_URL_TOOL.inputSchema,
},
},
},
});
function isSearXNGWebSearchArgs(args) {
return (typeof args === "object" &&
args !== null &&
"query" in args &&
typeof args.query === "string");
}
async function performWebSearch(query, pageno = 1, time_range, language = "all", safesearch) {
const searxngUrl = process.env.SEARXNG_URL || "http://localhost:8080";
const url = new URL(`${searxngUrl}/search`);
url.searchParams.set("q", query);
url.searchParams.set("format", "json");
url.searchParams.set("pageno", pageno.toString());
if (time_range !== undefined &&
["day", "month", "year"].includes(time_range)) {
url.searchParams.set("time_range", time_range);
}
if (language && language !== "all") {
url.searchParams.set("language", language);
}
if (safesearch !== undefined && ["0", "1", "2"].includes(safesearch)) {
url.searchParams.set("safesearch", safesearch);
}
// Prepare request options with headers
const requestOptions = {
method: "GET"
};
// Add basic authentication if credentials are provided
const username = process.env.AUTH_USERNAME;
const password = process.env.AUTH_PASSWORD;
if (username && password) {
const base64Auth = Buffer.from(`${username}:${password}`).toString('base64');
requestOptions.headers = {
...requestOptions.headers,
'Authorization': `Basic ${base64Auth}`
};
}
const response = await fetch(url.toString(), requestOptions);
if (!response.ok) {
throw new Error(`SearXNG API error: ${response.status} ${response.statusText}\n${await response.text()}`);
}
const data = (await response.json());
const results = (data.results || []).map((result) => ({
title: result.title || "",
content: result.content || "",
url: result.url || "",
}));
return results
.map((r) => `Title: ${r.title}\nDescription: ${r.content}\nURL: ${r.url}`)
.join("\n\n");
}
async function fetchAndConvertToMarkdown(url, timeoutMs = 10000) {
// Create an AbortController instance
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
// Fetch the URL with the abort signal
const response = await fetch(url, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Failed to fetch the URL: ${response.statusText}`);
}
// Retrieve HTML content
const htmlContent = await response.text();
// Convert HTML to Markdown
const markdownContent = NodeHtmlMarkdown.translate(htmlContent);
return markdownContent;
}
catch (error) {
if (error.name === "AbortError") {
throw new Error(`Request timed out after ${timeoutMs}ms`);
}
console.error("Error:", error.message);
throw error;
}
finally {
// Clean up the timeout to prevent memory leaks
clearTimeout(timeoutId);
}
}
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [WEB_SEARCH_TOOL, READ_URL_TOOL],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
if (!args) {
throw new Error("No arguments provided");
}
if (name === "searxng_web_search") {
if (!isSearXNGWebSearchArgs(args)) {
throw new Error("Invalid arguments for searxng_web_search");
}
const { query, pageno = 1, time_range, language = "all", safesearch, } = args;
const results = await performWebSearch(query, pageno, time_range, language, safesearch);
return {
content: [{ type: "text", text: results }],
isError: false,
};
}
if (name === "web_url_read") {
const { url } = args;
const result = await fetchAndConvertToMarkdown(url);
return {
content: [{ type: "text", text: result }],
isError: false,
};
}
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
};
}
catch (error) {
return {
content: [
{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
});
async function runServer() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
runServer().catch((error) => {
console.error("Fatal error running server:", error);
process.exit(1);
});