Skip to content

Commit eff4e4a

Browse files
MaciekMaciek
authored andcommitted
Translate project to English + fix resource read handler
- All docstrings, comments, user-facing messages, prompts, error texts, README, .env.example, Dockerfile and CI workflow are now English-only. (Polish remains only in offline memory files outside the repo.) - Fix: server resource handler compared the raw AnyUrl against a str, always falling through to "Unknown resource URI". MCP SDK passes pydantic AnyUrl objects; cast to str on entry. - Add tests/test_resources_readable end-to-end probe so any future read_resource regression is caught (not just list_resources). Verified: 13 tests pass (1 skipped — user-management 501 on this tenant), ruff clean, Snyk clean, full MCP memory-transport smoke green, Docker image builds and answers the MCP initialize handshake.
1 parent 7473277 commit eff4e4a

22 files changed

Lines changed: 305 additions & 287 deletions

.env.example

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
1-
# ESET Connect API — dedykowane konto API (NIE zwykłe logowanie do panelu).
2-
# Utwórz w ESET PROTECT Hub / ESET Business Account → API users.
1+
# ESET Connect API — dedicated API user account (NOT your regular console login).
2+
# Create one in ESET PROTECT Hub / ESET Business Account → API users.
33
ESET_USER=api-user@yourdomain.tld
44
ESET_PASSWORD=replace-me
55

6-
# Tryb pracy MCP — niezależnie od uprawnień konta API.
7-
# RO — rejestrowane są wszystkie tools, ale RW są blokowane przed wysłaniem requestu.
8-
# RW — pełny dostęp; akcje destrukcyjne mają w MCP annotations destructiveHint=true.
6+
# MCP server operating mode — independent of the API account's permissions.
7+
# RO — all tools are registered, but RW tools are blocked before the HTTP request.
8+
# RW — full access; destructive tools carry destructiveHint=true in MCP annotations.
99
ESET_MODE=RO
1010

11-
# Region — decyduje o domenach API (eu/de/us/ca/jpn).
12-
# EU obsługuje większość klientów z PL.
11+
# Region — selects the API domains (eu/de/us/ca/jpn).
1312
ESET_REGION=eu
1413

15-
# Transport MCP (opcjonalne; default: stdio).
16-
# stdio — dla Claude Desktop / Claude Code (lokalnie, przez stdin/stdout).
17-
# http — Streamable HTTP (najnowszy spec MCP 2025-11-25); wystawia HTTP endpoint.
14+
# MCP transport (optional; default: stdio).
15+
# stdio — for Claude Desktop / Claude Code (local, stdin/stdout JSON-RPC).
16+
# http — Streamable HTTP (MCP spec 2025-11-25); exposes an HTTP endpoint.
1817
ESET_MCP_TRANSPORT=stdio
1918

20-
# Tylko przy ESET_MCP_TRANSPORT=http:
19+
# Only used when ESET_MCP_TRANSPORT=http:
2120
ESET_MCP_HTTP_HOST=127.0.0.1
2221
ESET_MCP_HTTP_PORT=8765
2322

24-
# Opcjonalne: poziom logowania (DEBUG/INFO/WARNING/ERROR).
23+
# Optional logging level (DEBUG/INFO/WARNING/ERROR).
2524
ESET_LOG_LEVEL=INFO

.github/workflows/integration.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ on:
66
pull_request:
77
branches: [main]
88
schedule:
9-
# Codzienne uruchomienie 03:17 UTC — wykrywa rozjazd MCP z ESET API
10-
# (np. nowa wersja v3 endpointu pojawiająca się w Swaggerze).
9+
# Daily run at 03:17 UTC — catches drift between the MCP server and the
10+
# ESET API (e.g. a new v3 endpoint appearing in Swagger).
1111
- cron: "17 3 * * *"
1212
workflow_dispatch: {}
1313

@@ -39,6 +39,6 @@ jobs:
3939
ESET_REGION: ${{ secrets.ESET_REGION || 'eu' }}
4040
ESET_MODE: RO
4141
run: |
42-
# -m "not rw" — pomiń testy RW (konto w CI ma tylko RO).
43-
# Testy z markerem `ro` + unit-testy bez markerów.
42+
# -m "not rw" — skip RW-marked tests (CI's account is RO-only).
43+
# Runs tests with the `ro` marker plus any unmarked tests.
4444
pytest -v -m "not rw"

.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ venv/
2020
htmlcov/
2121
.tox/
2222

23-
# ───────── Editor / OS ─────────
23+
# ───────── Editors / OS ─────────
2424
.DS_Store
2525
.idea/
2626
.vscode/
2727
*.swp
2828

29-
# ───────── Local-only docs (zbyt duże / copyright ESET) ─────────
29+
# ───────── Local-only docs (large / ESET copyright) ─────────
3030
eset_connect_enu.pdf
3131
eset_docs.txt
3232

33-
# ───────── Logi ─────────
33+
# ───────── Logs ─────────
3434
*.log
3535
logs/

Dockerfile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
88

99
WORKDIR /app
1010

11-
# Najpierw skopiuj pyproject (bez kodu) — lepsze cache layer'y dla deps.
11+
# Copy pyproject first (no source) — better layer caching for deps.
1212
COPY pyproject.toml README.md ./
1313
RUN pip install --upgrade pip setuptools wheel
1414

15-
# Skopiuj resztę i zainstaluj paczkę (deps z pyproject).
15+
# Copy the rest and install the package (deps come from pyproject).
1616
COPY eset_mcp ./eset_mcp
1717
RUN pip install .
1818

19-
# Default: stdio. Dla HTTP wystaw port (override w docker-compose).
19+
# Defaults: stdio. For HTTP, expose the port (override in docker-compose).
2020
ENV ESET_MCP_TRANSPORT=stdio \
2121
ESET_MCP_HTTP_HOST=0.0.0.0 \
2222
ESET_MCP_HTTP_PORT=8765

README.md

Lines changed: 58 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,121 @@
11
# ESET-MCP
22

