#
tokens: 3461/50000 14/14 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .dockerignore
├── .github
│   └── FUNDING.yml
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── pyproject.toml
├── README.md
├── smithery.yaml
├── src
│   └── mcp_searxng
│       ├── __init__.py
│       ├── main.py
│       ├── prompts.py
│       ├── search.py
│       ├── server.py
│       └── tools.py
└── uv.lock
```

# Files

--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------

```
3.12

```

--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------

```
.github
.gitignore
.git
.vscode
__pycache__
.venv
.ruff_cache
.mypy_cache
.mcp-searxng.egg-info
dist
config.json
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/

# Translations
*.mo
*.pot

# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal

# Flask stuff:
instance/
.webassets-cache

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
.pybuilder/
target/

# Jupyter Notebook
.ipynb_checkpoints

# IPython
profile_default/
ipython_config.py

# pyenv
#   For a library or package, you might want to ignore these files since the code is
#   intended to run in multiple environments; otherwise, check them in:
# .python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# poetry
#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
#   This is especially recommended for binary packages to ensure reproducibility, and is more
#   commonly ignored for libraries.
#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock

# pdm
#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
#   in version control.
#   https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/

# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/

# Celery stuff
celerybeat-schedule
celerybeat.pid

# SageMath parsed files
*.sage.py

# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

# pytype static type analyzer
.pytype/

# Cython debug symbols
cython_debug/

# PyCharm
#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
#  and can be added to the global gitignore or merged into this file.  For a more nuclear
#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# MCP-searxng

An MCP server for connecting agentic systems to search systems via [searXNG](https://docs.searxng.org/).

<p align="center">
  <a href="https://glama.ai/mcp/servers/sl2zl8vaz8">
    <img width="380" height="200" src="https://glama.ai/mcp/servers/sl2zl8vaz8/badge" alt="MCP SearxNG Badge"/>
  </a>
</p>

## Tools

Search the web with SearXNG

## Prompts

```python
search(query: str) -> f"Searching for {query} using searXNG"
```

## Usage

### via uvx

1) configure your client JSON like

```json
{
  "mcpServers": {
    "searxng": {
      "command": "uvx", 
      "args": [
        "mcp-searxng"
      ]
    }
  }
}
```

### via git clone

1) Add the server to claude desktop (the entrypoint is main.py)

Clone the repo and add this JSON to claude desktop

you can run this server with `uvx mcp-searxng`, or use a local copy of the repo

```json
{
  "mcpServers": {
    "searxng": {
      "command": "uv", 
      "args": [
        "--project",
        "/absoloute/path/to/MCP-searxng/",
        "run",
        "/absoloute/path/to/MCP-searxng/mcp-searxng/main.py"
      ]
    }
  }
}
```

you will need to change the paths to match your environment

### Custom SearXNG URL

2) set the environment variable `SEARXNG_URL` to the URL of the searxng server (default is `http://localhost:8080`)

3) run your MCP client and you should be able to search the web with searxng

Note: if you are using claude desktop make sure to kill the process (task manager or equivalent) before running the server again

```

--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------

```yaml
github: SecretiveShell
ko_fi: secretiveshell

```

--------------------------------------------------------------------------------
/src/mcp_searxng/server.py:
--------------------------------------------------------------------------------

```python
from mcp.server import Server

__all__ = ["server"]

server = Server("searxng")

```

--------------------------------------------------------------------------------
/src/mcp_searxng/__init__.py:
--------------------------------------------------------------------------------

```python
import asyncio
from mcp_searxng.main import run

def main() -> None:
    asyncio.run(run())
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
FROM python:3.12-slim-bullseye

RUN pip install uv

COPY . .

RUN uv venv --python 3.12
RUN uv sync

ENTRYPOINT [ "uv", "run", "mcp-searxng/main.py" ]
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[project]
name = "mcp-searxng"
version = "0.1.0"
description = "MCP server for connecting agentic systems to search systems via searXNG"
readme = "README.md"
authors = [
    { name = "TerminalMan", email = "[email protected]" }
]
requires-python = ">=3.12"
dependencies = [
    "httpx>=0.28.1",
    "mcp>=1.1.2",
    "pydantic>=2.10.3",
]

[project.scripts]
mcp-searxng = "mcp_searxng:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
# Smithery configuration file: https://smithery.ai/docs/deployments

build:
  dockerBuildPath: .
startCommand:
  type: stdio
  configSchema:
    # JSON Schema defining the configuration options for the MCP.
    type: object
    required:
      - searxngUrl
    properties:
      searxngUrl:
        type: string
        description: The URL of the searxng server.
  commandFunction:
    # A function that produces the CLI command to start the MCP on stdio.
    |-
    (config) => ({command:'uv',args:['--project', '.', 'run', 'mcp-searxng/main.py'], env:{SEARXNG_URL: config.searxngUrl}})
