Eset Mcp — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Eset Mcp (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
A Model Context Protocol server for the entire ESET management surface: ESET Connect (cloud, all regions), ESET PROTECT On-Prem, ESET Inspect, and ESET Cloud Office. Drive any of them from any MCP host (Claude Desktop, Claude Code, or a custom agent) through tools, resources, and prompts.
Built as a single hub for any number of ESET deployments. One process fronts cloud and on-prem consoles at the same time; clients pick the target per request via headers. As long as MCP receives valid credentials (Basic auth, plus an optional URL override and optional Cloudflare Access service token) it routes the call to the right backend, mints its own tokens, and keeps tenants isolated in the pool.
⚠️ Just to be clear, fellas This is an independent, community-driven open-source project and is not affiliated with, officially supported by, or endorsed by ESET, spol. s r.o. ESET and its product names are registered trademarks of their respective owners.
>
While every effort has been made to ensure this software is safe and robust (including the strict Read-Only mode gate), this code is provided "AS IS", without any warranty of any kind. You are solely responsible for how you use this tool and any changes made to your ESET environment.
>
specs, covering application-management, asset-management, automation, device-management, identity, incident-management, installer-management, mobile-device-management, network-access-protection, patch-management, policy-management, quarantine-management, user-management, vulnerability-management, and web-access-protection.
eset_search, device_full_profile, incident_full_context, latest_detections.
ESET_MODE=RO → catalog exposes only read-only tools (51 total).Write tools are hidden from list_tools entirely.
ESET_MODE=RW → all 106 tools advertised; mutating tools carrydestructiveHint: true in their MCP annotations.
before any HTTP request is sent.
ESET_AUTH_MODE=env - single tenant, credentials from .env.ESET_AUTH_MODE=basic - multi tenant, clients passAuthorization: Basic <base64(user:password)> per request (plus optional X-ESET-Region for a different cloud region, or X-ESET-Server-URL to route the request to an on-prem PROTECT console). One server fronts many ESET accounts and can mix cloud + on-prem in the same process.
(user, password_hash, deployment, region-or-server-url, cf_secret_hash). Rotating a password or Cloudflare Access service token mints a fresh client; cloud and on-prem clients for the same user never share a pool entry.
eu / de / us / ca / jpn. Fixed via ESET_REGION in env mode; per-request via X-ESET-Region in basic mode.
In addition to the cloud regions, a single MCP server can front customer-hosted ESET PROTECT On-Prem consoles. The on-prem auth wire format (POST /GetTokens with a camelCase response) and per-host URL structure are handled transparently; clients pick the target per request via the X-ESET-Server-URL header. See On-prem ESET PROTECT support.
When the on-prem console sits behind a Cloudflare Access tunnel, MCP authenticates as a service token (env-default or per-request X-ESET-CF-Access-Client-Id / X-ESET-CF-Access-Client-Secret) and rides through to the origin. The CF token is an extra ingress layer in front of - not a replacement for - the ESET account credentials. Cloud requests never carry these headers.
refresh + retry on 401.
Retry-After).
nextPageToken) walked transparently.response-id header, up to 10 minutes.A single uncapped list_* call can return hundreds of KB - enough to overflow a model's context. Two transformations are applied to every tool response:
fields: [string] parameter that filters each list-item down to the requested keys (e.g. ["uuid", "displayName"]). Applied server-side after fetch.
ESET_MCP_RESPONSE_BYTES_MAX, default 100 KB) - if apayload still exceeds the budget, the longest list is trimmed while every top-level field (nextPageToken, totalSize, …) is preserved, and a _capped metadata block is attached with an actionable hint on how to continue. Agents retain full access to the data through pagination.
HTTP errors are mapped to readable hints: 403 → check Permission Sets in ESET PROTECT Hub; 401 → server refreshes the token automatically; 429 → back off; 5xx → retry shortly.
prod log shippers via ESET_MCP_LOG_FORMAT=json. Every tool call, token refresh, HTTP retry and pool eviction emits a typed event record with low-cardinality fields (tool, deployment, status, duration_ms, response_bytes, ...).
/metrics endpoint(ESET_MCP_METRICS_ENABLED=true, requires pip install eset-mcp[metrics]). Counters for tool calls, token refreshes, HTTP retries, cap hits; histograms for tool duration and response sizes; gauge for client pool size.
Authorizationheaders, CF Access secrets, request/response bodies, query strings, substituted path parameters (which can leak UUIDs). A defensive deny-list in the logger strips known-sensitive keys before any formatter sees them.
a broken metrics registry or formatter can never turn a successful tool call into an error to the agent. /metrics returns 500 (not 503) if exposition ever raises - the worker stays up.
ESET_LOG_LEVEL=WARNING to mute theper-call INFO events but keep retries / errors visible; set ESET_LOG_LEVEL=ERROR to silence everything but hard failures. Disable metrics entirely with ESET_MCP_METRICS_ENABLED=false (default). The three knobs are independent.
The simplest setup: credentials in .env, one MCP host, one ESET cloud region. Good for personal use, a single team, or a desktop AI client like Claude Desktop or Claude Code.
Single-tenant: one ESET account, one deployment
For real multi-tenant or enterprise deployments, put a credentials manager in front of ESET-MCP. The diagram below shows one such pattern using IBM mcp-context-forge as the "creds management" layer - any equivalent MCP gateway (or your own auth proxy) works the same way:
Multi-tenant: many clients, one MCP, many backends
The forge holds per-tenant secrets and injects Authorization: Basic, X-ESET-Region, X-ESET-Server-URL, and X-ESET-CF-Access-* headers per request. ESET-MCP routes each request to the right backend (cloud region, on-prem PROTECT console, or on-prem behind Cloudflare Access) and the per-tenant LRU client pool keeps OAuth tokens fully isolated between tenants. This is a working pattern, not aspirational - the headers, pool keys, and routing rules described here are all in the test suite under tests/test_concurrency.py and tests/test_onprem.py.
env mode the password is read once at startup and kept in memory.basic mode the password is on the wire only for the duration ofthe request, and in memory only while the per-tenant client is hot in the LRU pool. It is never logged.
password itself.
tenant boundaries within a single session.
| Mode | Transport allowed | Credentials source |
|---|---|---|
env | stdio or http | .env (ESET_USER / ESET_PASSWORD) |
basic | http only | Authorization: Basic header per request |
basic mode over plain HTTP would leak passwords. The server enforces HTTP transport for basic mode at startup but does not enforce TLS - that is the deployment's job. The prod docker-compose profile fronts the server with Caddy + Let's Encrypt.
Missing / malformed Authorization in basic mode → HTTP 401 with a WWW-Authenticate: Basic challenge. Unknown region in X-ESET-Region → HTTP 401.
Two independent layers:
list_tools filters out every non-GET tool inRO mode. The agent never sees write tools.
call_tool validates the tool's declaredmode against ESET_MODE before any HTTP request goes out. Hard-coded clients, prompt-injection attempts, and stale agent snapshots all hit the gate and receive a structured ModeForbiddenError text response (no exception, no network call).
In RW mode, mutating tools carry destructiveHint: true so MCP hosts that respect annotations can require a per-call confirmation.
ContextVar; they never enter request bodies or logs.
Credentials instance keyed by(user, password_hash, region).
random-credential spraying.
docker compose up) the MCP server publishes :8765.prod profile the MCP container has no published port -Caddy joins the same docker bridge network and proxies HTTPS in. The only host ports are 80 (HTTP-01 ACME) and 443 (HTTPS).
*.eset.systems (auth + APIs).eset_mcp/.select = E F W I B UP RUF).mcp, httpx, pydantic, python-dotenv.Plus starlette + uvicorn when running HTTP.
basic-mode credentials to disk.Please open a private security advisory rather than a public issue: <https://github.com/maciekaz/ESET-MCP/security/advisories/new>.
The fastest path is the published Docker image. No Python install, no venv, no source checkout - just .env + docker run. The image is multi-arch (amd64 + arm64), signed with cosign, ships with SBOM + build provenance, and is published to GHCR on every release.
cp .env.example .env # fill in ESET_USER / ESET_PASSWORD / ESET_REGION
docker run --rm -i --env-file .env ghcr.io/maciekaz/eset-mcp:1Pin policy:
:1 - latest 1.x.x (auto-updates within the major):1.0 - latest 1.0.x (auto-updates within the minor):1.0.1 - exact version (production):latest - most recent stable release:main / :sha-<short> - edge builds from main (not for production)// claude_desktop_config.json
{
"mcpServers": {
"eset": {
"command": "docker",
"args": ["run", "--rm", "-i", "--env-file", "/absolute/path/to/.env",
"ghcr.io/maciekaz/eset-mcp:1"]
}
}
}docker run -d --name eset-mcp \
--env-file .env -p 8765:8765 \
-e ESET_MCP_TRANSPORT=http \
ghcr.io/maciekaz/eset-mcp:1
# MCP endpoint: http://localhost:8765/mcpcosign verify \
--certificate-identity-regexp '^https://github.com/maciekaz/ESET-MCP/' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/maciekaz/eset-mcp:1git clone https://github.com/maciekaz/ESET-MCP.git
cd ESET-MCP
cp .env.example .env
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
eset-mcpdocker compose up -d eset-mcp-http
# MCP endpoint: http://localhost:8765/mcpBy default the compose file pulls ghcr.io/maciekaz/eset-mcp:1 - no local build, fast first start. Pin a specific version by editing the image: line in docker-compose.yml.
One-off stdio via compose:
docker compose --profile stdio run --rm eset-mcp-stdioHacking on the source? Use the dev profile to build from your local checkout instead of pulling:
docker compose --profile dev up --build eset-mcp-http-devAll settings live in .env. Required fields are marked in .env.example.
| Variable | Default | Purpose |
|---|---|---|
ESET_AUTH_MODE | env | env (single tenant) or basic (multi tenant) |
ESET_USER | - | API user (required in env mode) |
ESET_PASSWORD | - | API password (required in env mode) |
ESET_MODE | RO | RO (read-only catalog) or RW |
ESET_REGION | eu | eu / de / us / ca / jpn |
ESET_MCP_TRANSPORT | stdio | stdio or http |
ESET_MCP_HTTP_HOST | 127.0.0.1 | HTTP bind address |
ESET_MCP_HTTP_PORT | 8765 | HTTP port |
ESET_MCP_RESPONSE_BYTES_MAX | 100000 | Per-call response byte cap; 0 disables |
ESET_LOG_LEVEL | INFO | DEBUG / INFO / WARNING / ERROR |
ESET_MCP_LOG_FORMAT | text | text (dev, human-readable) or json (prod log shippers) |
ESET_MCP_METRICS_ENABLED | false | Mount Prometheus /metrics; requires eset-mcp[metrics] |
ESET_MCP_METRICS_PATH | /metrics | Where to mount the metrics endpoint |
ESET_DEPLOYMENT | cloud | cloud (ESET Connect) or onprem (customer-hosted PROTECT) |
ESET_ONPREM_SERVER_URL | - | https://host[:port] of the on-prem console (req. in env+onprem) |
ESET_ONPREM_VERIFY_SSL | true | Set false for on-prem consoles with self-signed certs |
ESET_ONPREM_CF_ACCESS_CLIENT_ID | - | Cloudflare Access Service Token client-id (on-prem behind CF) |
ESET_ONPREM_CF_ACCESS_CLIENT_SECRET | - | Cloudflare Access Service Token client-secret (paired with the above) |
ESET_PUBLIC_DOMAIN | - | Domain Caddy issues a TLS cert for (prod profile only) |
ESET_ACME_EMAIL | - | Email Let's Encrypt uses for renewals (prod profile only) |
Use a dedicated API user - not your console login. Create one in ESET PROTECT Hub / ESET Business Account → API users.
# .env
ESET_AUTH_MODE=basic
ESET_MCP_TRANSPORT=http
ESET_REGION=eu # default region; clients can override per requestEvery HTTP request must carry:
| Header | Required | Notes |
|---|---|---|
Authorization | yes | Basic <base64(user:password)> |
X-ESET-Region | no | Override default region (eu/de/us/ca/jpn) |
X-ESET-Server-URL | no | Route this request to an on-prem PROTECT console (e.g. https://protect.example.com:9443) - see On-prem support |
X-ESET-CF-Access-Client-Id | no | Cloudflare Access Service Token client-id (on-prem behind CF Access) |
X-ESET-CF-Access-Client-Secret | no | Paired with the above - both must be sent together |
Example Python client:
import base64
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
token = base64.b64encode(b"[email protected]:secret").decode()
headers = {"Authorization": f"Basic {token}", "X-ESET-Region": "us"}
async with streamablehttp_client(
"https://eset-mcp.example.com/mcp/", headers=headers
) as (r, w, _):
async with ClientSession(r, w) as session:
await session.initialize()
tools = await session.list_tools()⚠️ Basic auth without TLS leaks credentials. Always run basic mode behind HTTPS.ESET ships PROTECT as both a cloud service (the ESET Connect API at *.eset.systems) and an on-prem console customers self-host. The on-prem REST API lives on a single host (default port 9443) and uses a different authentication endpoint - POST /GetTokens with a JSON body and a camelCase response - but otherwise shares the URL structure of the cloud API. ESET-MCP supports both, in the same process.
ESET_AUTH_MODE | What controls the deployment per request |
|---|---|
env | Static: ESET_DEPLOYMENT (cloud) or ESET_DEPLOYMENT=onprem + ESET_ONPREM_SERVER_URL |
basic | Per request: presence of X-ESET-Server-URL switches that single request to on-prem; absence falls back to the env default (cloud or on-prem) |
So a single MCP server can front the cloud for most clients and route specific requests to one or more on-prem consoles - keyed entirely by which URL each client sends in X-ESET-Server-URL.
# .env
ESET_AUTH_MODE=env
ESET_DEPLOYMENT=onprem
ESET_ONPREM_SERVER_URL=https://protect.company.local:9443
ESET_ONPREM_VERIFY_SSL=true # set to false only for self-signed certs you trust
[email protected]
ESET_PASSWORD=...# .env
ESET_AUTH_MODE=basic
ESET_MCP_TRANSPORT=http
ESET_DEPLOYMENT=cloud # default; clients opt into on-prem per-request
# ESET_ONPREM_SERVER_URL is optional - if set it becomes the on-prem defaultClient targeting on-prem:
headers = {
"Authorization": f"Basic {token}",
"X-ESET-Server-URL": "https://protect.client-a.local:9443",
}Same MCP server, different request - same headers minus X-ESET-Server-URL
The tool catalog is identical for both deployments (all 102 OpenAPI-derived tools plus the 4 composites). At call time the server uses cloud paths for cloud credentials and on-prem paths for on-prem credentials.
device_*, asset_groups_*, policy_* and mostof task_* (Automation) work the same on cloud and on-prem.
incident_*, mobile_*, wap_*, nap_*,quarantine_* and most of vuln_* correspond to separate ESET products (ESET Inspect, Cloud Office Security, MDM) that are not part of the on-prem PROTECT installation. Calling them against an on-prem console returns a plain 404 from ESET - surfaced to the agent as an ESET API error: 404 text response with no special handling.
POST /v1/devices/{uuid}:rename is :renameDevice on-prem. These are declared in eset_mcp/openapi/onprem-path-overrides.json and applied automatically when the request targets on-prem.
When the on-prem PROTECT console is exposed via a Cloudflare tunnel and gated by Cloudflare Access, MCP can authenticate as a service token. The chain becomes MCP → Cloudflare Access → ESET on-prem.
Two values per token pair, supplied either via .env:
ESET_ONPREM_CF_ACCESS_CLIENT_ID=abc1234567890.access
ESET_ONPREM_CF_ACCESS_CLIENT_SECRET=<long-secret>…or per-request in basic-auth mode (overrides the env defaults - handy when each tenant has its own tunnel and its own service token):
headers = {
"Authorization": f"Basic {token}",
"X-ESET-Server-URL": "https://protect.client-a.local:9443",
"X-ESET-CF-Access-Client-Id": "abc1234567890.access",
"X-ESET-CF-Access-Client-Secret": "<long-secret>",
}MCP translates the X-ESET-CF-* input headers into the actual CF-Access-Client-Id / CF-Access-Client-Secret headers that Cloudflare Access expects, and attaches them to every outbound call - both the POST /GetTokens auth handshake and every subsequent ESET API request.
The CF secret is treated like the password: never logged, only its SHA-256 hash enters the client pool key. Rotating the secret mints a fresh client + fresh ESET token. Cloud requests never carry CF Access headers regardless of env defaults - ESET Connect is a public SaaS.
X-ESET-Server-URL accepts only https:// URLs with no path, query orfragment. Trailing slashes are stripped. Anything else → HTTP 400.
ESET_ONPREM_VERIFY_SSL=false disables TLS certificate verification andexposes the connection to MITM. The server logs a single WARNING per client construction when it's disabled. Use only on trusted intranets with self-signed certs you cannot replace.
(user, password_hash, server_url, cf_secret_hash) - same isolation rules as cloud tokens. The pool keys them separately so cloud and on-prem clients never collide, and two clients hitting the same on-prem URL with different CF service tokens get separate pool entries.
X-ESET-CF-* headers returns HTTP 400rather than silently falling back to the env default (almost certain operator typo).
The prod docker-compose profile launches Caddy in front of the MCP server. Caddy fetches a Let's Encrypt cert on first start (HTTP-01 challenge - ports 80 / 443 must be reachable from the public internet) and proxies HTTPS to the internal MCP container.
# .env
ESET_AUTH_MODE=basic
ESET_PUBLIC_DOMAIN=eset-mcp.example.com
[email protected]
docker compose --profile prod up -d
# MCP endpoint: https://eset-mcp.example.com/mcpYou get:
| Tool | Returns |
|---|---|
eset_search(query, kinds?, limit_per_kind?) | Case-insensitive substring matches across devices / users / policies / groups |
device_full_profile(deviceUuid) | Device record + recent detections + vulnerabilities + recent scans |
incident_full_context(incidentUuid) | Incident + comments + related detections + affected devices |
latest_detections(hours=24, limit=10, severity_min?) | Newest detections in a time window, sorted by occurTime desc; v2 → v1 fallback |
Each composite degrades gracefully when a sub-call returns 403/404 (e.g. on tenants missing a module). The shape carries skipped / truncated flags where applicable.
eset://config/mode - RO or RW.eset://config/region - current region (per-request in basic-auth mode).eset://config/deployment - cloud or onprem (<server-url>) for this request.eset://config/tools-catalog - JSON catalog of all 106 tools (name,mode, method, path, service, description).
eset://docs/rate-limits - quick reminder about the 10 req/s ceiling.audit_inactive_devices(days=30) - offboarding candidates.vulnerability_report - per-device CVE report.incident_triage - open incidents + related detections.eset_mcp/
├── __main__.py # entrypoint - stdio or HTTP, wires resolver + pool
├── server.py # MCP server (tools / resources / prompts) + telemetry
├── credentials.py # Credentials + EnvResolver / BasicAuthResolver + ContextVar
├── middleware.py # ASGI Basic-auth middleware (basic mode only)
├── client_pool.py # LRU pool of EsetHttpClient keyed by (user, region, ...)
├── http_client.py # async httpx + 202 polling + 429 retry + 401 refresh
├── auth.py # CloudTokenManager (OAuth2) + OnPremTokenManager (/GetTokens)
├── regions.py # cloud region → per-service domains + on-prem URL resolver
├── modes.py # RO/RW gate
├── errors.py # HTTP error → agent-friendly text
├── config.py # .env loading
├── response_shaping.py # fields projection + byte cap
├── composite_tools.py # hand-written high-level tools
├── tools_loader.py # generator: tools from OpenAPI specs + on-prem path overrides
├── observability/ # JSON/text structured logging + Prometheus metrics
└── openapi/ # 16 ESET Connect OpenAPI 3.0.1 specs + onprem path overridespytest # full suite (RO smoke + unit + integration)
pytest -m "not rw" # RO only (default in CI)
pytest -m rw # RW (requires an account with RW permissions)Integration tests hit a real ESET tenant - credentials supplied via the same .env. CI workflow: .github/workflows/integration.yml runs on PR, on push to main, and once a day at 03:17 UTC. The cron catches drift between the server and ESET's published OpenAPI specs.
cd eset_mcp/openapi
for name in business-account application-management asset-management automation \
device-management iam incident-management installer-management \
mobile-device-management network-access-protection patch-management \
policy-management quarantine-management user-management \
vulnerability-management web-access-protection; do
curl -sO "https://eu.esetconnect.eset.systems/swagger/api/${name}.json"
donetests/test_catalog_vs_openapi.py flags any new or changed operations after a refresh.
MIT
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.