3-
Serwer [Model Context Protocol](https://modelcontextprotocol.io) (spec **2025-11-25**)
4-
opakowujący [ESET Connect API](https://help.eset.com/eset_connect/en-US/) jako zestaw
5-
tools, resources i prompts do użycia w Claude Desktop, Claude Code lub innym MCP host.
6-
7-
- **102 tools** wygenerowane z 16 oficjalnych OpenAPI specs ESET Connect (`eu.esetconnect.eset.systems`)
8-
- **Dwa tryby pracy** niezależne od uprawnień konta API:
9-
- `RO` — RW tools widoczne w katalogu, ale zablokowane przed dotknięciem sieci
10-
- `RW` — pełny dostęp, z `destructiveHint: true` annotations dla MCP host
11-
- **Dwa transporty**: stdio (lokalnie) i Streamable HTTP (zdalnie, wg najnowszego MCP spec)
12-
- **OAuth2** z automatycznym refresh ~5min przed wygaśnięciem tokenu (1h validity)
13-
- **Pagination** (nextPageToken), **rate limit** retry (10 req/s + Fair Use), **202 polling** do 10 min
3+
A [Model Context Protocol](https://modelcontextprotocol.io) server (spec **2025-11-25**)
4+
wrapping the [ESET Connect API](https://help.eset.com/eset_connect/en-US/) as a set of
5+
tools, resources, and prompts usable from Claude Desktop, Claude Code, or any MCP host.
6+
7+
- **102 tools** auto-generated from 16 official OpenAPI specs published at
8+
`eu.esetconnect.eset.systems`
9+
- **Two operating modes** independent of the API account's permissions:
10+
- `RO` — RW tools remain visible in the catalog but are blocked before touching the network
11+
- `RW` — full access; mutating tools carry `destructiveHint: true` MCP annotations
12+
- **Two transports**: stdio (local) and Streamable HTTP (remote, per the latest MCP spec)
13+
- **OAuth2** with automatic token refresh ~5 min before expiry (tokens are valid for 1 h)
14+
- **Pagination** (nextPageToken), **rate-limit** retry (10 req/s + Fair Use Policy),
15+
**202 polling** up to 10 minutes
1416
- **Multi-region**: eu / de / us / ca / jpn
1517

1618
## Quick start
1719

18-
### 1. Konfiguracja
20+
### 1. Configure
1921

2022
```bash
2123
cp .env.example .env
22-
# uzupełnij ESET_USER / ESET_PASSWORD (dedykowane konto API, NIE logowanie do panelu)
23-
# ustaw ESET_REGION i ESET_MODE
24+
# Fill in ESET_USER / ESET_PASSWORD (a dedicated API user, NOT your console login).
25+
# Set ESET_REGION and ESET_MODE.
2426
```
2527

26-
### 2. Uruchomienie lokalne (stdio)
28+
### 2. Run locally (stdio)
2729

2830
```bash
2931
python -m venv .venv && source .venv/bin/activate
3032
pip install -e ".[dev]"
3133
eset-mcp
3234
```
3335

34-
### 3. Podpięcie do Claude Desktop / Claude Code
36+
### 3. Wire it up to Claude Desktop / Claude Code
3537

3638
```jsonc
3739
// claude_desktop_config.json
3840
{
3941
"mcpServers": {
4042
"eset": {
4143
"command": "/absolute/path/to/.venv/bin/eset-mcp",
42-
"env": { /* opcjonalnie nadpisz cokolwiek z .env */ }
44+
"env": { /* optionally override anything from .env */ }
4345
}
4446
}
4547
}
4648
```
4749

48-
### 4. Docker (HTTP, dla zdalnego deployu)
50+
### 4. Docker (HTTP transport, for remote deployment)
4951

5052
```bash
5153
docker compose up --build eset-mcp-http
5254
# MCP endpoint: http://localhost:8765/mcp
5355
```
5456

55-
stdio w kontenerze (jednorazowe wywołanie):
57+
One-off stdio invocation inside a container:
5658
```bash
5759
docker compose run --rm eset-mcp-stdio
5860
```
5961

60-
## Architektura
62+
## Architecture
6163

6264
```
6365
eset_mcp/
64-
├── __main__.py # entrypoint — wybór transportu (stdio / http)
66+
├── __main__.py # entrypoint — picks a transport (stdio / http)
6567
├── server.py # MCP server (tools / resources / prompts)
66-
├── tools_loader.py # generator tools z OpenAPI specs
68+
├── tools_loader.py # generator: tools from OpenAPI specs
6769
├── http_client.py # async httpx + 202 polling + 429 retry + 401 refresh
68-
├── auth.py # OAuth2 password grant, proaktywny refresh
69-
├── regions.py # region → domeny per service
70-
├── modes.py # RO/RW gate (rzuca przed wysłaniem requestu)
71-
├── errors.py # mapowanie HTTP errorsuser-friendly text dla agenta
72-
├── config.py # ładowanie .env
73-
└── openapi/ # 16 OpenAPI 3.0.1 specs ESET Connect
70+
├── auth.py # OAuth2 password grant, proactive refresh
71+
├── regions.py # region → per-service domains
72+
├── modes.py # RO/RW gate (raises before any HTTP request)
73+
├── errors.py # HTTP erroragent-friendly text
74+
├── config.py # .env loading
75+
└── openapi/ # 16 ESET Connect OpenAPI 3.0.1 specs
7476
```
7577

76-
## Tryby RO / RW
78+
## RO / RW modes
7779

78-
Tryb wybiera się przez `ESET_MODE` w `.env`. **Niezależnie** od uprawnień konta API:
80+
The mode is chosen via `ESET_MODE` in `.env`. **Independent** of the API
81+
account's permissions:
7982

80-
| `ESET_MODE` | Działanie |
83+
| `ESET_MODE` | Behaviour |
8184
|-------------|-----------|
82-
| `RO` | RW tools są widoczne w katalogu (agent wie, że istnieją), ale `call_tool` na nich kończy się czytelnym komunikatem `Tool '...' wymaga trybu RW, a serwer MCP jest w trybie RO`. Żaden request HTTP nie wychodzi. |
83-
| `RW` | RW tools działają. Każdy z nich w MCP annotations ma `destructiveHint: true` MCP host (np. Claude) dostaje sygnał i może wymagać dodatkowego potwierdzenia. |
85+
| `RO` | RW tools are visible in the catalog (the agent knows they exist), but `call_tool` on them returns a clear message `Tool '...' requires RW mode, but the MCP server is in RO mode`. No HTTP request goes out. |
86+
| `RW` | RW tools run normally. Every one of them carries `destructiveHint: true` in MCP annotations — the MCP host (e.g. Claude) can use that signal to require extra confirmation. |
8487

85-
Dodatkowo agent dostaje czytelne komunikaty błędów dla wszystkich HTTP error codes
86-
(401 / 403 / 429 / 5xx) — m.in. wskazówkę co sprawdzić w ESET PROTECT Hub przy 403.
88+
The server additionally surfaces agent-friendly messages for every HTTP error
89+
(401 / 403 / 429 / 5xx) — for instance, on 403 it suggests checking Permission
90+
Sets in ESET PROTECT Hub.
8791

8892
## MCP Resources
8993

90-
- `eset://config/mode``RO` lub `RW`
91-
- `eset://config/region`aktualny region
92-
- `eset://config/tools-catalog` — JSON-owy katalog wszystkich 102 tools (nazwa, mode, method, path, service, description)
93-
- `eset://docs/rate-limits`przypomnienie o limitach 10 req/s
94+
- `eset://config/mode``RO` or `RW`
95+
- `eset://config/region`current region
96+
- `eset://config/tools-catalog` — JSON catalog of all 102 tools (name, mode, method, path, service, description)
97+
- `eset://docs/rate-limits`quick reminder about the 10 req/s ceiling
9498

95-
## MCP Prompts (wbudowane recepty)
99+
## MCP Prompts (built-in recipes)
96100

97-
- `audit_inactive_devices(days=30)`kandydaci do offboardingu
98-
- `vulnerability_report`raport CVE per device
99-
- `incident_triage`otwarte incydenty + powiązane detekcje
101+
- `audit_inactive_devices(days=30)`offboarding candidates
102+
- `vulnerability_report`per-device CVE report
103+
- `incident_triage`open incidents + related detections
100104

101-
## Testy
105+
## Tests
102106

103107
```bash
104-
pytest # wszystko (RO smoke + unit catalog vs OpenAPI)
105-
pytest -m "not rw" # tylko RO (default w CI)
106-
pytest -m rw # RW (wymaga konta z uprawnieniami RW)
108+
pytest # everything (RO smoke + unit catalog vs OpenAPI)
109+
pytest -m "not rw" # RO only (the default in CI)
110+
pytest -m rw # RW (requires an account with RW permissions)
107111
```
108112

109113
CI workflow: [.github/workflows/integration.yml](.github/workflows/integration.yml)
110-
puszcza się on-PR, on-push do `main` i raz dziennie (3:17 UTC). Cron-job
111-
łapie rozjazdy MCP z dokumentacją ESET (np. nowa wersja endpointu w Swaggerze).
114+
runs on PR, on push to `main`, and once a day at 03:17 UTC. The cron job
115+
catches drift between the MCP server and ESET's documentation (e.g. a new
116+
endpoint version appearing in Swagger).
112117

113-
## Aktualizacja OpenAPI
118+
## Refreshing the OpenAPI specs
114119

115120
```bash
116121
cd eset_mcp/openapi
@@ -123,8 +128,9 @@ for name in business-account application-management asset-management automation
123128
done
124129
```
125130

126-
Po aktualizacji koniecznie puść testy — `test_catalog_vs_openapi.py` złapie nowe / zmienione operacje.
131+
Run the tests after an update — `test_catalog_vs_openapi.py` will flag any new
132+
or changed operations.
127133

128-
## Licencja
134+
## License
129135

130136
MIT

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
services:
2-
# Tryb HTTP (Streamable HTTP, MCP spec 2025-11-25) — wystawia /mcp na 0.0.0.0:8765.
2+
# HTTP mode (Streamable HTTP, MCP spec 2025-11-25) — exposes /mcp on 0.0.0.0:8765.
33
eset-mcp-http:
44
build: .
55
container_name: eset-mcp-http
@@ -12,8 +12,8 @@ services:
1212
ports:
1313
- "8765:8765"
1414

15-
# Tryb stdio — uruchamiany ad-hoc do podłączenia jako lokalny serwer MCP
16-
# (np. do Claude Desktop / Claude Code). Użyj:
15+
# stdio mode — run on demand to attach as a local MCP server (e.g. from
16+
# Claude Desktop / Claude Code):
1717
# docker compose run --rm eset-mcp-stdio
1818
eset-mcp-stdio:
1919
build: .

eset_mcp/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
"""ESET-MCP — serwer MCP dla ESET Connect API."""
1+
"""ESET-MCP — Model Context Protocol server for the ESET Connect API."""
22
__version__ = "0.1.0"

eset_mcp/__main__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Entrypoint serwera MCP — stdio albo Streamable HTTP."""
1+
"""Server entrypoint — stdio or Streamable HTTP transport."""
22
from __future__ import annotations
33

44
import asyncio
@@ -15,10 +15,10 @@ def main() -> None:
1515
logging.basicConfig(
1616
level=settings.log_level,
1717
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
18-
stream=sys.stderr, # MCP stdio używa stdout do JSON-RPC — logi MUSZĄ iść do stderr.
18+
stream=sys.stderr, # MCP stdio uses stdout for JSON-RPC — logs MUST go to stderr.
1919
)
2020
log = logging.getLogger("eset_mcp")
21-
log.info("ESET-MCP start — mode=%s region=%s transport=%s", settings.mode, settings.region, settings.transport)
21+
log.info("ESET-MCP starting — mode=%s region=%s transport=%s", settings.mode, settings.region, settings.transport)
2222

2323
asyncio.run(_run(settings))
2424

@@ -32,7 +32,7 @@ async def _run(settings: Settings) -> None:
3232
async with stdio_server() as (read, write):
3333
await server.run(read, write, server.create_initialization_options())
3434
elif settings.transport == "http":
35-
# Streamable HTTP — najnowszy transport wg spec MCP 2025-11-25.
35+
# Streamable HTTP — the current transport per MCP spec 2025-11-25.
3636
import uvicorn
3737
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
3838
from starlette.applications import Starlette
@@ -45,7 +45,7 @@ async def _run(settings: Settings) -> None:
4545
)
4646
await uvicorn.Server(config).serve()
4747
else:
48-
raise RuntimeError(f"Nieznany transport: {settings.transport!r}")
48+
raise RuntimeError(f"Unknown transport: {settings.transport!r}")
4949

5050

5151
if __name__ == "__main__":

eset_mcp/auth.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
"""OAuth2 password grant + automatyczny refresh.
1+
"""OAuth2 password grant + automatic token refresh.
22
3-
Token jest ważny 1h (docs ESET). Odświeżamy proaktywnie 5 min przed wygaśnięciem,
4-
żeby uniknąć race podczas long-running tool calls.
3+
The access token is valid for 1h (per ESET docs). We refresh proactively
4+
5 minutes before expiry to avoid a race during long-running tool calls.
55
"""
66
from __future__ import annotations
77

@@ -15,7 +15,7 @@
1515
from .errors import map_http_error
1616
from .regions import auth_url
1717

18-
# Bufor bezpieczeństwaodśwież token N sekund przed nominalnym expiry.
18+
# Safety marginrefresh the token N seconds before its nominal expiry.
1919
_REFRESH_MARGIN_S = 300
2020

2121

@@ -30,7 +30,7 @@ def expired_or_expiring(self, margin: int = _REFRESH_MARGIN_S) -> bool:
3030

3131

3232
class TokenManager:
33-
"""Trzyma jeden token na proces serwera MCP. Odświeża proaktywnie i pod 401."""
33+
"""Holds a single token for the MCP server process; refreshes proactively and on 401."""
3434

3535
def __init__(self, settings: Settings, http: httpx.AsyncClient):
3636
self._settings = settings
@@ -39,24 +39,24 @@ def __init__(self, settings: Settings, http: httpx.AsyncClient):
3939
self._lock = asyncio.Lock()
4040

4141
async def get_access_token(self) -> str:
42-
"""Zwróć ważny access_token. Odśwież jeśli expired/expiring."""
42+
"""Return a valid access_token, refreshing it if expired or expiring soon."""
4343
async with self._lock:
4444
if self._token is None or self._token.expired_or_expiring():
4545
await self._refresh_locked()
4646
assert self._token is not None
4747
return self._token.access_token
4848

4949
async def force_refresh(self) -> str:
50-
"""Wymusza refresh — używamy po 401 z serwera."""
50+
"""Force a refresh — used after a 401 from the server."""
5151
async with self._lock:
5252
await self._refresh_locked()
5353
assert self._token is not None
5454
return self._token.access_token
5555

5656
async def _refresh_locked(self) -> None:
57-
"""Wewnętrznewywołuj tylko przy nabytym _lock."""
58-
# Jeśli mamy refresh_token i jeszcze nie wygasł całkiem, użyj go.
59-
# Inaczejod zera przez password grant.
57+
"""Internalmust be called while holding _lock."""
58+
# If we have a refresh_token and it has not fully expired, use it.
59+
# Otherwisefall back to the password grant.
6060
if self._token and self._token.refresh_token and not self._token.expired_or_expiring(margin=0):
6161
data = {
6262
"grant_type": "refresh_token",

0 commit comments

Comments
 (0)