Inventory Guard MCP Server

Inventory Guard MCP Server

Enables AI agents like Claude Desktop to read vulnerability reports, trigger scans, and manage findings for the Inventory Guard self-hosted vulnerability watchdog.

Category
Visit Server

README

Inventory Guard (invguard)

A self-hosted vulnerability watchdog for the hardware and software you actually own.

You tell it what's in your rack. It watches the public CVE firehose — NVD, CVE List V5, OSV, CISA KEV, EPSS, Cisco PSIRT, MSRC, CSAF vendors — and tells you when something that matters to your boxes shows up. Nothing else.

It runs in one container, on one machine, behind loopback. It never scans your network. It never can.

Version 1.7.2 · Python 3.12 / FastAPI / SQLite · Docker Compose · MCP server included

Note on language: the web UI, CLI output and error messages are in Ukrainian. The codebase, API and this document are in English. Internationalization is on the roadmap, not in the box.


Table of contents


The design stance

Most vulnerability tooling starts by scanning your network. Inventory Guard starts by refusing to.

The inventory is maintained by hand — web forms, CSV import, or an AI agent acting on your explicit instruction. There is no discovery, no SNMP, no SSH polling, no agents on endpoints. This isn't a missing feature; it's the product. A security tool that opens connections into your infrastructure is a security tool that can be turned against it.

Three invariants follow from that, and they're enforced in code and in tests:

Invariant Enforcement
The app never initiates connections into your internal network httpguard rejects literal IPs and all private/link-local ranges, on the resolved address, redirects included
Only public identifiers leave the machine Outbound URL and body are scanned; anything matching name, role, notes, host_label or an IP address is refused before the socket opens
A feed failure degrades, never cascades Each connector is wrapped; a failure lands in feeds_failed with a human-readable reason, the run continues on cache, and the report marks that source as stale

What actually goes over the wire: CPE strings, PURLs, version numbers, package names. Public classifiers. Nothing that describes your topology.


What it does

  • Manual inventory of devices (vendor / model / OS family / version → CPE 2.3) and software packages (name / ecosystem / version → PURL), with cascading autocomplete backed by the official NIST CPE dictionary and ecosystem registries.
  • Incremental feed sync — full CVE corpus on first run, cursor-based deltas after that.
  • Version-range matching across CPE ranges and ecosystem version schemes, with source priority vendor > NVD > OSV and provenance recorded per match.
  • Scheduled, triggered and manual scans — weekly by default, 60-second debounce after inventory edits, or on demand.
  • Markdown reports stored in SQLite with a one-way file mirror, plus a diff of new/closed findings per run.
  • Alerts on KEV membership, CVSS ≥ 7.0 or EPSS ≥ 0.5 (all configurable), deduplicated per finding.
  • Findings workflow — open → acknowledged → fixed | false_positive, append-only history.
  • MCP server so Claude Desktop or any other agent can read the report, run a scan and triage findings.
  • CSV/JSON export and a full database dump endpoint.

Architecture

One process, one SQLite file, three ways in.

graph TB
    subgraph clients["Clients"]
        UI["Web UI<br/><i>vanilla ES modules</i>"]
        CURL["REST clients<br/><i>scripts, CI</i>"]
        AGENT["AI agents<br/><i>Claude Desktop, IDEs</i>"]
    end

    subgraph app["invguard container - 127.0.0.1:8480"]
        MW["Bearer-token middleware<br/><i>everything except /health and /</i>"]
        ROUTERS["FastAPI routers<br/><i>thin: zero logic</i>"]
        MCP["FastMCP server<br/><i>/mcp + stdio shim</i>"]

        subgraph domain["Domain"]
            INV["inventory<br/>service, importer, catalog"]
            SCAN["compliance<br/>scanner, reporter, alerts, scheduler"]
            MATCH["matching<br/>engine, versions"]
            KB["kb<br/>worker, store, sources"]
            FEEDS["feeds<br/>connector registry"]
        end

        GUARD["httpguard<br/><i>the only door outward</i>"]
        DB[("SQLite WAL<br/>invguard.db<br/><i>0600, self-migrating</i>")]
    end

    EXT["Public feeds<br/><i>NVD, CVE V5, OSV, KEV, EPSS,<br/>Cisco, MSRC, CSAF</i>"]
    FS["/reports<br/><i>one-way markdown mirror</i>"]

    UI --> MW
    CURL --> MW
    AGENT --> MCP
    MW --> ROUTERS
    ROUTERS --> INV
    ROUTERS --> SCAN
    ROUTERS --> MATCH
    ROUTERS --> KB
    MCP --> INV
    MCP --> SCAN
    MCP --> MATCH
    SCAN --> FEEDS
    KB --> GUARD
    FEEDS --> GUARD
    GUARD --> EXT
    INV --> DB
    SCAN --> DB
    MATCH --> DB
    KB --> DB
    FEEDS --> DB
    SCAN --> FS

