agent-tools
by garnetlyx
README.md
# agent-tools
> **agent-tools** is a multi-function MCP tool aggregation server. It exposes a single Streamable HTTP MCP endpoint hosting multiple tool families. The first family is **web search** (aggregating Brave → Exa → SearXNG with multi-key rotation and cross-provider fallback). Additional families (URL fetch, code search, image search, RAG retrieval, etc.) can be added under `src/agent_tools/families/<name>/` without changing the server shell, auth, or observability layer.
`agent-tools` is written in Python 3.12 using the official Python MCP SDK (`mcp==1.27.2`) and FastMCP. It is consumed by Open WebUI 0.11.0 over **Streamable HTTP only** — no stdio, no legacy SSE.
- License: MIT
- Default endpoint: `http://0.0.0.0:8765/mcp`
- Container bind: `0.0.0.0:8765` (host-side loopback isolation is enforced by `docker run -p 127.0.0.1:8765:8765`)
- Logs: JSONL to **stderr only** (stdout is reserved for MCP protocol frames)
---
## Install
### Run with Docker (recommended)
```bash
docker buildx build --load -f Dockerfile -t agent-tools:latest .
docker run -d --name agent-tools \
-p 127.0.0.1:8765:8765 \
--env-file config/agent-tools/tools.env \
--env-file config/agent-tools/tools.secrets.env \
-v "$PWD/examples/backends.yaml:/app/config/backends.yaml:ro" \
agent-tools:latest
```
The image listens on `0.0.0.0:8765/mcp`; bind to loopback on the host with `-p 127.0.0.1:8765:8765`.
### Run from source
Requires Python 3.12 and [`uv`](https://docs.astral.sh/uv/).
```bash
uv sync --frozen
.venv/bin/python -m agent_tools
```
---
## Configuration
Configuration is split into two layers:
1. **Environment variables** — bind settings and secrets.
2. **`backends.yaml`** — operator-tunable per-backend policy (chain order, cooldowns, regex safety gate). No secrets live here.
### Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `HOST` | no | `0.0.0.0` | Bind address (container-internal; publish via `-p`). |
| `PORT` | no | `8765` | Bind port. |
| `MCP_PATH` | no | `/mcp` | MCP Streamable HTTP mount path. |
| `LOG_LEVEL` | no | `INFO` | Python log level. |
| `BRAVE_API_KEYS` | yes* | — | Comma-separated Brave Search API keys. |
| `EXA_API_KEYS` | yes* | — | Comma-separated Exa API keys. |
| `SEARXNG_URLS` | yes* | `http://localhost:8888` | Comma-separated SearXNG instance base URLs. |
| `TOOLS_AUTH_ENABLED` | no | `false` | Enable bearer-token auth on the MCP endpoint. |
| `TOOLS_AUTH_TOKEN` | no | — | Bearer token when `TOOLS_AUTH_ENABLED=true`. |
| `AGENT_TOOLS_BACKENDS_CONFIG` | no | `/app/config/backends.yaml` | Path to the backends policy file. |
\* At least one backend must have at least one key/URL configured for the server to serve traffic; the server itself starts regardless so the failure is visible to MCP clients rather than as a crash loop.
### `backends.yaml`
See [`examples/backends.yaml`](examples/backends.yaml) for the full annotated sample. Key fields:
```yaml
chain_order: [brave, exa, searxng]
fallback_on_empty: false # healthy empty result is a success by default
request_timeout_seconds: 10
max_provider_attempts: 3
backends:
brave:
rate_limit_patterns: ["429", "rate.?limit", "quota"]
cooldown_seconds: 300
suspend_on_monthly_exhausted: true
exa:
rate_limit_patterns: ["429", "rate.?limit"]
cooldown_seconds: 60
searxng:
rate_limit_patterns: [] # no rate-limit body patterns by default
cooldown_seconds: 30
partial_on_unresponsive_engines: true
```
**Safety gate (invariant).** `rate_limit_patterns` is the only signal used to classify an error as retryable. An unmatched error (for example a provider returning `isError: true` with body `"no results"`) is terminal — it does not trigger key rotation or cross-backend fallback. This prevents infinite retry loops.
### Secrets hygiene
The repository ships **no secrets**. The example backends file contains no keys. Put API keys only in an `--env-file` or your secret manager. Anything matching `*.secrets.env` is gitignored.
---
## Tool reference
All four tools share the same input schema and return the same `WebSearchOutput`. They differ only in which backends are consulted.
### `web_search`
Canonical aggregated web search. Walks `chain_order` (`brave → exa → searxng` by default), rotating keys within each backend and falling over to the next backend on hard failures. Set `backend: "brave" | "exa" | "searxng"` in the input to force a single provider; the default is `backend: "auto"`.
### `web_search_brave`
Force **Brave Search** only. Bypasses the fallback chain but still uses the Brave token pool for multi-key rotation.
### `web_search_exa`
Force **Exa** only. Bypasses the fallback chain but still uses the Exa token pool. 402 `TEAM_BUDGET_EXCEEDED` suspends the whole pool; 402 `NO_MORE_CREDITS` / `API_KEY_BUDGET_EXCEEDED` suspends only the current key.
### `web_search_searxng`
Force a **self-hosted SearXNG instance** only. Bypasses the fallback chain. The "key" in the pool is the instance base URL; SearXNG has no API-key contract. The instance must be configured with `search.formats: [html, json]` or agent-tools will classify the response as `config_error`.
### Input schema (`WebSearchInput`)
| Field | Type | Default | Notes |
|---|---|---|---|
| `query` | `str` | required | Max 400 chars (Brave clamps). |
| `num_results` | `int [1, 100]` | `10` | Brave clamps to 20; Exa to 100; SearXNG ignores (instance controls page size). |
| `page` | `int >= 1` | `1` | Brave treats this as a 0-indexed page index (offset 0-9); Exa ignores beyond page 1; SearXNG maps to `pageno`. |
| `language` | `str` | `"en"` | BCP-47-ish language tag. |
| `time_range` | `"day" \| "week" \| "month" \| "year" \| "all"` | `"all"` | SearXNG supports day/month/year; `"week"` maps to `"month"` and is recorded in `applied_filters`. |
| `safe_search` | `"off" \| "moderate" \| "strict"` | `"moderate"` | Mapped per provider. |
| `include_domains` | `list[str]` | `[]` | Exa only; Brave/SearXNG record as `applied=false`. |
| `exclude_domains` | `list[str]` | `[]` | Exa only; Brave/SearXNG record as `applied=false`. |
| `backend` | `"auto" \| "brave" \| "exa" \| "searxng"` | `"auto"` | Force a single backend. |
| `fallback_on_empty` | `bool \| null` | `null` | If true, chain to the next backend when the selected one returns a healthy empty; `null` reads `backends.yaml` (default false). |
### Output schema (`WebSearchOutput`)
| Field | Type | Notes |
|---|---|---|
| `query` | `str` | Echoes the input query. |
| `backend_used` | `"brave" \| "exa" \| "searxng"` | The backend that returned the result. |
| `results` | `SearchResult[]` | Canonical result list. |
| `total_results` | `int \| null` | Provider-reported total when available. |
| `search_time_ms` | `int` | Wall-clock elapsed for the full chain. |
| `fallback_chain_used` | `str[]` | Backends attempted in order. |
| `partial` | `bool` | True when at least one backend failed before another succeeded (or all failed). |
| `applied_filters` | `dict` | Per-backend notes about filters that could not be applied verbatim (page size clamp, unsupported domains, etc.). |
| `errors` | `dict[]` | Structured per-backend errors: `{backend, class, message, details?}`. |
`SearchResult` fields: `title`, `url`, `snippet`, `published_date`, `author`, `score`, `highlights`, `engine`, `provider_metadata`.
> **Scores are not comparable across providers.** `score` is preserved provider-native and `provider_metadata` carries the type context (e.g. Exa similarity, SearXNG instance-local score). There is no cross-provider normalization.
---
## Development
### Running tests
```bash
uv sync --frozen --extra dev
.venv/bin/pytest -q
```
Or in Docker (fully network-isolated):
```bash
docker buildx build --load --target test -f Dockerfile -t agent-tools:test .
docker run --rm --network=none agent-tools:test
```
All HTTP is mocked with [`respx`](https://lundberg.github.io/respx/); tests pass with `--network=none`.
### Adding a new backend
1. Add `src/agent_tools/families/websearch/backends/<name>.py` implementing `BackendAdapter.search(key, request) -> BackendResult`.
2. Add a `normalize_<name>(raw)` function in `normalizer.py` that maps the raw payload to `SearchResult` (preserve every native field under `provider_metadata.<name>`).
3. Register the adapter and a `TokenPool` in `families/websearch/tool.py` (`WebSearchFamily.__init__`).
4. Add the backend name to `BackendName` in `schemas.py` and to the chain validator in `config.py`.
5. Add `tests/test_<name>_adapter.py` using `respx` to mock success / empty / rate-limit / auth / quota / 5xx paths.
### Adding a new tool family
Tool families live under `src/agent_tools/families/<name>/` and are self-contained: schemas, adapters (if any), and tool registration.
1. **Create the family package.** `mkdir -p src/agent_tools/families/<name>` and add `__init__.py`.
2. **Define pydantic schemas.** Create `schemas.py` with an input model and an output model. These drive the MCP `inputSchema`/`outputSchema` automatically.
3. **Implement the tool functions.** Create a module (e.g. `tool.py`) exposing one async callable per MCP tool, typed with the pydantic models from step 2. Group shared state (HTTP clients, pools) in a class.
4. **Register in `server.py`.** Construct the family in `create_app()` and attach its tools to the shared `FastMCP` instance, typically via a `register(mcp, family)` hook.
5. **Add tests.** Create `tests/test_<name>.py` with mocked external I/O. If the family calls HTTP services, mock them with `respx` so the suite remains `--network=none` clean.
The server shell (auth, JSONL logging, bind/mount, error envelope) does not change when a family is added.
---
## Project layout
```
src/agent_tools/
__init__.py
__main__.py # python -m agent_tools entrypoint
server.py # FastMCP app construction
config.py # env + backends.yaml loading
logging.py # JSONL -> stderr
http_client.py # shared httpx.AsyncClient factory
pool.py # TokenPool: health-aware weighted round-robin
families/
websearch/
schemas.py # WebSearchInput / WebSearchOutput / SearchResult
fallback.py # cross-backend state machine
normalizer.py # per-provider canonical mapping
tool.py # FastMCP tool registration
backends/
base.py # BackendAdapter ABC + BackendResult + ErrorClass
brave.py
exa.py
searxng.py
tests/ # pytest + respx (all network mocked)
examples/backends.yaml # documented sample policy
Dockerfile # multi-stage: builder + runtime + test target
```
---
## Operational notes
- **Bearer auth is optional.** Loopback + tailnet is the default boundary; set `TOOLS_AUTH_ENABLED=true` and `TOOLS_AUTH_TOKEN` for defense-in-depth.
- **Quarantine is sticky.** A key that returns 401/403 is quarantined until an operator calls the pool's `clear_quarantine` (v1: restart the server to clear all state). This prevents a known-bad credential from being retried on every request.
- **Team suspension (Exa).** 402 `TEAM_BUDGET_EXCEEDED` suspends the entire Exa pool for one hour by default (override via backends.yaml); individual key suspensions last until the process restarts.
- **No caching in v1.** Per-query caching and SearXNG-native cache control are deferred to a future release.