ESET Protect MCP
About
MCP server for ESET Connect API - 102 tools, RO/RW mode, stdio+HTTP, OAuth2
Details
- Author
- maciekaz
- Categories
- Other, Security, API, Infrastructure
Jump to
Setup
Install ESET Protect MCP in your MCP client (Claude Desktop, Cursor, Windsurf, and others).
Repository: https://github.com/maciekaz/ESET-MCP
Follow the installation instructions in the repository README, then restart your MCP client.
AModel Context Protocolserver 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 asingle 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 isnotaffiliated 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.
- Features
- Architecture at a glance
- Security
- Quick start
- Configuration
- Multi-tenant deployment (basic-auth mode)
- On-prem ESET PROTECT support
- Production deployment (HTTPS via Caddy)
- Tools, resources & prompts
- Architecture
- Tests
- Refreshing the OpenAPI specs
- License
- 102 toolsauto-generated from 16 official ESET Connect OpenAPI 3.0.1 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.
- 4 high-level compositesthat fold 3-6 raw calls into one:eset_search,device_full_profile,incident_full_context,latest_detections.
- ESET_MODE=RO→ catalog exposesonlyread-only tools (51 total). Write tools are hidden fromlist_toolsentirely.
- ESET_MODE=RW→ all 106 tools advertised; mutating tools carrydestructiveHint: truein their MCP annotations.
- Independent of the ESET account's underlying permissions.
- A defence-in-depth in-memory gate rejects RW tool names in RO mode 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 optionalX-ESET-Regionfor a different cloud region, orX-ESET-Server-URLto 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.
- Per-tenant OAuth tokens, pooled and isolated by(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.
- stdio- JSON-RPC over stdin/stdout for local hosts.
- Streamable HTTP- the current MCP transport (Nov 2025 spec).
eu/de/us/ca/jpn. Fixed viaESET_REGIONinenvmode; per-request viaX-ESET-Regioninbasicmode.
In addition to the cloud regions, a single MCP server can front customer-hostedESET PROTECT On-Premconsoles. The on-prem auth wire format (POST /GetTokenswith a camelCase response) and per-host URL structure are handled transparently; clients pick the target per request via theX-ESET-Server-URLheader. SeeOn-prem ESET PROTECT support.
When the on-prem console sits behind a Cloudflare Access tunnel, MCP authenticates as aservice token(env-default or per-requestX-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.
- OAuth2 with proactive refresh ~5 min before token expiry and a forced refresh + retry on 401.
- 429 retries with exponential backoff (up to 3 attempts, honoursRetry-After).
- Pagination (nextPageToken) walked transparently.
- 202 long-polling with theresponse-idheader, up to 10 minutes.
Response shaping (context-window protection)
A single uncappedlist_call can return hundreds of KB - enough to overflow a model's context. Two transformations are applied to every tool response:
- fieldsprojection- every GET tool exposes an optionalfields: [string]parameter that filters each list-item down to the requested keys (e.g.["uuid", "displayName"]). Applied server-side after fetch.
- Byte cap(ESET_MCP_RESPONSE_BYTES_MAX, default 100 KB) - if a payload still exceeds the budget, the longest list is trimmed while every top-level field (nextPageToken,totalSize, …) is preserved, and a_cappedmetadata 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.
- Structured logsto stderr. Text (default) for dev, JSON Lines for prod log shippers viaESET_MCP_LOG_FORMAT=json. Every tool call, token refresh, HTTP retry and pool eviction emits a typedeventrecord with low-cardinality fields (tool, deployment, status, duration_ms, response_bytes, ...).
- Prometheus metricsat an opt-in/metricsendpoint (ESET_MCP_METRICS_ENABLED=true, requirespip 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.
- What never enters logs or metrics: passwords,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.
- Failure isolation: telemetry emission is wrapped in a try/except so a broken metrics registry or formatter can never turn a successful tool call into an error to the agent./metricsreturns 500 (not 503) if exposition ever raises - the worker stays up.
- Quieting in production: setESET_LOG_LEVEL=WARNINGto mute the per-call INFO events but keep retries / errors visible; setESET_LOG_LEVEL=ERRORto silence everything but hard failures. Disable metrics entirely withESET_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.
For real multi-tenant or enterprise deployments, put a credentials manager in front of ESET-MCP. The diagram below shows one such pattern usingIBM mcp-context-forgeas the "creds management" layer - any equivalent MCP gateway (or your own auth proxy) works the same way:
The forge holds per-tenant secrets and injectsAuthorization: Basic,X-ESET-Region,X-ESET-Server-URL, andX-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 undertests/test_concurrency.pyandtests/test_onprem.py.
- Inenvmode the password is read once at startup and kept in memory.
- Inbasicmode the password is on the wire only for the duration of the request, and in memory only while the per-tenant client is hot in the LRU pool. It isnever logged.
- The pool key uses a SHA-256 hash of the password rather than the password itself.
- OAuth access/refresh tokens are held per-tenant; tokens never cross tenant boundaries within a single session.
basicmode over plain HTTP would leak passwords. The server enforces HTTP transport forbasicmode at startup but doesnotenforce TLS - that is the deployment's job. Theproddocker-compose profile fronts the server with Caddy + Let's Encrypt.
Missing / malformedAuthorizationinbasicmode → HTTP 401 with aWWW-Authenticate: Basicchallenge. Unknown region inX-ESET-Region→ HTTP 401.
- Catalog hiding-list_toolsfilters out every non-GET tool in RO mode. The agent never sees write tools.
- Defence-in-depth gate-call_toolvalidates the tool's declared mode againstESET_MODEbefore any HTTP request goes out. Hard-coded clients, prompt-injection attempts, and stale agent snapshots all hit the gate and receive a structuredModeForbiddenErrortext response (no exception, no network call).
In RW mode, mutating tools carrydestructiveHint: trueso MCP hosts that respect annotations can require a per-call confirmation.
- Auth headers are parsed in dedicated ASGI middleware and stashed in aContextVar; they never enter request bodies or logs.
- Each request resolves to aCredentialsinstance keyed by(user, password_hash, region).
- An LRU bound on the client pool prevents unbounded memory growth from random-credential spraying.
- In dev (docker compose up) the MCP server publishes:8765.
- In theprodprofile the MCP container hasno 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).
- No outbound traffic except to.eset.systems(auth + APIs).
- Snyk Code: 0 issues ineset_mcp/.
- Ruff: clean (select = E F W I B UP RUF).
- Runtime dependencies:mcp,httpx,pydantic,python-dotenv. Plusstarlette+uvicornwhen running HTTP.
- No webhook receivers.
- No persistent storage; logs go to stdout.
- No on-disk caching of OAuth tokens.
- No write-back ofbasic-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:1
- :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)
Wire up to Claude Desktop / Claude Code (stdio)
// claude_desktop_config.json { "mcpServers": { "eset": { "command": "docker", "args": ["run", "--rm", "-i", "--env-file", "/absolute/path/to/.env", "ghcr.io/maciekaz/eset-mcp:1"] } } }
HTTP transport (single-tenant or behind your own proxy)
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/mcp
cosign verify \ --certificate-identity-regexp '^https://github.com/maciekaz/ESET-MCP/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/maciekaz/eset-mcp:1
From source (contributors / local hacking)
git 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-mcp
Docker Compose (uses the published image)
docker compose up -d eset-mcp-http # MCP endpoint: http://localhost:8765/mcp
By default the compose file pullsghcr.io/maciekaz/eset-mcp:1- no local build, fast first start. Pin a specific version by editing theimage:line indocker-compose.yml.
docker compose --profile stdio run --rm eset-mcp-stdio
Hacking 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-dev
All settings live in.env. Required fields are marked in.env.example.
Use adedicated API user- not your console login. Create one in ESET PROTECT Hub / ESET Business Account → API users.
Multi-tenant deployment (basic-auth mode)
# .env ESET_AUTH_MODE=basic ESET_MCP_TRANSPORT=http ESET_REGION=eu # default region; clients can override per request
import base64 from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client token = base64.b64encode(b"api-user@tenant.tld: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 runbasicmode 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 port9443) and uses a different authentication endpoint -POST /GetTokenswith 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.
So a single MCP server can front the cloud for most clientsandroute specific requests to one or more on-prem consoles - keyed entirely by which URL each client sends inX-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 ESET_USER=api-user@company.local ESET_PASSWORD=...
Multi-tenant on-prem (basic auth, per-request URL)
# .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 default
headers = { "Authorization": f"Basic {token}", "X-ESET-Server-URL": "https://protect.client-a.local:9443", }
Same MCP server, different request - same headers minusX-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.
- Shared & verified:device_,asset_groups_,policy_and most oftask_(Automation) work the same on cloud and on-prem.
- Cloud-only modules:incident_,mobile_,wap_,nap_,quarantine_and most ofvuln_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 anESET API error: 404text response with no special handling.
- Path overrides: a few endpoints have a different URL on-prem - e.g.POST /v1/devices/{uuid}:renameis:renameDeviceon-prem. These are declared ineset_mcp/openapi/onprem-path-overrides.jsonand applied automatically when the request targets on-prem.
Cloudflare Access in front of the on-prem console
When the on-prem PROTECT console is exposed via a Cloudflare tunnel and gated byCloudflare Access, MCP can authenticate as aservice token. The chain becomesMCP → 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 theX-ESET-CF-input headers into the actualCF-Access-Client-Id/CF-Access-Client-Secretheaders that Cloudflare Access expects, and attaches them toeveryoutbound call - both thePOST /GetTokensauth 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 requestsnevercarry CF Access headers regardless of env defaults - ESET Connect is a public SaaS.
- X-ESET-Server-URLaccepts onlyhttps://URLs with no path, query or fragment. Trailing slashes are stripped. Anything else → HTTP 400.
- ESET_ONPREM_VERIFY_SSL=falsedisables TLS certificate verification and exposes 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.
- On-prem tokens are held in memory per(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.
- Sending only one of the twoX-ESET-CF-headers returns HTTP 400 rather than silently falling back to the env default (almost certain operator typo).
Theproddocker-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 ESET_ACME_EMAIL=ops@example.com docker compose --profile prod up -d # MCP endpoint: https://eset-mcp.example.com/mcp
- HTTPS on 443 with auto-renewing Let's Encrypt cert.
- HTTP-01 challenge on 80.
- MCP container bound only to the docker bridge network - no published port.
- gzip / zstd compression, JSON access logs on stdout.
Each composite degrades gracefully when a sub-call returns 403/404 (e.g. on tenants missing a module). The shape carriesskipped/truncatedflags where applicable.
- eset://config/mode-ROorRW.
- eset://config/region- current region (per-request in basic-auth mode).
- eset://config/deployment-cloudoronprem (<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 overrides
Sign in to leave a review
Use Google, GitHub, or an email account so ratings stay tied to real people.
No reviews posted yet.




