Skip to content

Instantly share code, notes, and snippets.

@lloydchang
Forked from ewired/searxng.ts
Created December 13, 2024 06:50

Revisions

  1. @ewired ewired created this gist Dec 13, 2024.
    232 changes: 232 additions & 0 deletions searxng.ts
    Original file line number Diff line number Diff line change
    @@ -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);