mcp-botnex 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mcp_botnex-0.1.0/PKG-INFO +141 -0
- mcp_botnex-0.1.0/README.md +109 -0
- mcp_botnex-0.1.0/app/__init__.py +15 -0
- mcp_botnex-0.1.0/app/client/__init__.py +5 -0
- mcp_botnex-0.1.0/app/client/backend_client.py +270 -0
- mcp_botnex-0.1.0/app/core/__init__.py +1 -0
- mcp_botnex-0.1.0/app/core/config.py +83 -0
- mcp_botnex-0.1.0/app/core/exceptions.py +81 -0
- mcp_botnex-0.1.0/app/core/logging.py +45 -0
- mcp_botnex-0.1.0/app/core/registry.py +77 -0
- mcp_botnex-0.1.0/app/formatters/__init__.py +18 -0
- mcp_botnex-0.1.0/app/formatters/cve_formatter.py +106 -0
- mcp_botnex-0.1.0/app/formatters/finding_formatter.py +75 -0
- mcp_botnex-0.1.0/app/formatters/scan_formatter.py +44 -0
- mcp_botnex-0.1.0/app/formatters/summary_formatter.py +38 -0
- mcp_botnex-0.1.0/app/http_server.py +175 -0
- mcp_botnex-0.1.0/app/mcp_server.py +112 -0
- mcp_botnex-0.1.0/app/resources/__init__.py +38 -0
- mcp_botnex-0.1.0/app/resources/base.py +93 -0
- mcp_botnex-0.1.0/app/resources/cve_resources.py +33 -0
- mcp_botnex-0.1.0/app/resources/scan_resources.py +56 -0
- mcp_botnex-0.1.0/app/tools/__init__.py +35 -0
- mcp_botnex-0.1.0/app/tools/base.py +45 -0
- mcp_botnex-0.1.0/app/tools/cve_tools.py +86 -0
- mcp_botnex-0.1.0/app/tools/report_tools.py +87 -0
- mcp_botnex-0.1.0/app/tools/scan_tools.py +158 -0
- mcp_botnex-0.1.0/mcp_botnex.egg-info/PKG-INFO +141 -0
- mcp_botnex-0.1.0/mcp_botnex.egg-info/SOURCES.txt +36 -0
- mcp_botnex-0.1.0/mcp_botnex.egg-info/dependency_links.txt +1 -0
- mcp_botnex-0.1.0/mcp_botnex.egg-info/entry_points.txt +3 -0
- mcp_botnex-0.1.0/mcp_botnex.egg-info/requires.txt +12 -0
- mcp_botnex-0.1.0/mcp_botnex.egg-info/top_level.txt +1 -0
- mcp_botnex-0.1.0/pyproject.toml +75 -0
- mcp_botnex-0.1.0/setup.cfg +4 -0
- mcp_botnex-0.1.0/tests/test_backend_client.py +73 -0
- mcp_botnex-0.1.0/tests/test_config.py +34 -0
- mcp_botnex-0.1.0/tests/test_resources.py +49 -0
- mcp_botnex-0.1.0/tests/test_tools.py +69 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-botnex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Server for BotNEX - VAPT scans, reports and CVE intelligence for AI clients
|
|
5
|
+
Author: BotNEX Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ivedha-tech/botnex-mcp-server
|
|
8
|
+
Project-URL: Repository, https://github.com/ivedha-tech/botnex-mcp-server
|
|
9
|
+
Project-URL: Documentation, https://github.com/ivedha-tech/botnex-mcp-server#readme
|
|
10
|
+
Keywords: mcp,botnex,vapt,security,cve,vulnerability,model-context-protocol
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: mcp>=1.0.0
|
|
22
|
+
Requires-Dist: fastapi>=0.109.0
|
|
23
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
24
|
+
Requires-Dist: httpx>=0.25.0
|
|
25
|
+
Requires-Dist: pydantic>=2.0.0
|
|
26
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-httpx>=0.30.0; extra == "dev"
|
|
32
|
+
|
|
33
|
+
# mcp-botnex
|
|
34
|
+
|
|
35
|
+
MCP (Model Context Protocol) server for **BotNEX** — exposes VAPT scans,
|
|
36
|
+
reports, and CVE intelligence to AI clients such as Cursor and the NEXA
|
|
37
|
+
platform. It is a thin, secure bridge to the existing `botnex-backend` REST
|
|
38
|
+
API and authenticates with a **user-scoped API token** (no JWT).
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- **10 tools** for scan lifecycle, reports, and CVE lookup
|
|
43
|
+
- **6 resources** (`botnex://…`) for `@`-mentionable context in Cursor
|
|
44
|
+
- **Dual transport**: stdio (Cursor / Claude Desktop) and HTTP (NEXA / Docker)
|
|
45
|
+
- **API-token auth** mapped per-user on the backend — RBAC enforced server-side
|
|
46
|
+
- AI-friendly formatters (severity-first findings, normalized CVE output)
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install mcp-botnex
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Authentication
|
|
55
|
+
|
|
56
|
+
1. Log in to the BotNEX UI.
|
|
57
|
+
2. Create an API token: **API Tokens → Create** (calls `POST /api/v1/users/api-tokens/`).
|
|
58
|
+
3. Copy the raw token once and provide it to the MCP server via `BOTNEX_API_KEY`.
|
|
59
|
+
|
|
60
|
+
The token is sent as `Authorization: Bearer <token>` on every backend request.
|
|
61
|
+
The backend resolves it to your user account and enforces all authorization
|
|
62
|
+
and per-user data scoping.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
| Variable | Required | Default | Description |
|
|
67
|
+
|----------|----------|---------|-------------|
|
|
68
|
+
| `BOTNEX_BACKEND_URL` | Yes | – | Backend origin, e.g. `https://botnex.example.com` (no `/api/v1`) |
|
|
69
|
+
| `BOTNEX_API_KEY` | Yes | – | User API token |
|
|
70
|
+
| `BOTNEX_MCP_SERVER_PORT` | No | `8001` | HTTP mode port |
|
|
71
|
+
| `BOTNEX_LOG_LEVEL` | No | `INFO` | Log level |
|
|
72
|
+
| `BOTNEX_BACKEND_TIMEOUT` | No | `60.0` | Backend HTTP timeout (s) |
|
|
73
|
+
| `BOTNEX_FINDINGS_PAGE_SIZE` | No | `50` | Default findings page size |
|
|
74
|
+
|
|
75
|
+
See `.env.example` for the full list.
|
|
76
|
+
|
|
77
|
+
## Usage — Cursor (stdio)
|
|
78
|
+
|
|
79
|
+
Add to your Cursor MCP config:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"mcpServers": {
|
|
84
|
+
"botnex": {
|
|
85
|
+
"command": "mcp-botnex",
|
|
86
|
+
"env": {
|
|
87
|
+
"BOTNEX_BACKEND_URL": "https://botnex.example.com",
|
|
88
|
+
"BOTNEX_API_KEY": "your-api-token"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Usage — HTTP (NEXA / Docker)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
mcp-botnex-http
|
|
99
|
+
# or
|
|
100
|
+
uvicorn app.http_server:app --host 0.0.0.0 --port 8001
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Endpoints: `GET /mcp/tools`, `POST /mcp/tools/call`, `GET /mcp/resources`,
|
|
104
|
+
`POST /mcp/resources/read`, `GET /health`.
|
|
105
|
+
|
|
106
|
+
## Tools
|
|
107
|
+
|
|
108
|
+
| Tool | Description |
|
|
109
|
+
|------|-------------|
|
|
110
|
+
| `list_scans` | List the user's scans and statuses |
|
|
111
|
+
| `trigger_scan` | Start a security or asset-discovery scan |
|
|
112
|
+
| `get_scan_findings` | Paginated, severity-ordered findings for a scan |
|
|
113
|
+
| `get_scan_summary` | Aggregated dashboard summary |
|
|
114
|
+
| `list_scheduled_scans` | Upcoming scheduled scans |
|
|
115
|
+
| `generate_report` | Generate a PDF/CSV/DOCX report |
|
|
116
|
+
| `get_report` | Report metadata by id |
|
|
117
|
+
| `search_cves` | Full-text CVE search |
|
|
118
|
+
| `get_cve_by_id` | CVE detail by id |
|
|
119
|
+
| `search_cves_by_vendor` | CVEs by vendor + version |
|
|
120
|
+
|
|
121
|
+
## Resources
|
|
122
|
+
|
|
123
|
+
| URI | Description |
|
|
124
|
+
|-----|-------------|
|
|
125
|
+
| `botnex://scans/all` | All scans |
|
|
126
|
+
| `botnex://scans/summary` | Scan dashboard summary |
|
|
127
|
+
| `botnex://scans/scheduled` | Scheduled scans |
|
|
128
|
+
| `botnex://scan/{scan_id}/findings` | Findings for a scan |
|
|
129
|
+
| `botnex://cve/{cve_id}` | CVE detail |
|
|
130
|
+
| `botnex://cve/latest` | Latest CVEs |
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install -e ".[dev]"
|
|
136
|
+
pytest
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## License
|
|
140
|
+
|
|
141
|
+
MIT
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# mcp-botnex
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for **BotNEX** — exposes VAPT scans,
|
|
4
|
+
reports, and CVE intelligence to AI clients such as Cursor and the NEXA
|
|
5
|
+
platform. It is a thin, secure bridge to the existing `botnex-backend` REST
|
|
6
|
+
API and authenticates with a **user-scoped API token** (no JWT).
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- **10 tools** for scan lifecycle, reports, and CVE lookup
|
|
11
|
+
- **6 resources** (`botnex://…`) for `@`-mentionable context in Cursor
|
|
12
|
+
- **Dual transport**: stdio (Cursor / Claude Desktop) and HTTP (NEXA / Docker)
|
|
13
|
+
- **API-token auth** mapped per-user on the backend — RBAC enforced server-side
|
|
14
|
+
- AI-friendly formatters (severity-first findings, normalized CVE output)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install mcp-botnex
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Authentication
|
|
23
|
+
|
|
24
|
+
1. Log in to the BotNEX UI.
|
|
25
|
+
2. Create an API token: **API Tokens → Create** (calls `POST /api/v1/users/api-tokens/`).
|
|
26
|
+
3. Copy the raw token once and provide it to the MCP server via `BOTNEX_API_KEY`.
|
|
27
|
+
|
|
28
|
+
The token is sent as `Authorization: Bearer <token>` on every backend request.
|
|
29
|
+
The backend resolves it to your user account and enforces all authorization
|
|
30
|
+
and per-user data scoping.
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
| Variable | Required | Default | Description |
|
|
35
|
+
|----------|----------|---------|-------------|
|
|
36
|
+
| `BOTNEX_BACKEND_URL` | Yes | – | Backend origin, e.g. `https://botnex.example.com` (no `/api/v1`) |
|
|
37
|
+
| `BOTNEX_API_KEY` | Yes | – | User API token |
|
|
38
|
+
| `BOTNEX_MCP_SERVER_PORT` | No | `8001` | HTTP mode port |
|
|
39
|
+
| `BOTNEX_LOG_LEVEL` | No | `INFO` | Log level |
|
|
40
|
+
| `BOTNEX_BACKEND_TIMEOUT` | No | `60.0` | Backend HTTP timeout (s) |
|
|
41
|
+
| `BOTNEX_FINDINGS_PAGE_SIZE` | No | `50` | Default findings page size |
|
|
42
|
+
|
|
43
|
+
See `.env.example` for the full list.
|
|
44
|
+
|
|
45
|
+
## Usage — Cursor (stdio)
|
|
46
|
+
|
|
47
|
+
Add to your Cursor MCP config:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"mcpServers": {
|
|
52
|
+
"botnex": {
|
|
53
|
+
"command": "mcp-botnex",
|
|
54
|
+
"env": {
|
|
55
|
+
"BOTNEX_BACKEND_URL": "https://botnex.example.com",
|
|
56
|
+
"BOTNEX_API_KEY": "your-api-token"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Usage — HTTP (NEXA / Docker)
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
mcp-botnex-http
|
|
67
|
+
# or
|
|
68
|
+
uvicorn app.http_server:app --host 0.0.0.0 --port 8001
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Endpoints: `GET /mcp/tools`, `POST /mcp/tools/call`, `GET /mcp/resources`,
|
|
72
|
+
`POST /mcp/resources/read`, `GET /health`.
|
|
73
|
+
|
|
74
|
+
## Tools
|
|
75
|
+
|
|
76
|
+
| Tool | Description |
|
|
77
|
+
|------|-------------|
|
|
78
|
+
| `list_scans` | List the user's scans and statuses |
|
|
79
|
+
| `trigger_scan` | Start a security or asset-discovery scan |
|
|
80
|
+
| `get_scan_findings` | Paginated, severity-ordered findings for a scan |
|
|
81
|
+
| `get_scan_summary` | Aggregated dashboard summary |
|
|
82
|
+
| `list_scheduled_scans` | Upcoming scheduled scans |
|
|
83
|
+
| `generate_report` | Generate a PDF/CSV/DOCX report |
|
|
84
|
+
| `get_report` | Report metadata by id |
|
|
85
|
+
| `search_cves` | Full-text CVE search |
|
|
86
|
+
| `get_cve_by_id` | CVE detail by id |
|
|
87
|
+
| `search_cves_by_vendor` | CVEs by vendor + version |
|
|
88
|
+
|
|
89
|
+
## Resources
|
|
90
|
+
|
|
91
|
+
| URI | Description |
|
|
92
|
+
|-----|-------------|
|
|
93
|
+
| `botnex://scans/all` | All scans |
|
|
94
|
+
| `botnex://scans/summary` | Scan dashboard summary |
|
|
95
|
+
| `botnex://scans/scheduled` | Scheduled scans |
|
|
96
|
+
| `botnex://scan/{scan_id}/findings` | Findings for a scan |
|
|
97
|
+
| `botnex://cve/{cve_id}` | CVE detail |
|
|
98
|
+
| `botnex://cve/latest` | Latest CVEs |
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install -e ".[dev]"
|
|
104
|
+
pytest
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""BotNEX MCP Server package.
|
|
2
|
+
|
|
3
|
+
A Model Context Protocol bridge between AI clients (Cursor, NEXA) and the
|
|
4
|
+
BotNEX backend. Exposes VAPT scans, reports and CVE intelligence as MCP tools
|
|
5
|
+
and resources, authenticating to the backend with a user-scoped API token.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = metadata.version("mcp-botnex")
|
|
12
|
+
except metadata.PackageNotFoundError: # running from source without install
|
|
13
|
+
__version__ = "0.0.0+local"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Async HTTP client for the BotNEX backend.
|
|
2
|
+
|
|
3
|
+
Authentication
|
|
4
|
+
--------------
|
|
5
|
+
Every request carries the user-scoped API token::
|
|
6
|
+
|
|
7
|
+
Authorization: Bearer <BOTNEX_API_KEY>
|
|
8
|
+
|
|
9
|
+
The backend resolves the token to a user (``request.user``) and enforces both
|
|
10
|
+
the API-token endpoint allowlist and per-user data scoping. The MCP server is
|
|
11
|
+
therefore a thin proxy - it never makes authorization decisions itself.
|
|
12
|
+
|
|
13
|
+
Error mapping
|
|
14
|
+
-------------
|
|
15
|
+
HTTP status codes are translated into the domain exception hierarchy so tools
|
|
16
|
+
and resources can render safe, useful messages.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from app import __version__
|
|
27
|
+
from app.core.config import Settings, get_settings
|
|
28
|
+
from app.core.exceptions import (
|
|
29
|
+
AccessForbiddenError,
|
|
30
|
+
AuthenticationError,
|
|
31
|
+
BackendConnectionError,
|
|
32
|
+
CveNotFoundError,
|
|
33
|
+
MCPServerError,
|
|
34
|
+
ReportNotFoundError,
|
|
35
|
+
ScanNotFoundError,
|
|
36
|
+
)
|
|
37
|
+
from app.core.logging import get_logger, mask_token
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BackendClient:
|
|
43
|
+
"""Thin async wrapper over the BotNEX ``/api/v1`` REST API."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, settings: Settings | None = None):
|
|
46
|
+
self.settings = settings or get_settings()
|
|
47
|
+
self._client: httpx.AsyncClient | None = None
|
|
48
|
+
|
|
49
|
+
# -- lifecycle ---------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
async def _get_client(self) -> httpx.AsyncClient:
|
|
52
|
+
"""Lazily build the shared httpx client after validating config."""
|
|
53
|
+
self.settings.require_backend_config()
|
|
54
|
+
if self._client is None:
|
|
55
|
+
self._client = httpx.AsyncClient(
|
|
56
|
+
base_url=self.settings.api_base_url,
|
|
57
|
+
timeout=self.settings.botnex_backend_timeout,
|
|
58
|
+
verify=self.settings.botnex_verify_ssl,
|
|
59
|
+
headers=self._default_headers(),
|
|
60
|
+
)
|
|
61
|
+
logger.info(
|
|
62
|
+
"Backend client initialised: %s (token=%s)",
|
|
63
|
+
self.settings.api_base_url,
|
|
64
|
+
mask_token(self.settings.botnex_api_key),
|
|
65
|
+
)
|
|
66
|
+
return self._client
|
|
67
|
+
|
|
68
|
+
def _default_headers(self) -> dict[str, str]:
|
|
69
|
+
return {
|
|
70
|
+
"Authorization": f"Bearer {self.settings.botnex_api_key.strip()}",
|
|
71
|
+
"Accept": "application/json",
|
|
72
|
+
"User-Agent": f"{self.settings.botnex_mcp_server_name}/{__version__}",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async def aclose(self) -> None:
|
|
76
|
+
if self._client is not None:
|
|
77
|
+
await self._client.aclose()
|
|
78
|
+
self._client = None
|
|
79
|
+
|
|
80
|
+
async def __aenter__(self) -> "BackendClient":
|
|
81
|
+
await self._get_client()
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
85
|
+
await self.aclose()
|
|
86
|
+
|
|
87
|
+
# -- request core ------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
async def request(
|
|
90
|
+
self,
|
|
91
|
+
method: str,
|
|
92
|
+
path: str,
|
|
93
|
+
*,
|
|
94
|
+
params: dict[str, Any] | None = None,
|
|
95
|
+
json: dict[str, Any] | None = None,
|
|
96
|
+
extra_headers: dict[str, str] | None = None,
|
|
97
|
+
not_found: type[MCPServerError] = ScanNotFoundError,
|
|
98
|
+
expect_binary: bool = False,
|
|
99
|
+
) -> Any:
|
|
100
|
+
"""Perform an HTTP request with retries and error mapping.
|
|
101
|
+
|
|
102
|
+
``path`` is relative to ``/api/v1`` (e.g. ``"/scans/list"``).
|
|
103
|
+
Returns parsed JSON, or raw bytes when ``expect_binary`` is True.
|
|
104
|
+
"""
|
|
105
|
+
client = await self._get_client()
|
|
106
|
+
attempts = max(1, self.settings.botnex_backend_retry_attempts)
|
|
107
|
+
last_exc: Exception | None = None
|
|
108
|
+
|
|
109
|
+
for attempt in range(1, attempts + 1):
|
|
110
|
+
try:
|
|
111
|
+
response = await client.request(
|
|
112
|
+
method,
|
|
113
|
+
path,
|
|
114
|
+
params=params,
|
|
115
|
+
json=json,
|
|
116
|
+
headers=extra_headers,
|
|
117
|
+
)
|
|
118
|
+
return self._handle_response(
|
|
119
|
+
response, not_found=not_found, expect_binary=expect_binary
|
|
120
|
+
)
|
|
121
|
+
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
|
122
|
+
last_exc = exc
|
|
123
|
+
logger.warning(
|
|
124
|
+
"Backend %s %s failed (attempt %d/%d): %s",
|
|
125
|
+
method,
|
|
126
|
+
path,
|
|
127
|
+
attempt,
|
|
128
|
+
attempts,
|
|
129
|
+
exc,
|
|
130
|
+
)
|
|
131
|
+
if attempt < attempts:
|
|
132
|
+
await asyncio.sleep(self.settings.botnex_backend_retry_delay * attempt)
|
|
133
|
+
continue
|
|
134
|
+
raise BackendConnectionError(
|
|
135
|
+
"Could not reach the BotNEX backend. Verify BOTNEX_BACKEND_URL "
|
|
136
|
+
"and that the service is reachable."
|
|
137
|
+
) from exc
|
|
138
|
+
except httpx.HTTPError as exc:
|
|
139
|
+
# Non-retryable transport error.
|
|
140
|
+
raise BackendConnectionError(f"HTTP transport error: {exc}") from exc
|
|
141
|
+
|
|
142
|
+
# Should be unreachable, but keep the type checker happy.
|
|
143
|
+
raise BackendConnectionError(str(last_exc) if last_exc else "Unknown error")
|
|
144
|
+
|
|
145
|
+
def _handle_response(
|
|
146
|
+
self,
|
|
147
|
+
response: httpx.Response,
|
|
148
|
+
*,
|
|
149
|
+
not_found: type[MCPServerError],
|
|
150
|
+
expect_binary: bool,
|
|
151
|
+
) -> Any:
|
|
152
|
+
status = response.status_code
|
|
153
|
+
|
|
154
|
+
if status == 401:
|
|
155
|
+
raise AuthenticationError(
|
|
156
|
+
"The API token is invalid, expired, or revoked. Create a new "
|
|
157
|
+
"token in the BotNEX UI."
|
|
158
|
+
)
|
|
159
|
+
if status == 403:
|
|
160
|
+
raise AccessForbiddenError(
|
|
161
|
+
"This API token is not permitted to access the requested "
|
|
162
|
+
"resource."
|
|
163
|
+
)
|
|
164
|
+
if status == 404:
|
|
165
|
+
raise not_found("The requested item does not exist or is not "
|
|
166
|
+
"accessible with this token.")
|
|
167
|
+
if status == 429:
|
|
168
|
+
raise BackendConnectionError(
|
|
169
|
+
"Rate limit exceeded on the BotNEX backend. Try again later."
|
|
170
|
+
)
|
|
171
|
+
if status >= 500:
|
|
172
|
+
raise BackendConnectionError(
|
|
173
|
+
f"BotNEX backend returned a server error ({status})."
|
|
174
|
+
)
|
|
175
|
+
if status >= 400:
|
|
176
|
+
raise MCPServerError(self._extract_error_message(response, status))
|
|
177
|
+
|
|
178
|
+
if expect_binary:
|
|
179
|
+
return response.content
|
|
180
|
+
if not response.content:
|
|
181
|
+
return {}
|
|
182
|
+
try:
|
|
183
|
+
return response.json()
|
|
184
|
+
except ValueError:
|
|
185
|
+
return {"raw": response.text}
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def _extract_error_message(response: httpx.Response, status: int) -> str:
|
|
189
|
+
try:
|
|
190
|
+
body = response.json()
|
|
191
|
+
if isinstance(body, dict):
|
|
192
|
+
for key in ("message", "error", "detail"):
|
|
193
|
+
if body.get(key):
|
|
194
|
+
return str(body[key])
|
|
195
|
+
except ValueError:
|
|
196
|
+
pass
|
|
197
|
+
return f"Request failed with status {status}."
|
|
198
|
+
|
|
199
|
+
# -- convenience verbs -------------------------------------------------
|
|
200
|
+
|
|
201
|
+
async def get(self, path: str, **kwargs: Any) -> Any:
|
|
202
|
+
return await self.request("GET", path, **kwargs)
|
|
203
|
+
|
|
204
|
+
async def post(self, path: str, **kwargs: Any) -> Any:
|
|
205
|
+
return await self.request("POST", path, **kwargs)
|
|
206
|
+
|
|
207
|
+
# ======================================================================
|
|
208
|
+
# Domain methods (one per backend endpoint used by the MCP surface)
|
|
209
|
+
# ======================================================================
|
|
210
|
+
|
|
211
|
+
# --- scans ---
|
|
212
|
+
async def list_scans(self) -> Any:
|
|
213
|
+
return await self.get("/scans/list")
|
|
214
|
+
|
|
215
|
+
async def trigger_scan(
|
|
216
|
+
self, payload: dict[str, Any], *, idempotency_key: str | None = None
|
|
217
|
+
) -> Any:
|
|
218
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
219
|
+
return await self.post("/scans/scan", json=payload, extra_headers=headers)
|
|
220
|
+
|
|
221
|
+
async def get_scan_report(self, payload: dict[str, Any]) -> Any:
|
|
222
|
+
return await self.post("/scans/report", json=payload)
|
|
223
|
+
|
|
224
|
+
async def get_scan_summary(self) -> Any:
|
|
225
|
+
return await self.get("/scans/summary/")
|
|
226
|
+
|
|
227
|
+
async def list_scheduled_scans(self) -> Any:
|
|
228
|
+
return await self.get("/scans/scheduled/")
|
|
229
|
+
|
|
230
|
+
# --- reports ---
|
|
231
|
+
async def generate_report(self, payload: dict[str, Any]) -> bytes:
|
|
232
|
+
return await self.post(
|
|
233
|
+
"/reports/report",
|
|
234
|
+
json=payload,
|
|
235
|
+
not_found=ReportNotFoundError,
|
|
236
|
+
expect_binary=True,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def get_report_metadata(self, report_id: str) -> Any:
|
|
240
|
+
return await self.get(
|
|
241
|
+
f"/reports/{report_id}", not_found=ReportNotFoundError
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
# --- cve ---
|
|
245
|
+
async def search_cves(self, search_term: str) -> Any:
|
|
246
|
+
return await self.get(
|
|
247
|
+
"/cve/search/all_fields/",
|
|
248
|
+
params={"search_term": search_term},
|
|
249
|
+
not_found=CveNotFoundError,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def get_cve_by_id(self, cve_id: str) -> Any:
|
|
253
|
+
return await self.get(
|
|
254
|
+
f"/cve/searchid/{cve_id}/", not_found=CveNotFoundError
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
async def search_cves_by_vendor(self, vendor: str, version: str) -> Any:
|
|
258
|
+
return await self.get(
|
|
259
|
+
"/cve/search/",
|
|
260
|
+
params={"vendor": vendor, "version": version},
|
|
261
|
+
not_found=CveNotFoundError,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
async def get_latest_cves(self) -> Any:
|
|
265
|
+
return await self.get("/cve/search/latest/", not_found=CveNotFoundError)
|
|
266
|
+
|
|
267
|
+
# --- identity ---
|
|
268
|
+
async def verify_token(self) -> Any:
|
|
269
|
+
"""Return the authenticated user's identity (verify connection)."""
|
|
270
|
+
return await self.get("/users/token/verification")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core configuration, exceptions and registry for the BotNEX MCP server."""
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Application settings.
|
|
2
|
+
|
|
3
|
+
Settings load from environment variables (and a local ``.env`` file when
|
|
4
|
+
present). Validation of required values is *lazy*: the server must start even
|
|
5
|
+
when ``BOTNEX_BACKEND_URL`` / ``BOTNEX_API_KEY`` are missing, because host
|
|
6
|
+
platforms (NEXA, K8s) may inject configuration late or spawn the process just
|
|
7
|
+
to list tools. The first backend call raises a clear, actionable error
|
|
8
|
+
instead.
|
|
9
|
+
|
|
10
|
+
``extra="ignore"`` ensures unrelated platform environment variables never
|
|
11
|
+
crash the server.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
|
|
16
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
17
|
+
|
|
18
|
+
from app.core.exceptions import ConfigurationError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Settings(BaseSettings):
|
|
22
|
+
model_config = SettingsConfigDict(
|
|
23
|
+
env_file=".env",
|
|
24
|
+
env_file_encoding="utf-8",
|
|
25
|
+
case_sensitive=False,
|
|
26
|
+
extra="ignore",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Required for backend access (lazy-validated).
|
|
30
|
+
botnex_backend_url: str = ""
|
|
31
|
+
botnex_api_key: str = ""
|
|
32
|
+
|
|
33
|
+
# Server identity.
|
|
34
|
+
botnex_mcp_server_name: str = "botnex-mcp-server"
|
|
35
|
+
botnex_mcp_server_port: int = 8001
|
|
36
|
+
|
|
37
|
+
# Logging.
|
|
38
|
+
botnex_log_level: str = "INFO"
|
|
39
|
+
|
|
40
|
+
# Backend HTTP behaviour. Report generation can be slow, default generous.
|
|
41
|
+
botnex_backend_timeout: float = 60.0
|
|
42
|
+
botnex_backend_retry_attempts: int = 3
|
|
43
|
+
botnex_backend_retry_delay: float = 1.0
|
|
44
|
+
botnex_verify_ssl: bool = True
|
|
45
|
+
|
|
46
|
+
# Output caps (keep LLM context manageable).
|
|
47
|
+
botnex_findings_page_size: int = 50
|
|
48
|
+
botnex_cve_result_limit: int = 25
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def backend_origin(self) -> str:
|
|
52
|
+
"""Backend origin without trailing slash."""
|
|
53
|
+
return self.botnex_backend_url.strip().rstrip("/")
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def api_base_url(self) -> str:
|
|
57
|
+
"""Full ``/api/v1`` base used by the backend client."""
|
|
58
|
+
return f"{self.backend_origin}/api/v1"
|
|
59
|
+
|
|
60
|
+
def require_backend_config(self) -> None:
|
|
61
|
+
"""Raise ConfigurationError when backend credentials are missing.
|
|
62
|
+
|
|
63
|
+
Called by the backend client before the first request, never at
|
|
64
|
+
process start.
|
|
65
|
+
"""
|
|
66
|
+
missing = []
|
|
67
|
+
if not self.botnex_backend_url.strip():
|
|
68
|
+
missing.append("BOTNEX_BACKEND_URL")
|
|
69
|
+
if not self.botnex_api_key.strip():
|
|
70
|
+
missing.append("BOTNEX_API_KEY")
|
|
71
|
+
if missing:
|
|
72
|
+
raise ConfigurationError(
|
|
73
|
+
"Missing required environment variable(s): "
|
|
74
|
+
+ ", ".join(missing)
|
|
75
|
+
+ ". Set the BotNEX backend URL and a user API token "
|
|
76
|
+
"(create one in the BotNEX UI under API Tokens)."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@lru_cache
|
|
81
|
+
def get_settings() -> Settings:
|
|
82
|
+
"""Return a cached Settings instance."""
|
|
83
|
+
return Settings()
|