Why these choices, briefly. SQLite because this is a single-user local service and one file is a trivial backup — access goes through a repository layer so a PostgreSQL move wouldn't touch the domain. Vanilla JS because a security tool shouldn't ship a build chain and a node_modules supply chain with it. One uvicorn worker because a shared DB connection and a shared scheduler beat horizontal scaling for a workload measured in dozens of inventory items. No Celery/Redis for background jobs — the queue lives in the same SQLite database (kb_jobs) with an asyncio worker in the app lifespan, which survives restarts without two extra containers.


How a scan actually runs

Three triggers, one pipeline, exactly one run at a time.

flowchart TD
    T1["cron<br/><i>every N hours, default 168</i>"] --> LOCK
    T2["inventory changed<br/><i>60s debounce</i>"] --> LOCK
    T3["manual / scan_now<br/><i>UI, REST, agent</i>"] --> LOCK

    LOCK{{"asyncio.Lock + flock<br/><i>single-flight across processes</i>"}}
    LOCK --> RUN["INSERT scan_runs<br/><i>trigger, scope</i>"]

    RUN --> FACTS["Phase 1 - FACT feeds, sequential<br/>cvelist_v5, cisco_openvuln, msrc,<br/>csaf, nvd, osv"]
    FACTS --> STORE[("advisories + affected<br/><i>cached corpus</i>")]
    STORE --> ENGINE["matching.engine.sync()<br/><i>exactly once</i>"]
    ENGINE --> DIFF["diff vs previous run<br/><i>new, closed, total_open</i>"]
    DIFF --> ENRICH["Phase 2 - ENRICHMENT feeds<br/>cisa_kev, epss, premium<br/><i>only for advisories that actually matched</i>"]
    ENRICH --> FINISH["UPDATE scan_runs<br/><i>feeds_ok / feeds_failed snapshot</i>"]
    FINISH --> REPORT["reporter.generate()<br/><i>markdown to DB + atomic file mirror</i>"]
    REPORT --> ALERTS["alerts.process()<br/><i>KEV, or CVSS 7.0+, or EPSS 0.5+</i>"]

    FACTS -. "connector raises" .-> FAIL["feeds_failed[]<br/><i>run continues on cache,<br/>report marks the stale source</i>"]
    FAIL -.-> ENGINE

Two ordering decisions worth calling out. Facts before enrichment: KEV and EPSS are asked only about advisories that already matched something you own — no point pulling exploit probability for CVEs irrelevant to your rack. Matching runs exactly once, between the phases; enrichment doesn't change affected, so a second sync would only zero out the "new findings" counter.

Passing target_ids to scan_now narrows the run to specific inventory items — seconds instead of minutes, useful right after adding one device.


Data sources

Connector Source Auth Role
cvelist_v5 CVE List V5 (MITRE bulk releases) none Core corpus, incremental by release cursor
nvd NVD API 2.0 optional free key CPE configurations with version ranges — the backbone of hardware matching
osv OSV.dev none Software packages by PURL (PyPI, npm, Go, Maven, deb, rpm)
cisa_kev CISA Known Exploited Vulnerabilities none "Exploited in the wild" flag — always alerts
epss FIRST.org EPSS none Exploitation probability 0–1, drives report ordering
cisco_openvuln Cisco PSIRT openVuln OAuth2 (free registration) Queries by exact IOS/IOS-XE/NX-OS/ASA version — more precise than CPE matching, returns first-fixed
msrc Microsoft MSRC none Windows: matches against FixedBuild per remediation
csaf Any CSAF 2.0 provider none One parser covers Dell, VMware, Juniper, Fortinet, Siemens… configure provider URLs in settings
vulners Vulners (premium) API key Vendor bulletin aggregation, exploit markers
vulncheck VulnCheck (premium) API key Extended KEV, exploit intelligence

