Created
December 13, 2024 06:27
-
-
Save ewired/90e2a0af9f83174afb1c6db5b99d1ee7 to your computer and use it in GitHub Desktop.
SearXNG MCP server
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env -S deno run --allow-net | |
/* | |
{ | |
"mcpServers": { | |
"searxng": { | |
"command": "/path/to/deno", | |
"args": [ | |
"run", | |
"--allow-net", | |
"/home/<YOUR USERNAME>/Documents/Cline/MCP/searxng.ts", | |
"searx.foss.family,searx.perennialte.ch,search.mdosch.de,etsi.me", | |
"engines=google" | |
] | |
} | |
} | |
} | |
*/ | |
import { Server } from "npm:@modelcontextprotocol/sdk/server/index.js"; | |
import { StdioServerTransport } from "npm:@modelcontextprotocol/sdk/server/stdio.js"; | |
import { | |
CallToolRequestSchema, | |
ErrorCode, | |
ListToolsRequestSchema, | |
McpError, | |
} from "npm:@modelcontextprotocol/sdk/types.js"; | |
interface SearchArgs { | |
query: string; | |
} | |
interface SearchResult { | |
url: string; | |
title: string; | |
content: string; | |
engine: string; | |
} | |
const isValidSearchArgs = (args: unknown): args is SearchArgs => { | |
if (typeof args !== "object" || args === null) return false; | |
const { query } = args as SearchArgs; | |
return typeof query === "string"; | |
}; | |
// Fisher-Yates shuffle | |
function shuffle<T>(array: T[]): T[] { | |
const result = [...array]; | |
for (let i = result.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[result[i], result[j]] = [result[j], result[i]]; | |
} | |
return result; | |
} | |
function formatResults(results: SearchResult[]): string { | |
return results.map((result, index) => { | |
return [ | |
`Result ${index + 1}:`, | |
`Title: ${result.title}`, | |
`URL: ${result.url}`, | |
`Engine: ${result.engine}`, | |
`Summary: ${result.content}`, | |
"", | |
].join("\n"); | |
}).join("\n"); | |
} | |
class SearxNGServer { | |
private server: Server; | |
private instances: string[]; | |
private searchParams: URLSearchParams; | |
constructor(domains: string[], searchParams: string[]) { | |
if (domains.length === 0) { | |
throw new Error("At least one SearxNG instance must be provided"); | |
} | |
this.instances = domains.map((domain) => `https://${domain}`); | |
// Parse additional search parameters | |
this.searchParams = new URLSearchParams(); | |
for (const param of searchParams) { | |
const [key, value] = param.split("="); | |
if (key && value) { | |
this.searchParams.append(key, value); | |
} | |
} | |
this.server = new Server( | |
{ | |
name: "searxng-server", | |
version: "0.1.0", | |
}, | |
{ | |
capabilities: { | |
tools: {}, | |
}, | |
}, | |
); | |
this.setupToolHandlers(); | |
this.server.onerror = (error) => console.error("[MCP Error]", error); | |
// Handle graceful shutdown | |
const shutdown = async () => { | |
await this.server.close(); | |
Deno.exit(0); | |
}; | |
Deno.addSignalListener("SIGINT", shutdown); | |
Deno.addSignalListener("SIGTERM", shutdown); | |
} | |
private setupToolHandlers() { | |
this.server.setRequestHandler( | |
ListToolsRequestSchema, | |
() => | |
Promise.resolve({ | |
tools: [ | |
{ | |
name: "search", | |
description: "Search the public web", | |
inputSchema: { | |
type: "object", | |
properties: { | |
query: { | |
type: "string", | |
description: "Search query", | |
}, | |
}, | |
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", | |
); | |
} | |
const { query } = request.params.arguments; | |
try { | |
// Randomize instance order for each query | |
const shuffledInstances = shuffle(this.instances); | |
// Try each instance until one succeeds | |
for (const instance of shuffledInstances) { | |
try { | |
const url = new URL("/search", instance); | |
// Set required parameters | |
url.searchParams.set("q", query); | |
url.searchParams.set("format", "json"); | |
// Add any additional search parameters | |
for (const [key, value] of this.searchParams) { | |
url.searchParams.set(key, value); | |
} | |
const response = await fetch(url); | |
if (!response.ok) { | |
console.error( | |
`Instance ${instance} failed with status ${response.status}`, | |
); | |
continue; | |
} | |
const data = await response.json(); | |
if (!data.results || !Array.isArray(data.results)) { | |
console.error(`Instance ${instance} returned invalid results`); | |
continue; | |
} | |
const formattedResults = formatResults(data.results); | |
return { | |
content: [ | |
{ | |
type: "text", | |
text: formattedResults, | |
}, | |
], | |
}; | |
} catch (error) { | |
console.error(`Instance ${instance} failed:`, error); | |
continue; | |
} | |
} | |
throw new Error("All instances failed"); | |
} catch (error) { | |
const message = error instanceof Error ? error.message : error; | |
return { | |
content: [ | |
{ | |
type: "text", | |
text: `Search failed: ${message}`, | |
}, | |
], | |
isError: true, | |
}; | |
} | |
}); | |
} | |
async run() { | |
const transport = new StdioServerTransport(); | |
await this.server.connect(transport); | |
console.error("SearxNG MCP server running on stdio"); | |
} | |
} | |
// Parse arguments | |
const [domainsArg, ...searchParams] = Deno.args; | |
const domains = domainsArg?.split(",") ?? []; | |
const server = new SearxNGServer(domains, searchParams); | |
server.run().catch(console.error); | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment