Skip to content

Commit dda137e

Browse files
MaciekMaciek
authored andcommitted
Initial release: ESET Connect API MCP server (Python, MCP spec 2025-11-25)
A Model Context Protocol server wrapping the ESET Connect API as 106 tools, 4 resources and 3 prompts. Targets Claude Desktop, Claude Code, and any MCP host speaking stdio or Streamable HTTP. Highlights: • 102 tools auto-generated from 16 official OpenAPI 3.0.1 specs published by ESET (eu.esetconnect.eset.systems), covering every endpoint in their Connect gateway. • 4 hand-written high-level composites that fold 3-6 raw calls into one: - eset_search — substring search across devices/users/policies/groups - device_full_profile — device + detections + vulnerabilities + scans - incident_full_context — incident + comments + detections + devices - latest_detections — newest detections, v2→v1 fallback, sorted by occurTime • RO / RW mode independent of the API account's permissions. In RO mode the server hides every RW tool from the catalog entirely (51 tools visible, down from 106), so the agent's context isn't wasted on things it can never call. An in-memory gate refuses RW tool names as defence in depth. • Two auth modes: - env — credentials from .env (single tenant) - basic — Authorization: Basic header + X-ESET-Region per-request, so one MCP server can serve many ESET tenants Pool of credentials-bound HTTP clients keyed by (user, password_hash, region) — rotating a password mints a fresh OAuth token rather than reusing a stale one. • Streamable HTTP transport (per the November 2025 MCP spec) plus stdio. Production docker-compose profile launches Caddy with auto Let's Encrypt in front of an internally-bound MCP container. • Friendly errors: 401/403/429/5xx come back with operator-readable hints (e.g. on 403, "check Permission Sets in ESET PROTECT Hub"). • Pagination, rate-limit retry (10 req/s + Fair Use), 202 long-polling (up to 10 min), proactive token refresh ~5 min before expiry. • 51 tests (RO smoke + unit + integration via MCP memory transport hitting the real tenant). Daily CI cron catches drift between the server and the official ESET OpenAPI specs. Snyk clean, ruff clean.
0 parents  commit dda137e

48 files changed

Lines changed: 33857 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.env
2+
.env.*
3+
!.env.example
4+
.git
5+
.gitignore
6+
.venv
7+
__pycache__
8+
*.pyc
9+
.pytest_cache
10+
.ruff_cache
11+
.mypy_cache
12+
.coverage
13+
htmlcov
14+
tests
15+
scripts
16+
eset_connect_enu.pdf
17+
eset_docs.txt
18+
test_eset_api.py
19+
.github

.env.example

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ─── Auth mode ─────────────────────────────────────────────────────────────
2+
# env — single tenant: ESET_USER/ESET_PASSWORD below are used for every
3+
# request. Default. Works on both stdio and http transports.
4+
# basic — multi tenant: clients send `Authorization: Basic <base64(user:pw)>`
5+
# (and optionally `X-ESET-Region`) on every request. Only valid with
6+
# ESET_MCP_TRANSPORT=http. ESET_USER/ESET_PASSWORD below become optional.
7+
ESET_AUTH_MODE=env
8+
9+
# ─── ESET API credentials (required when ESET_AUTH_MODE=env) ───────────────
10+
# Use a dedicated API user (NOT your regular console login). Create one in
11+
# ESET PROTECT Hub / ESET Business Account → API users.
12+
ESET_USER=api-user@yourdomain.tld
13+
ESET_PASSWORD=replace-me
14+
15+
# ─── Server behaviour ───────────────────────────────────────────────────────
16+
# RO — RW tools are visible but blocked before any HTTP request goes out.
17+
# RW — full access; destructive tools carry destructiveHint=true in MCP annotations.
18+
ESET_MODE=RO
19+
20+
# Default region for the API (eu/de/us/ca/jpn).
21+
# In basic-auth mode, clients can override this per-request with X-ESET-Region.
22+
ESET_REGION=eu
23+
24+
# ─── MCP transport ─────────────────────────────────────────────────────────
25+
# stdio — for Claude Desktop / Claude Code (local stdin/stdout JSON-RPC).
26+
# http — Streamable HTTP per MCP spec 2025-11-25; exposes an HTTP endpoint.
27+
ESET_MCP_TRANSPORT=stdio
28+
29+
# Used only when ESET_MCP_TRANSPORT=http:
30+
ESET_MCP_HTTP_HOST=127.0.0.1
31+
ESET_MCP_HTTP_PORT=8765
32+
33+
# ─── Optional logging level ────────────────────────────────────────────────
34+
ESET_LOG_LEVEL=INFO
35+
36+
# ─── Production deployment (docker compose --profile prod) ─────────────────
37+
# Public domain Caddy will request a Let's Encrypt cert for. Must point at
38+
# the host running docker-compose. Only used by the `prod` profile.
39+
ESET_PUBLIC_DOMAIN=eset-mcp.example.com
40+
# Email Let's Encrypt uses for cert expiry notifications.
41+
ESET_ACME_EMAIL=ops@example.com

