From 6e4125c707a589c93553b2b02c484fa2297506d9 Mon Sep 17 00:00:00 2001 From: joungmin Date: Sat, 28 Feb 2026 06:38:41 +0900 Subject: [PATCH] feat: searxng MCP server for self-hosted web search --- .env.example | 1 + .gitignore | 5 ++++ requirements.txt | 3 +++ src/__init__.py | 0 src/server.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce41fd4 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +SEARXNG_URL=https://searxng.cloud-handson.com diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..05738aa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.py[cod] +.venv/ +.DS_Store diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b32f357 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +mcp[cli]>=1.0.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/server.py b/src/server.py new file mode 100644 index 0000000..0e1a113 --- /dev/null +++ b/src/server.py @@ -0,0 +1,59 @@ +"""MCP server wrapping the self-hosted SearXNG instance for free web search.""" + +import os +import sys + +_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +from dotenv import load_dotenv + +load_dotenv(os.path.join(_project_root, ".env")) + +import httpx +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP( + name="searxng", + instructions="Web search via self-hosted SearXNG. Use this instead of built-in WebSearch.", +) + +SEARXNG_URL = os.environ.get("SEARXNG_URL", "https://searxng.cloud-handson.com") + + +@mcp.tool() +def web_search(query: str, max_results: int = 10) -> list[dict]: + """Search the web using the self-hosted SearXNG instance. + + Args: + query: The search query string. + max_results: Maximum number of results to return (default 10). + + Returns: + List of result dicts with keys: ``title``, ``url``, ``content`` (snippet). + """ + response = httpx.get( + f"{SEARXNG_URL}/search", + params={"q": query, "format": "json"}, + timeout=15, + ) + response.raise_for_status() + data = response.json() + + return [ + { + "title": r.get("title", ""), + "url": r.get("url", ""), + "content": r.get("content", ""), + } + for r in data.get("results", [])[:max_results] + ] + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main()