```

--------------------------------------------------------------------------------
/src/mcp_searxng/main.py:
--------------------------------------------------------------------------------

```python
from mcp.server import NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio

from mcp_searxng.server import server
import mcp_searxng.prompts  # noqa: F401
import mcp_searxng.tools  # noqa: F401


async def run():
    # Run the server as STDIO
    async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
        await server.run(
            read_stream,
            write_stream,
            InitializationOptions(
                server_name="searxng",
                server_version="0.1.0",
                capabilities=server.get_capabilities(
                    notification_options=NotificationOptions(),
                    experimental_capabilities={},
                ),
            ),
        )


if __name__ == "__main__":
    import asyncio

    asyncio.run(run())

```

--------------------------------------------------------------------------------
/src/mcp_searxng/prompts.py:
--------------------------------------------------------------------------------

```python
import mcp.types as types
from mcp_searxng.server import server


# Add prompt capabilities
@server.list_prompts()
async def handle_list_prompts() -> list[types.Prompt]:
    return [
        types.Prompt(
            name="search",
            description="Use searXNG to search the web",
            arguments=[
                types.PromptArgument(
                    name="query", description="Search query", required=True
                )
            ],
        )
    ]


def search_prompt(arguments: dict[str, str]) -> types.GetPromptResult:
    return types.GetPromptResult(
        description="searXNG search",
        messages=[
            types.PromptMessage(
                role="user",
                content=types.TextContent(
                    type="text", text=f"Searching for {arguments['query']} using searXNG"
                ),
            )
        ],
    )


@server.get_prompt()
async def handle_get_prompt(
    name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
    if arguments is None:
        arguments = {}

    if name == "search":
        return search_prompt(arguments)

    raise ValueError(f"Unknown prompt: {name}")

```

--------------------------------------------------------------------------------
/src/mcp_searxng/tools.py:
--------------------------------------------------------------------------------

```python
from mcp_searxng.server import server
import mcp.types as types
from mcp_searxng.search import search


@server.list_tools()
async def list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="search",
            description="search the web using searXNG. This will aggregate the results from google, bing, brave, duckduckgo and many others. Use this to find information on the web. Even if you do not have access to the internet, you can still use this tool to search the web.",
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {"type": "string"},
                },
                "required": ["query"],
            },
        )
    ]


async def search_tool(
    arguments: dict[str, str],
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
    query: str = arguments["query"]
    result = await search(query)

    return [types.TextContent(type="text", text=result)]


@server.call_tool()
async def get_tool(
    name: str, arguments: dict[str, str] | None
) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
    if arguments is None:
        arguments = {}

    try:
        if name == "search":
            return await search_tool(arguments)

    except Exception as e:
        text = f"Tool {name} failed with error: {e}"
        return [types.TextContent(type="text", text=text)]

    raise ValueError(f"Unknown tool: {name}")

```

--------------------------------------------------------------------------------
/src/mcp_searxng/search.py:
--------------------------------------------------------------------------------

```python
from logging import info
from typing import Optional
from httpx import AsyncClient
from os import getenv

from pydantic import BaseModel, Field


class SearchResult(BaseModel):
    url: str
    title: str
    content: str
    # thumbnail: Optional[str] = None
    # engine: str
    # parsed_url: list[str]
    # template: str
    # engines: list[str]
    # positions: list[int]
    # publishedDate: Optional[str] = None
    # score: float
    # category: str


class InfoboxUrl(BaseModel):
    title: str
    url: str


class Infobox(BaseModel):
    infobox: str
    id: str
    content: str
    # img_src: Optional[str] = None
    urls: list[InfoboxUrl]
    # attributes: list[str]
    # engine: str
    # engines: list[str]


class Response(BaseModel):
    query: str
    number_of_results: int
    results: list[SearchResult]
    # answers: list[str]
    # corrections: list[str]
    infoboxes: list[Infobox]
    # suggestions: list[str]
    # unresponsive_engines: list[str]


async def search(query: str, limit: int = 3) -> str:
    client = AsyncClient(base_url=str(getenv("SEARXNG_URL", "http://localhost:8080")))

    params: dict[str, str] = {"q": query, "format": "json"}

    response = await client.get("/search", params=params)
    response.raise_for_status()

    data = Response.model_validate_json(response.text)

    text = ""

    for index, infobox in enumerate(data.infoboxes):
        text += f"Infobox: {infobox.infobox}\n"
        text += f"ID: {infobox.id}\n"
        text += f"Content: {infobox.content}\n"
        text += "\n"

    if len(data.results) == 0:
        text += "No results found\n"

    for index, result in enumerate(data.results):
        text += f"Title: {result.title}\n"
        text += f"URL: {result.url}\n"
        text += f"Content: {result.content}\n"
        text += "\n"

        if index == limit - 1:
            break

    return str(text)


if __name__ == "__main__":
    import asyncio

    # test case for search
    print(asyncio.run(search("hello world")))

```