# 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:
--------------------------------------------------------------------------------
```
1 | 3.12
2 |
```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | .github
2 | .gitignore
3 | .git
4 | .vscode
5 | __pycache__
6 | .venv
7 | .ruff_cache
8 | .mypy_cache
9 | .mcp-searxng.egg-info
10 | dist
11 | config.json
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # pdm
105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106 | #pdm.lock
107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108 | # in version control.
109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control
110 | .pdm.toml
111 | .pdm-python
112 | .pdm-build/
113 |
114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
115 | __pypackages__/
116 |
117 | # Celery stuff
118 | celerybeat-schedule
119 | celerybeat.pid
120 |
121 | # SageMath parsed files
122 | *.sage.py
123 |
124 | # Environments
125 | .env
126 | .venv
127 | env/
128 | venv/
129 | ENV/
130 | env.bak/
131 | venv.bak/
132 |
133 | # Spyder project settings
134 | .spyderproject
135 | .spyproject
136 |
137 | # Rope project settings
138 | .ropeproject
139 |
140 | # mkdocs documentation
141 | /site
142 |
143 | # mypy
144 | .mypy_cache/
145 | .dmypy.json
146 | dmypy.json
147 |
148 | # Pyre type checker
149 | .pyre/
150 |
151 | # pytype static type analyzer
152 | .pytype/
153 |
154 | # Cython debug symbols
155 | cython_debug/
156 |
157 | # PyCharm
158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
160 | # and can be added to the global gitignore or merged into this file. For a more nuclear
161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
162 | #.idea/
163 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # MCP-searxng
2 |
3 | An MCP server for connecting agentic systems to search systems via [searXNG](https://docs.searxng.org/).
4 |
5 | <p align="center">
6 | <a href="https://glama.ai/mcp/servers/sl2zl8vaz8">
7 | <img width="380" height="200" src="https://glama.ai/mcp/servers/sl2zl8vaz8/badge" alt="MCP SearxNG Badge"/>
8 | </a>
9 | </p>
10 |
11 | ## Tools
12 |
13 | Search the web with SearXNG
14 |
15 | ## Prompts
16 |
17 | ```python
18 | search(query: str) -> f"Searching for {query} using searXNG"
19 | ```
20 |
21 | ## Usage
22 |
23 | ### via uvx
24 |
25 | 1) configure your client JSON like
26 |
27 | ```json
28 | {
29 | "mcpServers": {
30 | "searxng": {
31 | "command": "uvx",
32 | "args": [
33 | "mcp-searxng"
34 | ]
35 | }
36 | }
37 | }
38 | ```
39 |
40 | ### via git clone
41 |
42 | 1) Add the server to claude desktop (the entrypoint is main.py)
43 |
44 | Clone the repo and add this JSON to claude desktop
45 |
46 | you can run this server with `uvx mcp-searxng`, or use a local copy of the repo
47 |
48 | ```json
49 | {
50 | "mcpServers": {
51 | "searxng": {
52 | "command": "uv",
53 | "args": [
54 | "--project",
55 | "/absoloute/path/to/MCP-searxng/",
56 | "run",
57 | "/absoloute/path/to/MCP-searxng/mcp-searxng/main.py"
58 | ]
59 | }
60 | }
61 | }
62 | ```
63 |
64 | you will need to change the paths to match your environment
65 |
66 | ### Custom SearXNG URL
67 |
68 | 2) set the environment variable `SEARXNG_URL` to the URL of the searxng server (default is `http://localhost:8080`)
69 |
70 | 3) run your MCP client and you should be able to search the web with searxng
71 |
72 | Note: if you are using claude desktop make sure to kill the process (task manager or equivalent) before running the server again
73 |
```
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
```yaml
1 | github: SecretiveShell
2 | ko_fi: secretiveshell
3 |
```
--------------------------------------------------------------------------------
/src/mcp_searxng/server.py:
--------------------------------------------------------------------------------
```python
1 | from mcp.server import Server
2 |
3 | __all__ = ["server"]
4 |
5 | server = Server("searxng")
6 |
```
--------------------------------------------------------------------------------
/src/mcp_searxng/__init__.py:
--------------------------------------------------------------------------------
```python
1 | import asyncio
2 | from mcp_searxng.main import run
3 |
4 | def main() -> None:
5 | asyncio.run(run())
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | FROM python:3.12-slim-bullseye
2 |
3 | RUN pip install uv
4 |
5 | COPY . .
6 |
7 | RUN uv venv --python 3.12
8 | RUN uv sync
9 |
10 | ENTRYPOINT [ "uv", "run", "mcp-searxng/main.py" ]
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "mcp-searxng"
3 | version = "0.1.0"
4 | description = "MCP server for connecting agentic systems to search systems via searXNG"
5 | readme = "README.md"
6 | authors = [
7 | { name = "TerminalMan", email = "[email protected]" }
8 | ]
9 | requires-python = ">=3.12"
10 | dependencies = [
11 | "httpx>=0.28.1",
12 | "mcp>=1.1.2",
13 | "pydantic>=2.10.3",
14 | ]
15 |
16 | [project.scripts]
17 | mcp-searxng = "mcp_searxng:main"
18 |
19 | [build-system]
20 | requires = ["hatchling"]
21 | build-backend = "hatchling.build"
22 |
```
--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------
```yaml
1 | # Smithery configuration file: https://smithery.ai/docs/deployments
2 |
3 | build:
4 | dockerBuildPath: .
5 | startCommand:
6 | type: stdio
7 | configSchema:
8 | # JSON Schema defining the configuration options for the MCP.
9 | type: object
10 | required:
11 | - searxngUrl
12 | properties:
13 | searxngUrl:
14 | type: string
15 | description: The URL of the searxng server.
16 | commandFunction:
17 | # A function that produces the CLI command to start the MCP on stdio.
18 | |-
19 | (config) => ({command:'uv',args:['--project', '.', 'run', 'mcp-searxng/main.py'], env:{SEARXNG_URL: config.searxngUrl}})
```
--------------------------------------------------------------------------------
/src/mcp_searxng/main.py:
--------------------------------------------------------------------------------
```python
1 | from mcp.server import NotificationOptions
2 | from mcp.server.models import InitializationOptions
3 | import mcp.server.stdio
4 |
5 | from mcp_searxng.server import server
6 | import mcp_searxng.prompts # noqa: F401
7 | import mcp_searxng.tools # noqa: F401
8 |
9 |
10 | async def run():
11 | # Run the server as STDIO
12 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
13 | await server.run(
14 | read_stream,
15 | write_stream,
16 | InitializationOptions(
17 | server_name="searxng",
18 | server_version="0.1.0",
19 | capabilities=server.get_capabilities(
20 | notification_options=NotificationOptions(),
21 | experimental_capabilities={},
22 | ),
23 | ),
24 | )
25 |
26 |
27 | if __name__ == "__main__":
28 | import asyncio
29 |
30 | asyncio.run(run())
31 |
```
--------------------------------------------------------------------------------
/src/mcp_searxng/prompts.py:
--------------------------------------------------------------------------------
```python
1 | import mcp.types as types
2 | from mcp_searxng.server import server
3 |
4 |
5 | # Add prompt capabilities
6 | @server.list_prompts()
7 | async def handle_list_prompts() -> list[types.Prompt]:
8 | return [
9 | types.Prompt(
10 | name="search",
11 | description="Use searXNG to search the web",
12 | arguments=[
13 | types.PromptArgument(
14 | name="query", description="Search query", required=True
15 | )
16 | ],
17 | )
18 | ]
19 |
20 |
21 | def search_prompt(arguments: dict[str, str]) -> types.GetPromptResult:
22 | return types.GetPromptResult(
23 | description="searXNG search",
24 | messages=[
25 | types.PromptMessage(
26 | role="user",
27 | content=types.TextContent(
28 | type="text", text=f"Searching for {arguments['query']} using searXNG"
29 | ),
30 | )
31 | ],
32 | )
33 |
34 |
35 | @server.get_prompt()
36 | async def handle_get_prompt(
37 | name: str, arguments: dict[str, str] | None
38 | ) -> types.GetPromptResult:
39 | if arguments is None:
40 | arguments = {}
41 |
42 | if name == "search":
43 | return search_prompt(arguments)
44 |
45 | raise ValueError(f"Unknown prompt: {name}")
46 |
```
--------------------------------------------------------------------------------
/src/mcp_searxng/tools.py:
--------------------------------------------------------------------------------
```python
1 | from mcp_searxng.server import server
2 | import mcp.types as types
3 | from mcp_searxng.search import search
4 |
5 |
6 | @server.list_tools()
7 | async def list_tools() -> list[types.Tool]:
8 | return [
9 | types.Tool(
10 | name="search",
11 | 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.",
12 | inputSchema={
13 | "type": "object",
14 | "properties": {
15 | "query": {"type": "string"},
16 | },
17 | "required": ["query"],
18 | },
19 | )
20 | ]
21 |
22 |
23 | async def search_tool(
24 | arguments: dict[str, str],
25 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
26 | query: str = arguments["query"]
27 | result = await search(query)
28 |
29 | return [types.TextContent(type="text", text=result)]
30 |
31 |
32 | @server.call_tool()
33 | async def get_tool(
34 | name: str, arguments: dict[str, str] | None
35 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
36 | if arguments is None:
37 | arguments = {}
38 |
39 | try:
40 | if name == "search":
41 | return await search_tool(arguments)
42 |
43 | except Exception as e:
44 | text = f"Tool {name} failed with error: {e}"
45 | return [types.TextContent(type="text", text=text)]
46 |
47 | raise ValueError(f"Unknown tool: {name}")
48 |
```
--------------------------------------------------------------------------------
/src/mcp_searxng/search.py:
--------------------------------------------------------------------------------
```python
1 | from logging import info
2 | from typing import Optional
3 | from httpx import AsyncClient
4 | from os import getenv
5 |
6 | from pydantic import BaseModel, Field
7 |
8 |
9 | class SearchResult(BaseModel):
10 | url: str
11 | title: str
12 | content: str
13 | # thumbnail: Optional[str] = None
14 | # engine: str
15 | # parsed_url: list[str]
16 | # template: str
17 | # engines: list[str]
18 | # positions: list[int]
19 | # publishedDate: Optional[str] = None
20 | # score: float
21 | # category: str
22 |
23 |
24 | class InfoboxUrl(BaseModel):
25 | title: str
26 | url: str
27 |
28 |
29 | class Infobox(BaseModel):
30 | infobox: str
31 | id: str
32 | content: str
33 | # img_src: Optional[str] = None
34 | urls: list[InfoboxUrl]
35 | # attributes: list[str]
36 | # engine: str
37 | # engines: list[str]
38 |
39 |
40 | class Response(BaseModel):
41 | query: str
42 | number_of_results: int
43 | results: list[SearchResult]
44 | # answers: list[str]
45 | # corrections: list[str]
46 | infoboxes: list[Infobox]
47 | # suggestions: list[str]
48 | # unresponsive_engines: list[str]
49 |
50 |
51 | async def search(query: str, limit: int = 3) -> str:
52 | client = AsyncClient(base_url=str(getenv("SEARXNG_URL", "http://localhost:8080")))
53 |
54 | params: dict[str, str] = {"q": query, "format": "json"}
55 |
56 | response = await client.get("/search", params=params)
57 | response.raise_for_status()
58 |
59 | data = Response.model_validate_json(response.text)
60 |
61 | text = ""
62 |
63 | for index, infobox in enumerate(data.infoboxes):
64 | text += f"Infobox: {infobox.infobox}\n"
65 | text += f"ID: {infobox.id}\n"
66 | text += f"Content: {infobox.content}\n"
67 | text += "\n"
68 |
69 | if len(data.results) == 0:
70 | text += "No results found\n"
71 |
72 | for index, result in enumerate(data.results):
73 | text += f"Title: {result.title}\n"
74 | text += f"URL: {result.url}\n"
75 | text += f"Content: {result.content}\n"
76 | text += "\n"
77 |
78 | if index == limit - 1:
79 | break
80 |
81 | return str(text)
82 |
83 |
84 | if __name__ == "__main__":
85 | import asyncio
86 |
87 | # test case for search
88 | print(asyncio.run(search("hello world")))
89 |
```