Without an NVD API key you're rate-limited to roughly 5 requests per 30 seconds and the first corpus build takes a long while. The key is free and takes two minutes: https://nvd.nist.gov/developers/request-an-api-key. Roughly a tenfold speedup.

Premium connectors are dark by default. They don't appear in a run, don't register their egress domains, and can't open a socket until you enter every required key. Deleting the key disables them instantly, no restart. Data from them only enriches records (enrichment_json) — a match found solely by a premium source is tagged matched_by='<source>' and badged in the UI, so every decision stays auditable.

MikroTik RouterOS and Supermicro BMC/BIOS have no vendor API. They're covered through NVD CPE + CVE List V5, and the report says so honestly: matched_by='nvd_cpe'.


The matching pipeline

flowchart LR
    subgraph inv["Your inventory"]
        D["Device<br/><i>cisco / c9300 / ios_xe / 17.6.4</i>"]
        P["Package<br/><i>pypi / requests / 2.28.1</i>"]
    end

    D --> CPE["CPE 2.3<br/><i>cpe:2.3:o:cisco:ios_xe:17.6.4</i>"]
    P --> PURL["PURL<br/><i>pkg:pypi/requests@2.28.1</i>"]

    AFF[("affected<br/>cpe_or_purl + version_range_json")]

    CPE --> CMP{"versions.compare()<br/><i>range boundaries, epochs,<br/>release suffixes,<br/>Including / Excluding</i>"}
    PURL --> CMP
    AFF --> CMP

    CMP -->|hit| M["match<br/><i>matched_by = vendor / nvd_cpe / osv / csaf</i>"]
    CMP -->|no hit| SKIP["ignored"]

    M --> PRIO{"conflicting sources?"}
    PRIO -->|"vendor beats nvd beats osv"| KEEP["keep the most authoritative<br/><i>the vendor knows its own fixed versions</i>"]

Version comparison is the part that quietly decides whether the whole product is useful. Boundary conditions (versionEndExcluding vs versionEndIncluding), RPM epochs, release suffixes and vendor formats like 15.2(2)E each have dedicated tests — a false "no vulnerabilities" is the worst possible output of a tool like this.


Finding lifecycle

stateDiagram-v2
    [*] --> open : matcher finds it
    open --> acknowledged : you take it on
    open --> fixed : version bumped
    open --> false_positive : comment required
    acknowledged --> fixed
    acknowledged --> false_positive : comment required
    fixed --> open : reappears in a later run
    false_positive --> [*]
    fixed --> [*]

    note right of open
        Every transition is appended to
        match_history. Nothing is ever
        overwritten.
    end note

Deleting a device closes its open findings with a "device decommissioned" marker rather than dropping the rows.


Egress guard

Every outbound request — including each hop of a redirect chain — passes three independent checks before a socket is opened.

flowchart TD
    REQ["Outbound request<br/><i>from a connector or the KB worker</i>"] --> C1{"1. Host on the allowlist?<br/><i>core domains + egress_domains<br/>of ENABLED connectors only</i>"}
    C1 -->|no| BLOCK["GuardError<br/><i>reason surfaces in the run report</i>"]
    C1 -->|yes| C2{"2. Literal IP, or resolves to<br/>private / loopback / link-local?"}
    C2 -->|yes| BLOCK
    C2 -->|no| C3{"3. Do URL or body contain<br/>name / role / notes / host_label<br/>or any IP address?"}
    C3 -->|yes| BLOCK
    C3 -->|no| SEND["Send"]
    SEND --> REDIR{"redirect?"}
    REDIR -->|yes| C1
    REDIR -->|no| DONE["Response"]

Check 3 has a subtlety that bit us in 1.0.1: a device with the role dell or the name cisco was blocking every legitimate NVD request for that vendor. The guard now maintains a set of public tokens — CPE components, PURL components, vendor and OS-family dictionary entries — that are legal to send outward.

The single documented exception to the private-address rule is the alert webhook, and it's restricted to loopback: alerts are not permitted to leave the machine.


Installation