.github/workflows/integration.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Integration tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
schedule:
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).
11+
- cron: "17 3 * * *"
12+
workflow_dispatch: {}
13+
14+
jobs:
15+
test:
16+
runs-on: ubuntu-latest
17+
timeout-minutes: 10
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-python@v5
23+
with:
24+
python-version: "3.13"
25+
cache: pip
26+
27+
- name: Install
28+
run: |
29+
python -m pip install --upgrade pip
30+
pip install -e ".[dev]"
31+
32+
- name: Lint
33+
run: ruff check eset_mcp tests
34+
35+
- name: Run tests (RO only)
36+
env:
37+
ESET_USER: ${{ secrets.ESET_USER }}
38+
ESET_PASSWORD: ${{ secrets.ESET_PASSWORD }}
39+
ESET_REGION: ${{ secrets.ESET_REGION || 'eu' }}
40+
ESET_MODE: RO
41+
run: |
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.
44+
pytest -v -m "not rw"

.gitignore

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# ───────── Secrets ─────────
2+
.env
3+
.env.local
4+
.env.*.local
5+
6+
# ───────── Python ─────────
7+
__pycache__/
8+
*.py[cod]
9+
*$py.class
10+
*.egg-info/
11+
.eggs/
12+
build/
13+
dist/
14+
.venv/
15+
venv/
16+
.pytest_cache/
17+
.mypy_cache/
18+
.ruff_cache/
19+
.coverage
20+
htmlcov/
21+
.tox/
22+
23+
# ───────── Editors / OS ─────────
24+
.DS_Store
25+
.idea/
26+
.vscode/
27+
*.swp
28+
29+
# ───────── Local-only docs (large / ESET copyright) ─────────
30+
eset_connect_enu.pdf
31+
eset_docs.txt
32+
33+
# ───────── Logs ─────────
34+
*.log
35+
logs/
36+
37+
# ───────── Claude Code per-session worktrees (out of scope for the repo) ─────
38+
.claude/

Caddyfile

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Caddy config for production deployment of ESET-MCP.
2+
#
3+
# Used only by the `prod` profile of docker-compose:
4+
# docker compose --profile prod up -d
5+
#
6+
# Caddy fetches a Let's Encrypt cert for {$ESET_PUBLIC_DOMAIN} on first start
7+
# (HTTP-01 challenge on port 80) and serves the MCP server on HTTPS/443.
8+
# The MCP container itself only listens on the internal docker network — it
9+
# is never exposed to the outside world directly.
10+
11+
{$ESET_PUBLIC_DOMAIN} {
12+
# Let's Encrypt cert auto-provisioning.
13+
tls {$ESET_ACME_EMAIL}
14+
15+
# Hard cap on request body — MCP messages are small.
16+
request_body {
17+
max_size 4MB
18+
}
19+
20+
# Pass everything through to the MCP server (Streamable HTTP transport).
21+
# The /mcp prefix is fixed by our Starlette Mount in eset_mcp/__main__.py.
22+
reverse_proxy eset-mcp-http:8765 {
23+
# Stream large/long-running responses (202-polled jobs etc).
24+
flush_interval -1
25+
# Forward client IP — used by ESET-MCP logs and (downstream) by ESET
26+
# rate-limit accounting.
27+
header_up X-Forwarded-For {remote_host}
28+
header_up X-Forwarded-Proto {scheme}
29+
}
30+
31+
encode gzip zstd
32+
33+
# Modest access log to stdout — let docker collect it.
34+
log {
35+
output stdout
36+
format json
37+
}
38+
}

Dockerfile

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# syntax=docker/dockerfile:1.6
2+
FROM python:3.13-slim AS base
3+
4+
ENV PYTHONDONTWRITEBYTECODE=1 \
5+
PYTHONUNBUFFERED=1 \
6+
PIP_NO_CACHE_DIR=1 \
7+
PIP_DISABLE_PIP_VERSION_CHECK=1
8+
9+
WORKDIR /app
10+
11+
# Copy pyproject first (no source) — better layer caching for deps.
12+
COPY pyproject.toml README.md ./
13+
RUN pip install --upgrade pip setuptools wheel
14+
15+
# Copy the rest and install the package (deps come from pyproject).
16+
COPY eset_mcp ./eset_mcp
17+
RUN pip install .
18+
19+
# Defaults: stdio. For HTTP, expose the port (override in docker-compose).
20+
ENV ESET_MCP_TRANSPORT=stdio \
21+
ESET_MCP_HTTP_HOST=0.0.0.0 \
22+
ESET_MCP_HTTP_PORT=8765
23+
24+
EXPOSE 8765
25+
26+
# Drop privileges.
27+
RUN useradd --create-home --uid 10001 esetmcp && chown -R esetmcp:esetmcp /app
28+
USER esetmcp
29+
30+
ENTRYPOINT ["eset-mcp"]

0 commit comments

Comments
 (0)