{
  "markdown": "# ESET-MCP\n\n[![Tests](https://github.com/maciekaz/ESET-MCP/actions/workflows/integration.yml/badge.svg)](https://github.com/maciekaz/ESET-MCP/actions/workflows/integration.yml)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](#license)\n[![MCP spec](https://img.shields.io/badge/MCP%20spec-2025--11--25-informational)](https://modelcontextprotocol.io/specification/2025-11-25)\n\n\nA [Model Context Protocol](https://modelcontextprotocol.io) server for the\nentire ESET management surface: [ESET Connect](https://help.eset.com/eset_connect/en-US/)\n(cloud, all regions), ESET PROTECT On-Prem, ESET Inspect, and ESET Cloud\nOffice. Drive any of them from any MCP host (Claude Desktop, Claude Code,\nor a custom agent) through tools, resources, and prompts.\n\nBuilt as a **single hub for any number of ESET deployments**. One process\nfronts cloud and on-prem consoles at the same time; clients pick the target\nper request via headers. As long as MCP receives valid credentials (Basic\nauth, plus an optional URL override and optional Cloudflare Access service\ntoken) it routes the call to the right backend, mints its own tokens, and\nkeeps tenants isolated in the pool.\n\n> **⚠️ Just to be clear, fellas**  \n> 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.  \n> \n> 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.\n> \n## Table of contents\n\n- [Features](#features)\n- [Architecture at a glance](#architecture-at-a-glance)\n- [Security](#security)\n- [Quick start](#quick-start)\n- [Configuration](#configuration)\n- [Multi-tenant deployment (basic-auth mode)](#multi-tenant-deployment-basic-auth-mode)\n- [On-prem ESET PROTECT support](#on-prem-eset-protect-support)\n- [Production deployment (HTTPS via Caddy)](#production-deployment-https-via-caddy)\n- [Tools, resources & prompts](#tools-resources--prompts)\n- [Architecture](#architecture)\n- [Tests](#tests)\n- [Refreshing the OpenAPI specs](#refreshing-the-openapi-specs)\n- [License](#license)\n\n---\n\n## Features\n\n### Complete API coverage\n\n- **102 tools** auto-generated from 16 official ESET Connect OpenAPI 3.0.1\n  specs, covering application-management, asset-management, automation,\n  device-management, identity, incident-management, installer-management,\n  mobile-device-management, network-access-protection, patch-management,\n  policy-management, quarantine-management, user-management,\n  vulnerability-management, and web-access-protection.\n- **4 high-level composites** that fold 3-6 raw calls into one:\n  `eset_search`, `device_full_profile`, `incident_full_context`,\n  `latest_detections`.\n\n### Read-only / read-write modes\n\n- `ESET_MODE=RO` → catalog exposes **only** read-only tools (51 total).\n  Write tools are hidden from `list_tools` entirely.\n- `ESET_MODE=RW` → all 106 tools advertised; mutating tools carry\n  `destructiveHint: true` in their MCP annotations.\n- Independent of the ESET account's underlying permissions.\n- A defence-in-depth in-memory gate rejects RW tool names in RO mode\n  before any HTTP request is sent.\n\n### Authentication\n\n- `ESET_AUTH_MODE=env` - single tenant, credentials from `.env`.\n- `ESET_AUTH_MODE=basic` - multi tenant, clients pass\n  `Authorization: Basic <base64(user:password)>` per request (plus optional\n  `X-ESET-Region` for a different cloud region, or `X-ESET-Server-URL` to\n  route the request to an on-prem PROTECT console). One server fronts many\n  ESET accounts and can mix cloud + on-prem in the same process.\n- Per-tenant OAuth tokens, pooled and isolated by\n  `(user, password_hash, deployment, region-or-server-url, cf_secret_hash)`.\n  Rotating a password or Cloudflare Access service token mints a fresh\n  client; cloud and on-prem clients for the same user never share a pool entry.\n\n### Transports\n\n- **stdio** - JSON-RPC over stdin/stdout for local hosts.\n- **Streamable HTTP** - the current MCP transport (Nov 2025 spec).\n\n### Multi-region\n\n`eu` / `de` / `us` / `ca` / `jpn`. Fixed via `ESET_REGION` in `env` mode;\nper-request via `X-ESET-Region` in `basic` mode.\n\n### Cloud + on-prem in one process\n\nIn addition to the cloud regions, a single MCP server can front\ncustomer-hosted **ESET PROTECT On-Prem** consoles. The on-prem auth wire\nformat (`POST /GetTokens` with a camelCase response) and per-host URL\nstructure are handled transparently; clients pick the target per request\nvia the `X-ESET-Server-URL` header. See\n[On-prem ESET PROTECT support](#on-prem-eset-protect-support).\n\n### Cloudflare Access (optional)\n\nWhen the on-prem console sits behind a Cloudflare Access tunnel, MCP\nauthenticates as a [service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/)\n(env-default or per-request `X-ESET-CF-Access-Client-Id` /\n`X-ESET-CF-Access-Client-Secret`) and rides through to the origin. The\nCF token is an extra ingress layer in front of - not a replacement for -\nthe ESET account credentials. Cloud requests never carry these headers.\n\n### Resilience\n\n- OAuth2 with proactive refresh ~5 min before token expiry and a forced\n  refresh + retry on 401.\n- 429 retries with exponential backoff (up to 3 attempts, honours\n  `Retry-After`).\n- Pagination (`nextPageToken`) walked transparently.\n- 202 long-polling with the `response-id` header, up to 10 minutes.\n\n### Response shaping (context-window protection)\n\nA single uncapped `list_*` call can return hundreds of KB - enough to\noverflow a model's context. Two transformations are applied to every\ntool response:\n\n- **`fields` projection** - every GET tool exposes an optional\n  `fields: [string]` parameter that filters each list-item down to the\n  requested keys (e.g. `[\"uuid\", \"displayName\"]`). Applied server-side\n  after fetch.\n- **Byte cap** (`ESET_MCP_RESPONSE_BYTES_MAX`, default 100 KB) - if a\n  payload still exceeds the budget, the longest list is trimmed while\n  every top-level field (`nextPageToken`, `totalSize`, …) is preserved,\n  and a `_capped` metadata block is attached with an actionable hint\n  on how to continue. Agents retain full access to the data through\n  pagination.\n\n### Agent-friendly errors\n\nHTTP errors are mapped to readable hints: 403 → check Permission Sets in\nESET PROTECT Hub; 401 → server refreshes the token automatically; 429 →\nback off; 5xx → retry shortly.\n\n### Observability (logs + Prometheus)\n\n- **Structured logs** to stderr. Text (default) for dev, JSON Lines for\n  prod log shippers via `ESET_MCP_LOG_FORMAT=json`. Every tool call,\n  token refresh, HTTP retry and pool eviction emits a typed `event`\n  record with low-cardinality fields (tool, deployment, status,\n  duration_ms, response_bytes, ...).\n- **Prometheus metrics** at an opt-in `/metrics` endpoint\n  (`ESET_MCP_METRICS_ENABLED=true`, requires\n  `pip install eset-mcp[metrics]`). Counters for tool calls, token\n  refreshes, HTTP retries, cap hits; histograms for tool duration and\n  response sizes; gauge for client pool size.\n- **What never enters logs or metrics**: passwords, `Authorization`\n  headers, CF Access secrets, request/response bodies, query strings,\n  substituted path parameters (which can leak UUIDs). A defensive\n  deny-list in the logger strips known-sensitive keys before any\n  formatter sees them.\n- **Failure isolation**: telemetry emission is wrapped in a try/except so\n  a broken metrics registry or formatter can never turn a successful\n  tool call into an error to the agent. `/metrics` returns 500 (not 503)\n  if exposition ever raises - the worker stays up.\n- **Quieting in production**: set `ESET_LOG_LEVEL=WARNING` to mute the\n  per-call INFO events but keep retries / errors visible; set\n  `ESET_LOG_LEVEL=ERROR` to silence everything but hard failures.\n  Disable metrics entirely with `ESET_MCP_METRICS_ENABLED=false`\n  (default). The three knobs are independent.\n\n---\n\n## Architecture at a glance\n\nThe simplest setup: credentials in `.env`, one MCP host, one ESET cloud\nregion. Good for personal use, a single team, or a desktop AI client like\nClaude Desktop or Claude Code.\n\n![Single-tenant: one ESET account, one deployment](docs/architecture-single-tenant.svg)\n\nFor real multi-tenant or enterprise deployments, put a credentials manager\nin front of ESET-MCP. The diagram below shows one such pattern using\n[IBM mcp-context-forge](https://github.com/IBM/mcp-context-forge) as the\n\"creds management\" layer - any equivalent MCP gateway (or your own auth\nproxy) works the same way:\n\n![Multi-tenant: many clients, one MCP, many backends](docs/architecture-multi-tenant.svg)\n\nThe forge holds per-tenant secrets and injects `Authorization: Basic`,\n`X-ESET-Region`, `X-ESET-Server-URL`, and `X-ESET-CF-Access-*` headers per\nrequest. ESET-MCP routes each request to the right backend (cloud region,\non-prem PROTECT console, or on-prem behind Cloudflare Access) and the\nper-tenant LRU client pool keeps OAuth tokens fully isolated between\ntenants. This is a working pattern, not aspirational - the headers, pool\nkeys, and routing rules described here are all in the test suite under\n`tests/test_concurrency.py` and `tests/test_onprem.py`.\n\n---\n\n## Security\n\n### Credentials\n\n- In `env` mode the password is read once at startup and kept in memory.\n- In `basic` mode the password is on the wire only for the duration of\n  the request, and in memory only while the per-tenant client is hot\n  in the LRU pool. It is **never logged**.\n- The pool key uses a SHA-256 hash of the password rather than the\n  password itself.\n- OAuth access/refresh tokens are held per-tenant; tokens never cross\n  tenant boundaries within a single session.\n\n### Authentication modes & transport\n\n| Mode    | Transport allowed | Credentials source                       |\n|---------|-------------------|------------------------------------------|\n| `env`   | stdio or http     | `.env` (`ESET_USER` / `ESET_PASSWORD`)   |\n| `basic` | http only         | `Authorization: Basic` header per request |\n\n`basic` mode over plain HTTP would leak passwords. The server enforces\nHTTP transport for `basic` mode at startup but does **not** enforce TLS -\nthat is the deployment's job. The `prod` docker-compose profile fronts\nthe server with Caddy + Let's Encrypt.\n\nMissing / malformed `Authorization` in `basic` mode → HTTP 401 with a\n`WWW-Authenticate: Basic` challenge. Unknown region in `X-ESET-Region`\n→ HTTP 401.\n\n### RO / RW isolation\n\nTwo independent layers:\n\n1. **Catalog hiding** - `list_tools` filters out every non-GET tool in\n   RO mode. The agent never sees write tools.\n2. **Defence-in-depth gate** - `call_tool` validates the tool's declared\n   mode against `ESET_MODE` before any HTTP request goes out. Hard-coded\n   clients, prompt-injection attempts, and stale agent snapshots all hit\n   the gate and receive a structured `ModeForbiddenError` text response\n   (no exception, no network call).\n\nIn RW mode, mutating tools carry `destructiveHint: true` so MCP hosts\nthat respect annotations can require a per-call confirmation.\n\n### Per-tenant isolation (basic-auth mode)\n\n- Auth headers are parsed in dedicated ASGI middleware and stashed in a\n  `ContextVar`; they never enter request bodies or logs.\n- Each request resolves to a `Credentials` instance keyed by\n  `(user, password_hash, region)`.\n- An LRU bound on the client pool prevents unbounded memory growth from\n  random-credential spraying.\n\n### Network surface\n\n- In dev (`docker compose up`) the MCP server publishes `:8765`.\n- In the `prod` profile the MCP container has **no published port** -\n  Caddy joins the same docker bridge network and proxies HTTPS in. The\n  only host ports are 80 (HTTP-01 ACME) and 443 (HTTPS).\n- No outbound traffic except to `*.eset.systems` (auth + APIs).\n\n### Dependency & code audit\n\n- **Snyk Code**: 0 issues in `eset_mcp/`.\n- **Ruff**: clean (`select = E F W I B UP RUF`).\n- Runtime dependencies: `mcp`, `httpx`, `pydantic`, `python-dotenv`.\n  Plus `starlette` + `uvicorn` when running HTTP.\n\n### Out of scope (by design)\n\n- No webhook receivers.\n- No persistent storage; logs go to stdout.\n- No on-disk caching of OAuth tokens.\n- No write-back of `basic`-mode credentials to disk.\n\n### Responsible disclosure\n\nPlease open a private security advisory rather than a public issue:\n<https://github.com/maciekaz/ESET-MCP/security/advisories/new>.\n\n---\n\n## Quick start\n\nThe fastest path is the published Docker image. No Python install, no\nvenv, no source checkout - just `.env` + `docker run`. The image is\nmulti-arch (amd64 + arm64), signed with cosign, ships with SBOM +\nbuild provenance, and is published to GHCR on every release.\n\n```bash\ncp .env.example .env          # fill in ESET_USER / ESET_PASSWORD / ESET_REGION\ndocker run --rm -i --env-file .env ghcr.io/maciekaz/eset-mcp:1\n```\n\nPin policy:\n\n- `:1` - latest 1.x.x (auto-updates within the major)\n- `:1.0` - latest 1.0.x (auto-updates within the minor)\n- `:1.0.1` - exact version (production)\n- `:latest` - most recent stable release\n- `:main` / `:sha-<short>` - edge builds from main (not for production)\n\n### Wire up to Claude Desktop / Claude Code (stdio)\n\n```jsonc\n// claude_desktop_config.json\n{\n  \"mcpServers\": {\n    \"eset\": {\n      \"command\": \"docker\",\n      \"args\": [\"run\", \"--rm\", \"-i\", \"--env-file\", \"/absolute/path/to/.env\",\n               \"ghcr.io/maciekaz/eset-mcp:1\"]\n    }\n  }\n}\n```\n\n### HTTP transport (single-tenant or behind your own proxy)\n\n```bash\ndocker run -d --name eset-mcp \\\n  --env-file .env -p 8765:8765 \\\n  -e ESET_MCP_TRANSPORT=http \\\n  ghcr.io/maciekaz/eset-mcp:1\n# MCP endpoint: http://localhost:8765/mcp\n```\n\n### Verify the image (cosign keyless)\n\n```bash\ncosign verify \\\n  --certificate-identity-regexp '^https://github.com/maciekaz/ESET-MCP/' \\\n  --certificate-oidc-issuer https://token.actions.githubusercontent.com \\\n  ghcr.io/maciekaz/eset-mcp:1\n```\n\n### From source (contributors / local hacking)\n\n```bash\ngit clone https://github.com/maciekaz/ESET-MCP.git\ncd ESET-MCP\ncp .env.example .env\npython -m venv .venv && source .venv/bin/activate\npip install -e \".[dev]\"\neset-mcp\n```\n\n### Docker Compose (uses the published image)\n\n```bash\ndocker compose up -d eset-mcp-http\n# MCP endpoint: http://localhost:8765/mcp\n```\n\nBy default the compose file pulls `ghcr.io/maciekaz/eset-mcp:1` - no\nlocal build, fast first start. Pin a specific version by editing the\n`image:` line in `docker-compose.yml`.\n\nOne-off stdio via compose:\n\n```bash\ndocker compose --profile stdio run --rm eset-mcp-stdio\n```\n\nHacking on the source? Use the dev profile to build from your local\ncheckout instead of pulling:\n\n```bash\ndocker compose --profile dev up --build eset-mcp-http-dev\n```\n\n---\n\n## Configuration\n\nAll settings live in `.env`. Required fields are marked in\n[`.env.example`](.env.example).\n\n| Variable                       | Default       | Purpose                                                       |\n|--------------------------------|---------------|---------------------------------------------------------------|\n| `ESET_AUTH_MODE`               | `env`         | `env` (single tenant) or `basic` (multi tenant)               |\n| `ESET_USER`                    | -             | API user (required in `env` mode)                             |\n| `ESET_PASSWORD`                | -             | API password (required in `env` mode)                         |\n| `ESET_MODE`                    | `RO`          | `RO` (read-only catalog) or `RW`                              |\n| `ESET_REGION`                  | `eu`          | `eu` / `de` / `us` / `ca` / `jpn`                             |\n| `ESET_MCP_TRANSPORT`           | `stdio`       | `stdio` or `http`                                             |\n| `ESET_MCP_HTTP_HOST`           | `127.0.0.1`   | HTTP bind address                                             |\n| `ESET_MCP_HTTP_PORT`           | `8765`        | HTTP port                                                     |\n| `ESET_MCP_RESPONSE_BYTES_MAX`  | `100000`      | Per-call response byte cap; `0` disables                      |\n| `ESET_LOG_LEVEL`               | `INFO`        | `DEBUG` / `INFO` / `WARNING` / `ERROR`                        |\n| `ESET_MCP_LOG_FORMAT`          | `text`        | `text` (dev, human-readable) or `json` (prod log shippers)    |\n| `ESET_MCP_METRICS_ENABLED`     | `false`       | Mount Prometheus `/metrics`; requires `eset-mcp[metrics]`     |\n| `ESET_MCP_METRICS_PATH`        | `/metrics`    | Where to mount the metrics endpoint                           |\n| `ESET_DEPLOYMENT`              | `cloud`       | `cloud` (ESET Connect) or `onprem` (customer-hosted PROTECT)  |\n| `ESET_ONPREM_SERVER_URL`       | -             | `https://host[:port]` of the on-prem console (req. in env+onprem) |\n| `ESET_ONPREM_VERIFY_SSL`       | `true`        | Set `false` for on-prem consoles with self-signed certs       |\n| `ESET_ONPREM_CF_ACCESS_CLIENT_ID` | -          | Cloudflare Access Service Token client-id (on-prem behind CF) |\n| `ESET_ONPREM_CF_ACCESS_CLIENT_SECRET` | -      | Cloudflare Access Service Token client-secret (paired with the above) |\n| `ESET_PUBLIC_DOMAIN`           | -             | Domain Caddy issues a TLS cert for (`prod` profile only)      |\n| `ESET_ACME_EMAIL`              | -             | Email Let's Encrypt uses for renewals (`prod` profile only)   |\n\n> Use a **dedicated API user** - not your console login. Create one in\n> ESET PROTECT Hub / ESET Business Account → API users.\n\n---\n\n## Multi-tenant deployment (basic-auth mode)\n\n```bash\n# .env\nESET_AUTH_MODE=basic\nESET_MCP_TRANSPORT=http\nESET_REGION=eu   # default region; clients can override per request\n```\n\nEvery HTTP request must carry:\n\n| Header              | Required | Notes                                                                  |\n|---------------------|----------|------------------------------------------------------------------------|\n| `Authorization`     | yes      | `Basic <base64(user:password)>`                                        |\n| `X-ESET-Region`     | no       | Override default region (`eu`/`de`/`us`/`ca`/`jpn`)                    |\n| `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](#on-prem-eset-protect-support) |\n| `X-ESET-CF-Access-Client-Id` | no | Cloudflare Access Service Token client-id (on-prem behind CF Access) |\n| `X-ESET-CF-Access-Client-Secret` | no | Paired with the above - both must be sent together |\n\nExample Python client:\n\n```python\nimport base64\nfrom mcp import ClientSession\nfrom mcp.client.streamable_http import streamablehttp_client\n\ntoken = base64.b64encode(b\"api-user@tenant.tld:secret\").decode()\nheaders = {\"Authorization\": f\"Basic {token}\", \"X-ESET-Region\": \"us\"}\n\nasync with streamablehttp_client(\n    \"https://eset-mcp.example.com/mcp/\", headers=headers\n) as (r, w, _):\n    async with ClientSession(r, w) as session:\n        await session.initialize()\n        tools = await session.list_tools()\n```\n\n> ⚠️ **Basic auth without TLS leaks credentials.** Always run `basic`\n> mode behind HTTPS.\n\n---\n\n## On-prem ESET PROTECT support\n\nESET ships PROTECT as both a cloud service (the ESET Connect API at\n`*.eset.systems`) and an on-prem console customers self-host. The on-prem\nREST API lives on a single host (default port `9443`) and uses a different\nauthentication endpoint - `POST /GetTokens` with a JSON body and a\ncamelCase response - but otherwise shares the URL structure of the cloud\nAPI. ESET-MCP supports both, **in the same process**.\n\n### How the server decides cloud vs on-prem\n\n| `ESET_AUTH_MODE` | What controls the deployment per request                                               |\n|------------------|----------------------------------------------------------------------------------------|\n| `env`            | Static: `ESET_DEPLOYMENT` (cloud) or `ESET_DEPLOYMENT=onprem` + `ESET_ONPREM_SERVER_URL` |\n| `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) |\n\nSo a single MCP server can front the cloud for most clients **and** route\nspecific requests to one or more on-prem consoles - keyed entirely by which\nURL each client sends in `X-ESET-Server-URL`.\n\n### Single-tenant on-prem (env mode)\n\n```bash\n# .env\nESET_AUTH_MODE=env\nESET_DEPLOYMENT=onprem\nESET_ONPREM_SERVER_URL=https://protect.company.local:9443\nESET_ONPREM_VERIFY_SSL=true     # set to false only for self-signed certs you trust\nESET_USER=api-user@company.local\nESET_PASSWORD=...\n```\n\n### Multi-tenant on-prem (basic auth, per-request URL)\n\n```bash\n# .env\nESET_AUTH_MODE=basic\nESET_MCP_TRANSPORT=http\nESET_DEPLOYMENT=cloud            # default; clients opt into on-prem per-request\n# ESET_ONPREM_SERVER_URL is optional - if set it becomes the on-prem default\n```\n\nClient targeting on-prem:\n\n```python\nheaders = {\n    \"Authorization\": f\"Basic {token}\",\n    \"X-ESET-Server-URL\": \"https://protect.client-a.local:9443\",\n}\n```\n\nSame MCP server, different request - same headers minus `X-ESET-Server-URL`\n- stays on cloud.\n\n### What works on on-prem vs cloud\n\nThe tool catalog is identical for both deployments (all 102 OpenAPI-derived\ntools plus the 4 composites). At call time the server uses cloud paths for\ncloud credentials and on-prem paths for on-prem credentials.\n\n- **Shared & verified**: `device_*`, `asset_groups_*`, `policy_*` and most\n  of `task_*` (Automation) work the same on cloud and on-prem.\n- **Cloud-only modules**: `incident_*`, `mobile_*`, `wap_*`, `nap_*`,\n  `quarantine_*` and most of `vuln_*` correspond to separate ESET products\n  (ESET Inspect, Cloud Office Security, MDM) that are not part of the\n  on-prem PROTECT installation. Calling them against an on-prem console\n  returns a plain 404 from ESET - surfaced to the agent as an\n  `ESET API error: 404` text response with no special handling.\n- **Path overrides**: a few endpoints have a different URL on-prem - e.g.\n  `POST /v1/devices/{uuid}:rename` is `:renameDevice` on-prem. These are\n  declared in [`eset_mcp/openapi/onprem-path-overrides.json`](eset_mcp/openapi/onprem-path-overrides.json)\n  and applied automatically when the request targets on-prem.\n\n### Cloudflare Access in front of the on-prem console\n\nWhen the on-prem PROTECT console is exposed via a Cloudflare tunnel and\ngated by **Cloudflare Access**, MCP can authenticate as a\n[service token](https://developers.cloudflare.com/cloudflare-one/access-controls/service-credentials/service-tokens/).\nThe chain becomes **MCP → Cloudflare Access → ESET on-prem**.\n\nTwo values per token pair, supplied either via `.env`:\n\n```bash\nESET_ONPREM_CF_ACCESS_CLIENT_ID=abc1234567890.access\nESET_ONPREM_CF_ACCESS_CLIENT_SECRET=<long-secret>\n```\n\n…or per-request in basic-auth mode (overrides the env defaults - handy\nwhen each tenant has its own tunnel and its own service token):\n\n```python\nheaders = {\n    \"Authorization\": f\"Basic {token}\",\n    \"X-ESET-Server-URL\": \"https://protect.client-a.local:9443\",\n    \"X-ESET-CF-Access-Client-Id\": \"abc1234567890.access\",\n    \"X-ESET-CF-Access-Client-Secret\": \"<long-secret>\",\n}\n```\n\nMCP translates the `X-ESET-CF-*` input headers into the actual\n`CF-Access-Client-Id` / `CF-Access-Client-Secret` headers that Cloudflare\nAccess expects, and attaches them to **every** outbound call - both the\n`POST /GetTokens` auth handshake and every subsequent ESET API request.\n\nThe CF secret is treated like the password: never logged, only its SHA-256\nhash enters the client pool key. Rotating the secret mints a fresh client\n+ fresh ESET token. Cloud requests **never** carry CF Access headers\nregardless of env defaults - ESET Connect is a public SaaS.\n\n### Security notes for on-prem\n\n- `X-ESET-Server-URL` accepts only `https://` URLs with no path, query or\n  fragment. Trailing slashes are stripped. Anything else → HTTP 400.\n- `ESET_ONPREM_VERIFY_SSL=false` disables TLS certificate verification and\n  exposes the connection to MITM. The server logs a single WARNING per\n  client construction when it's disabled. Use only on trusted intranets\n  with self-signed certs you cannot replace.\n- On-prem tokens are held in memory per\n  `(user, password_hash, server_url, cf_secret_hash)` - same isolation\n  rules as cloud tokens. The pool keys them separately so cloud and\n  on-prem clients never collide, and two clients hitting the same on-prem\n  URL with different CF service tokens get separate pool entries.\n- Sending only one of the two `X-ESET-CF-*` headers returns HTTP 400\n  rather than silently falling back to the env default (almost certain\n  operator typo).\n\n---\n\n## Production deployment (HTTPS via Caddy)\n\nThe `prod` docker-compose profile launches Caddy in front of the MCP\nserver. Caddy fetches a Let's Encrypt cert on first start (HTTP-01\nchallenge - ports 80 / 443 must be reachable from the public internet)\nand proxies HTTPS to the internal MCP container.\n\n```bash\n# .env\nESET_AUTH_MODE=basic\nESET_PUBLIC_DOMAIN=eset-mcp.example.com\nESET_ACME_EMAIL=ops@example.com\n\ndocker compose --profile prod up -d\n# MCP endpoint: https://eset-mcp.example.com/mcp\n```\n\nYou get:\n\n- HTTPS on 443 with auto-renewing Let's Encrypt cert.\n- HTTP-01 challenge on 80.\n- MCP container bound only to the docker bridge network - no published port.\n- gzip / zstd compression, JSON access logs on stdout.\n\n---\n\n## Tools, resources & prompts\n\n### Composite high-level tools\n\n| Tool                                                    | Returns                                                                          |\n|---------------------------------------------------------|----------------------------------------------------------------------------------|\n| `eset_search(query, kinds?, limit_per_kind?)`           | Case-insensitive substring matches across devices / users / policies / groups    |\n| `device_full_profile(deviceUuid)`                       | Device record + recent detections + vulnerabilities + recent scans               |\n| `incident_full_context(incidentUuid)`                   | Incident + comments + related detections + affected devices                      |\n| `latest_detections(hours=24, limit=10, severity_min?)`  | Newest detections in a time window, sorted by `occurTime` desc; v2 → v1 fallback |\n\nEach composite degrades gracefully when a sub-call returns 403/404\n(e.g. on tenants missing a module). The shape carries `skipped` /\n`truncated` flags where applicable.\n\n### Resources\n\n- `eset://config/mode` - `RO` or `RW`.\n- `eset://config/region` - current region (per-request in basic-auth mode).\n- `eset://config/deployment` - `cloud` or `onprem (<server-url>)` for this request.\n- `eset://config/tools-catalog` - JSON catalog of all 106 tools (name,\n  mode, method, path, service, description).\n- `eset://docs/rate-limits` - quick reminder about the 10 req/s ceiling.\n\n### Prompts\n\n- `audit_inactive_devices(days=30)` - offboarding candidates.\n- `vulnerability_report` - per-device CVE report.\n- `incident_triage` - open incidents + related detections.\n\n---\n\n## Architecture\n\n```\neset_mcp/\n├── __main__.py         # entrypoint - stdio or HTTP, wires resolver + pool\n├── server.py           # MCP server (tools / resources / prompts) + telemetry\n├── credentials.py      # Credentials + EnvResolver / BasicAuthResolver + ContextVar\n├── middleware.py       # ASGI Basic-auth middleware (basic mode only)\n├── client_pool.py      # LRU pool of EsetHttpClient keyed by (user, region, ...)\n├── http_client.py      # async httpx + 202 polling + 429 retry + 401 refresh\n├── auth.py             # CloudTokenManager (OAuth2) + OnPremTokenManager (/GetTokens)\n├── regions.py          # cloud region → per-service domains + on-prem URL resolver\n├── modes.py            # RO/RW gate\n├── errors.py           # HTTP error → agent-friendly text\n├── config.py           # .env loading\n├── response_shaping.py # fields projection + byte cap\n├── composite_tools.py  # hand-written high-level tools\n├── tools_loader.py     # generator: tools from OpenAPI specs + on-prem path overrides\n├── observability/      # JSON/text structured logging + Prometheus metrics\n└── openapi/            # 16 ESET Connect OpenAPI 3.0.1 specs + onprem path overrides\n```\n\n---\n\n## Tests\n\n```bash\npytest                  # full suite (RO smoke + unit + integration)\npytest -m \"not rw\"      # RO only (default in CI)\npytest -m rw            # RW (requires an account with RW permissions)\n```\n\nIntegration tests hit a real ESET tenant - credentials supplied via the\nsame `.env`. CI workflow:\n[`.github/workflows/integration.yml`](.github/workflows/integration.yml)\nruns on PR, on push to `main`, and once a day at 03:17 UTC. The cron\ncatches drift between the server and ESET's published OpenAPI specs.\n\n---\n\n## Refreshing the OpenAPI specs\n\n```bash\ncd eset_mcp/openapi\nfor name in business-account application-management asset-management automation \\\n            device-management iam incident-management installer-management \\\n            mobile-device-management network-access-protection patch-management \\\n            policy-management quarantine-management user-management \\\n            vulnerability-management web-access-protection; do\n  curl -sO \"https://eu.esetconnect.eset.systems/swagger/api/${name}.json\"\ndone\n```\n\n`tests/test_catalog_vs_openapi.py` flags any new or changed operations\nafter a refresh.\n\n---\n\n## License\n\nMIT\n",
  "bytes": 29633,
  "sha": "92f5adec825e4e68043edb26230db8262992f4ff4b225db3e7f9c5ccd7af886f",
  "repo_slug": "maciekaz/eset-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_maciekaz_eset_mcp_907de964/readme"
}