Requirements

  • Docker with Compose v2 (docker compose version), or the legacy docker-compose
  • bash and curl on the host — the install scripts need both. Most distributions ship them; Alpine and some minimal container images don't (apk add bash curl)
  • ~2 GB free disk for the CVE corpus, 1 GB RAM available to the container
  • Outbound HTTPS to the feed domains

On native Linux, your user must be able to talk to the Docker socket. If docker info fails with permission denied:

sudo usermod -aG docker "$USER"    # then log out and back in, or: newgrp docker

install.sh checks this up front and prints that command rather than failing later with something unrelated.

Quick start

git clone https://github.com/<you>/invguard.git
cd invguard
bash install.sh

install.sh checks Docker and curl, creates data/, reports/, upd/, backups/, writes .env, builds and starts the container, waits for health, and prints your access token. It's idempotent — re-running it never loses data.

Why there's a generated .env

This is the one piece of setup that genuinely differs between platforms, so it's worth thirty seconds of your attention.

The container writes to ./data and ./reports through bind mounts. On Docker Desktop (macOS, Windows) the file-sharing layer rewrites ownership, so whatever UID runs inside the container appears as you on the host. On native Linux there is no such translation — UIDs are literal. A container running as UID 10001 simply cannot write into a data/ directory that your user created with mode 700, and the failure is ugly: SQLite can't create the database, the app dies at startup, no token is ever generated.

So install.sh writes your host UID/GID into .env, and compose.yaml interpolates them:

INVGUARD_UID=1000
INVGUARD_GID=1000
user: "${INVGUARD_UID:-10001}:${INVGUARD_GID:-10001}"

Consequences worth knowing:

  • .env is machine-specific and gitignored. Cloning the repo elsewhere means running install.sh (or copying .env.example and filling in id -u / id -g) — bringing someone else's .env along will break the mount.
  • Running install.sh as root deliberately does not run the container as root: it keeps UID 10001 and chowns the volumes to match.
  • If you ever start the stack by hand without .env present, it falls back to UID 10001 and you're back to the Linux permission problem. docker compose up alone is only safe once .env exists.

Manual

docker compose up -d --build
docker compose ps                        # invguard: Up (healthy)
cat data/.token                          # your token
curl http://127.0.0.1:8480/health

Then open http://127.0.0.1:8480 and paste the token when prompted. The browser stores it locally; you won't be asked again.

A word on exposure

The port binds to 127.0.0.1 only. Publishing it to a LAN is a deliberate act, and if you do it, put TLS in front — your own reverse proxy or Tailscale. Don't just flip the compose mapping to 0.0.0.0 and call it a day: the token is the only authentication layer, and it was designed for a loopback threat model.


First run

sequenceDiagram
    actor You
    participant UI as Web UI
    participant KB as KB worker
    participant Scan as Scanner
    participant Feeds as Public feeds

    You->>UI: paste token
    You->>UI: settings - add NVD API key
    You->>UI: "+ Add vendor" (e.g. Cisco)
    UI->>KB: enqueue job (202)
    KB->>Feeds: page through the NIST CPE dictionary
    KB-->>UI: progress: vendors, models, OS, versions
    You->>UI: add device - autocomplete fills the CPE
    UI->>Scan: inventory changed (60s debounce)
    Scan->>Feeds: full corpus sync - minutes, once
    Scan-->>UI: report + findings

Recommended order:

  1. ⚙ → NVD API key. Do this first; everything downstream is faster.
  2. ⚙ → "+ Add vendor" for each vendor in your rack. This pulls that vendor's slice of the official CPE dictionary into a local knowledge base, so autocomplete only ever offers identifiers that will actually match. Runs in the background with a progress bar; a giant vendor is capped at 120k entries.
  3. Inventory → + Add devices and packages. Vendor → model → OS → version cascades; the CPE is assembled for you and validated before saving. Or import CSV — you get a per-row preview with reasons for every rejected line before anything is written, and partial success ("added N, rejected M").
  4. Report → "Scan now". The first run downloads the full CVE corpus — hundreds of MB, expect minutes. Later runs are incremental.
  5. bash scripts/setup-mcp.sh if you want an agent connected.

The suggestion order in autocomplete is deliberately precision-first: registry version → exact OS build → CPE marketing label. For Windows 10 you'll be offered 10.0.19045.5011 before 22h2, because the build number is what actually determines whether a patch is installed.


