|
|
@@ -0,0 +1,232 @@ |
|
|
#!/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); |
|
|
|