#!/usr/bin/env python3 """MCP server (stdio) exposing one tool, web_search, backed by a SearXNG instance. Standard library only. Set SEARXNG_URL to override the instance. """ import json import os import sys import urllib.error import urllib.parse import urllib.request SEARXNG_URL = os.environ.get("SEARXNG_URL", "https://search.rcjohnstone.com").rstrip("/") MAX_RESULTS = 10 TIMEOUT_S = 20 KNOWN_VERSIONS = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"] TOOL = { "name": "web_search", "description": ( "Search the web through Connor's self-hosted SearXNG. Returns up to " f"{MAX_RESULTS} results with title, URL and snippet. Use webfetch on a " "result URL to read the page." ), "inputSchema": { "type": "object", "properties": { "query": {"type": "string", "description": "Search terms."}, "page": {"type": "integer", "minimum": 1, "default": 1}, "time_range": { "type": "string", "enum": ["day", "month", "year"], "description": "Only results from this period.", }, "category": { "type": "string", "enum": ["general", "it", "news", "science"], "default": "general", }, }, "required": ["query"], }, } def search(args): query = str(args.get("query", "")).strip() if not query: raise ValueError("query is empty") params = { "q": query, "format": "json", "pageno": int(args.get("page", 1)), "categories": args.get("category", "general"), } if args.get("time_range"): params["time_range"] = args["time_range"] url = f"{SEARXNG_URL}/search?{urllib.parse.urlencode(params)}" req = urllib.request.Request(url, headers={"Accept": "application/json"}) with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp: data = json.load(resp) lines = [] for answer in data.get("answers", [])[:2]: text = answer.get("answer") if isinstance(answer, dict) else answer if text: lines.append(f"Answer: {text}\n") for i, r in enumerate(data.get("results", [])[:MAX_RESULTS], 1): snippet = " ".join((r.get("content") or "").split()) lines.append(f"{i}. {r.get('title', '').strip()}\n {r.get('url', '')}\n {snippet}") if not lines: lines.append("No results.") if data.get("suggestions"): lines.append("Suggestions: " + ", ".join(data["suggestions"][:5])) return "\n".join(lines) def handle(msg): method = msg.get("method") if method == "initialize": requested = msg.get("params", {}).get("protocolVersion") version = requested if requested in KNOWN_VERSIONS else KNOWN_VERSIONS[-2] return { "protocolVersion": version, "capabilities": {"tools": {}}, "serverInfo": {"name": "searxng", "version": "1.0.0"}, } if method == "ping": return {} if method == "tools/list": return {"tools": [TOOL]} if method == "tools/call": params = msg.get("params", {}) if params.get("name") != TOOL["name"]: raise LookupError(f"unknown tool: {params.get('name')}") try: text, is_error = search(params.get("arguments") or {}), False except (urllib.error.URLError, TimeoutError, ValueError, json.JSONDecodeError) as e: text, is_error = f"Search failed: {e}", True return {"content": [{"type": "text", "text": text}], "isError": is_error} raise LookupError(f"method not found: {method}") def main(): for line in sys.stdin: line = line.strip() if not line: continue try: msg = json.loads(line) except json.JSONDecodeError: continue if "id" not in msg: continue reply = {"jsonrpc": "2.0", "id": msg["id"]} try: reply["result"] = handle(msg) except LookupError as e: reply["error"] = {"code": -32601, "message": str(e)} except Exception as e: reply["error"] = {"code": -32603, "message": str(e)} sys.stdout.write(json.dumps(reply) + "\n") sys.stdout.flush() if __name__ == "__main__": main()