Configuration

Environment (compose.yaml)

Variable Default Meaning
INVGUARD_DATA_DIR /data SQLite, token file, CVE mirror
INVGUARD_REPORTS_DIR /reports Markdown report mirror
INVGUARD_TOKEN_FILE <data_dir>/.token Where the plaintext token is written once
INVGUARD_UID / INVGUARD_GID host id -u / id -g Read by Compose, not by the app — see .env

There is no port environment variable. The port inside the container is fixed at 8480 by the Dockerfile CMD and the healthcheck. To change the port you reach the service on, edit the mapping in compose.yaml — the first number is the host side:

ports:
  - "127.0.0.1:9000:8480"     # http://127.0.0.1:9000

Keep the 127.0.0.1: prefix unless you've read A word on exposure.

Settings (⚙ in the UI, stored in kv)

Setting Default Notes
scan_interval_hours 168 1 hour – 90 days. Counted from the last run of any kind
alert_cvss_threshold 7.0
alert_epss_threshold 0.5 KEV always alerts regardless
alert_webhook_url (empty) Loopback hosts only
nvd_api_key (empty) Masked on read — last 4 characters only
cisco_client_id / cisco_client_secret (empty) Enables the openVuln connector
csaf_provider_urls (empty) Comma-separated https://…/provider-metadata.json
vulners_api_key / vulncheck_api_key (empty) Enables premium connectors
premium_daily_limit 500 Request budget per premium connector per day

Secrets entered in the UI take priority over the environment, are stored in the kv table, are masked on read and never appear in logs.


MCP integration

The same domain, exposed as agent tools. Two transports: streamable HTTP mounted at /mcp behind the same bearer token, and a stdio shim for desktop clients.

bash scripts/setup-mcp.sh          # writes the Claude Desktop config, keeps existing servers

The script backs up your config and always writes it if Docker and the container are found — problems are reported as warnings rather than aborting halfway (a lesson from 1.7.1, where a fragile handshake check meant the config was sometimes never written at all).

Manual config, if you'd rather:

{
  "mcpServers": {
    "invguard": {
      "command": "/usr/local/bin/docker",
      "args": ["exec", "-i", "invguard", "python", "-m", "invguard.mcp_stdio"]
    }
  }
}

Use the absolute path to your docker binary (command -v docker) — desktop clients don't inherit your shell PATH. Config locations: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS), %APPDATA%\Claude\ (Windows), ~/.config/Claude/ (Linux). Fully quit and relaunch the client afterwards — not just close the window.

Tools: inventory_add_device, inventory_add_package, inventory_import, inventory_list, scan_now, compliance_status, get_report, query_cve, ack_match, resolve_match.

Resource: report://latest — the agent can read the latest report without calling a tool.

Verify readiness from inside the container:

docker exec -i invguard python -m invguard.cli mcp-check --verbose

REST API

Everything the UI does, the API does. All routes except /health and / require Authorization: Bearer <token>.

TOKEN=$(cat data/.token)
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8480/api/summary
Method Path Purpose
GET /health, /api/version, /api/ping Liveness (/health is public)
GET POST PUT DELETE /api/devices[/{id}] Device CRUD
GET POST PUT DELETE /api/packages[/{id}] Package CRUD
POST /api/import/{devices|packages} CSV import (dry_run for preview)
GET /api/import/{kind}/template CSV template
GET /api/catalog Vendor / OS-family dictionary
POST /api/scan Run now (optional target_ids)
GET /api/scan/runs Run history with feed snapshots
GET /api/summary, /api/matches[/{id}] Findings, filtered
PUT /api/matches/{id}/status Triage transition
GET /api/reports/latest, /api/reports/new/{run_id} Markdown
POST /api/reports/rebuild Rebuild the file mirror from the DB
POST GET /api/feeds/refresh, /api/feeds/status Feed sync and state
GET PUT /api/settings Thresholds, keys, schedule
GET POST DELETE /api/kb/vendors, /api/kb/jobs, /api/kb/suggest/* Knowledge base and autocomplete
GET /api/export/{inventory|matches|reports|database} CSV / JSON / ZIP

Domain errors come back as 400 with a human-readable error field (in Ukrainian). A missing or bad token is 401.


CLI

docker compose exec invguard python -m invguard.cli <command>
Command What it does
status Schema version, DB size, inventory counts, corpus size, findings by status, last run, per-feed freshness
backup [--output PATH] Consistent snapshot via VACUUM INTO — safe on a live database
restore SOURCE [--force] Restore a snapshot; the current DB is renamed to .db.old, migrations run on connect
rotate-token [--show] New token, old one invalid immediately, no restart
rebuild-reports Regenerate the reports/ file mirror from the database
mcp-check [--verbose] MCP self-diagnosis: import, tool count, stdout cleanliness, installed SDK version

The token's hash lives in the database, so a restored backup keeps working with the same token.


Backup, restore and updates

Backup is a copy of data/. For a consistent snapshot of a running system use invguard backup rather than cp — VACUUM INTO won't catch the database mid-write.

Updating from git:

git pull
docker compose up -d --build

Migrations run automatically on connect, keyed by PRAGMA user_version (currently v9). The migration list is append-only — a newer binary upgrades an older database; the reverse is not supported, so take a snapshot first.

Updating from a zip (for air-gapped or non-git deployments) — drop the archive in upd/ and run bash update.sh. It snapshots the database, backs up the current code, swaps it, rebuilds, and rolls back automatically if the build fails or health doesn't come up. data/, reports/, upd/ and backups/ are never touched.

After any update, hard-refresh the browser once (Ctrl+F5 / Cmd+Shift+R) and fully restart your MCP client — the MCP process lives inside the container and went down with it.


Troubleshooting

Install and startup

docker: command not found / "Docker не відповідає" Docker isn't installed or the daemon isn't running. On Linux: sudo systemctl start docker. On macOS/Windows: launch Docker Desktop and wait for it to go green.

"compose.yaml не знайдено" Run the script from the repository root, not from a subdirectory.

The service doesn't come up within 60 seconds install.sh now dumps the last 30 log lines automatically and names the three usual causes — you shouldn't have to go looking. To fetch more:

docker compose logs invguard --tail 100

Usual suspects: the volume isn't writable by the UID in .env (unable to open database file); port 8480 is taken (ss -tlnp | grep 8480, or lsof -i :8480 — note ss isn't installed on every minimal system, netstat -tlnp is the older fallback); or a build failure that scrolled past. To move the port, edit the mapping in compose.yaml.

"каталог data/ належить UID N, а контейнер працюватиме як M" install.sh caught an ownership mismatch before starting anything — it prints the exact chown to run. The usual cause is a first run under sudo followed by a run without it, or a data/ restored from an archive that carried foreign ownership:

sudo chown -R "$(id -u):$(id -g)" data reports

"unable to open database file" in the container log Same root cause, reached the hard way — the mount isn't writable by the UID in .env. Check that .env matches id -u / id -g, fix ownership as above, then docker compose up -d.

Rootless Docker Under rootless Docker your UID is remapped inside the container, and INVGUARD_UID from .env may not be what the container actually sees. If the volumes turn out unwritable, the simplest fix is to let the daemon's own mapping own them: remove the user: line from compose.yaml and chown -R 10001:10001 data reports from inside the namespace (podman unshare has an equivalent). This is the one configuration where the .env mechanism doesn't help.

SELinux (Fedora, RHEL, CentOS): Permission denied on the volumes install.sh detects Enforcing mode and warns you, but it won't edit your compose file. Add :z to both bind mounts in compose.yaml:

volumes:
  - ./data:/data:z
  - ./reports:/reports:z

:z relabels the content as shared. Use :Z (capital) only if this is the sole container touching those directories.

Access

Every request returns 401 The token in your browser is stale — most often after rotate-token or a fresh data/. Read the current one with cat data/.token and re-enter it via the "Токен" button.

data/.token doesn't exist The container never completed its first start. Check the logs; the token is generated during lifespan startup, right after the database opens.

Lost the token

docker compose exec invguard python -m invguard.cli rotate-token --show

Feeds and scanning

The first scan takes forever Expected. The first run pulls the entire CVE corpus — hundreds of thousands of records. Add an NVD API key before you start (⚙ → NVD) for roughly a tenfold speedup. Subsequent runs are incremental and take seconds to minutes.

A source shows up in feeds_failed By design the run continues on cached data and the report marks that source as stale. Check the reason on the Прогони (Runs) page:

  • HTTP 403/429 from NVD — you're rate-limited. Add an API key.
  • "заблоковано httpguard" — the connector tried to reach a domain outside the allowlist, or the target resolved to a private address. For CSAF providers, confirm the URL is an https://…/provider-metadata.json and that its ROLIE feeds live on public hosts.
  • Cisco 401 — OAuth2 credentials are wrong or the API subscription lapsed. Re-check client ID and secret at apiconsole.cisco.com.

No findings at all, and that feels wrong Check that your inventory items actually have a CPE (the column is visible in the inventory table). A device with an empty or hand-typed invalid CPE matches nothing. Use "+ Add vendor" first so autocomplete offers dictionary-backed identifiers — everything it suggests is guaranteed to match.

Findings for a version you already patched Update the version field on the device; the next run closes the finding automatically. If the source's range is genuinely wrong, mark the finding false_positive with a comment — it stays in the history and won't re-alert.

A scan seems stuck Only one run executes at a time (an in-process lock plus a flock file, so the MCP shim and the web process can't collide). A run triggered while another is in flight waits its turn. If the container was killed mid-run, the kernel releases the flock automatically — just restart.

MCP

The server doesn't appear in Claude Desktop Run the diagnostic first — it reports facts, not guesses:

docker exec -i invguard python -m invguard.cli mcp-check --verbose
  • No module named 'mcp.server.fastmcp' — an old MCP SDK got baked into a cached layer. Since 1.7.2 the build fails loudly on this instead of shipping a silently broken feature: docker compose build --no-cache && docker compose up -d.
  • "MCP: готово" but the client still shows nothing — the config path or the docker path is wrong. Use the absolute binary path; desktop apps don't inherit your shell environment. Then fully quit the client (⌘Q, not just closing the window).
  • Something writes to stdout during import — mcp-check catches this. stdout carries the JSON-RPC protocol; any stray print breaks the handshake.

Reading the client's MCP log The path is OS-specific — setup-mcp.sh prints the right one for your system in its closing hints:

OS Log
macOS ~/Library/Logs/Claude/mcp-server-invguard.log
Linux ${XDG_CONFIG_HOME:-~/.config}/Claude/logs/mcp-server-invguard.log
Windows %APPDATA%\Claude\logs\mcp-server-invguard.log

More detail, in Ukrainian: docs/MCP_TROUBLESHOOTING_UA.md.

UI

The interface looks like the old version after an update Hard-refresh once: Ctrl+F5 / Cmd+Shift+R. JS modules are served with a ?v=<version> query and the HTML is no-store, but a browser cache can still hold the previous assets for a moment.

Autocomplete offers nothing The knowledge base for that vendor hasn't been built yet. ⚙ → "+ Add vendor", then wait for the background job. Progress and any errors show up in the same modal.

Data

The database is much larger than expected The CVE corpus dominates. invguard status shows the split. VACUUM happens implicitly during backup, so restoring a backup gives you a compacted file.

Restoring while the service is running Don't. docker compose down, restore, then up. restore without --force refuses to overwrite an existing database precisely to make this hard to get wrong.


Development

python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest                                        # 151 test functions across 22 files
INVGUARD_DATA_DIR=./data uvicorn invguard.main:app --port 8480
bash tests/dryrun_install.sh    # install.sh on mocked docker/curl — no Docker needed

That harness exists because every installation bug we shipped was platform-specific, and almost all of them were invisible on macOS: Docker Desktop's ownership rewriting hides the entire class of "the container can't write to the bind mount". The mock deliberately emulates native Linux UID semantics and covers a missing curl, a Docker socket without permission, a stopped daemon, inherited root ownership, NO_COLOR, and a re-run for idempotency. pytest invokes it too.

The Python suite runs entirely on recorded feed fixtures — no network, no live API keys. It includes an end-to-end smoke test, a real MCP handshake, guard tests proving that a disabled connector cannot open a connection and that a request leaking a hostname or IP is rejected by the transport, and a version-comparison edge-case matrix.

House rules, learned the hard way:

  • Routers and MCP tools stay thin — zero logic, calls into the domain only. Two bridges, one core.
  • Every fix ships with a regression test that fails without it.
  • innerHTML is permitted only via DOMPurify.sanitize(marked.parse(...)), and only for completed markdown reports. Everything else is textContent.
  • Connectors never write to the database. They return normalized records; store.py applies them atomically. That's what makes them pure functions of (client, cursor) and testable without a database.
  • User-facing errors are written in plain Ukrainian, at the point where the domain knows what actually went wrong.

Project layout

invguard/
├── main.py              FastAPI app, lifespan, token middleware
├── cli.py               maintenance commands
├── mcp_server.py        FastMCP tools (thin, like the routers)
├── mcp_stdio.py         stdio shim for desktop clients
├── core/
│   ├── config.py        pydantic-settings; secrets live in kv, not here
│   ├── db.py            SQLite WAL 0600, self-migrating by user_version
│   ├── auth.py          bearer token (sha256 in DB), rotation
│   └── httpguard.py     allowlist, private-range block, leak filter
├── inventory/           CRUD, CSV import with partial success, CPE catalog
├── feeds/               connector contract + registry
│   └── premium/         optional, dark until keyed
├── matching/            engine (diff vs previous run) + version schemes
├── compliance/          scanner, reporter, alerts, scheduler
├── kb/                  knowledge base: worker, sources, autocomplete store
└── routers/             REST, one module per resource

web/                     index.html + ES modules + vendored libraries
tests/                   pytest suite on recorded fixtures
docs/                    architecture, operations, MCP diagnostics (Ukrainian)
scripts/setup-mcp.sh     Claude Desktop wiring
install.sh, update.sh    first install; zip-based update with rollback
compose.yaml             hardened single service
.env.example             template for the UID/GID file install.sh generates
tests/dryrun_install.sh  install.sh on mocked docker/curl (native-Linux semantics)

Runtime state (data/, reports/, backups/, upd/) is created at install time and gitignored. It never lives in the repository.


Container hardening

Not decorative — this is the deployment contract:

ports:         ["127.0.0.1:8480:8480"]   # loopback only
read_only:     true                      # immutable rootfs, /tmp is tmpfs
cap_drop:      [ALL]
security_opt:  [no-new-privileges:true]
mem_limit:     1g
memswap_limit: 1g                        # swap off - fail predictably, don't thrash
user:          "${INVGUARD_UID:-10001}:..."  # non-root, host UID from .env

The process runs a single uvicorn worker. Large corpora (NVD, CVE List V5) are streamed, never loaded into memory whole.

Recommended Servers

playwright-mcp

playwright-mcp

A Model Context Protocol server that enables LLMs to interact with web pages through structured accessibility snapshots without requiring vision models or screenshots.

Official
Featured
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

An AI-powered tool that generates modern UI components from natural language descriptions, integrating with popular IDEs to streamline UI development workflow.

Official
Featured
Local
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

Enables interaction with Audiense Insights accounts via the Model Context Protocol, facilitating the extraction and analysis of marketing insights and audience data including demographics, behavior, and influencer engagement.

Official
Featured
Local
TypeScript
VeyraX MCP

VeyraX MCP

Single MCP tool to connect all your favorite tools: Gmail, Calendar and 40 more.

Official
Featured
Local
graphlit-mcp-server

graphlit-mcp-server

The Model Context Protocol (MCP) Server enables integration between MCP clients and the Graphlit service. Ingest anything from Slack to Gmail to podcast feeds, in addition to web crawling, into a Graphlit project - and then retrieve relevant contents from the MCP client.

Official
Featured
TypeScript
Kagi MCP Server

Kagi MCP Server

An MCP server that integrates Kagi search capabilities with Claude AI, enabling Claude to perform real-time web searches when answering questions that require up-to-date information.

Official
Featured
Python
Neon Database

Neon Database

MCP server for interacting with Neon Management API and databases

Official
Featured
E2B

E2B

Using MCP to run code via e2b.

Official
Featured
Exa Search

Exa Search

A Model Context Protocol (MCP) server lets AI assistants like Claude use the Exa AI Search API for web searches. This setup allows AI models to get real-time web information in a safe and controlled way.

Official
Featured
Qdrant Server

Qdrant Server

This repository is an example of how to create a MCP server for Qdrant, a vector search engine.

Official
Featured