Skip to main content
Glama
xordej

Inventory Guard MCP Server

by xordej

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


Related MCP server: SAST MCP Server

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 workflowopen → 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 cpVACUUM 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 importmcp-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.

F
license - not found
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    -
    quality
    B
    maintenance
    Integrates 15+ static application security testing tools (Semgrep, Bandit, TruffleHog, etc.) with Claude Code AI, enabling automated vulnerability scanning and security analysis through natural language commands. Supports cross-platform operation with remote execution on dedicated security VMs.
    Last updated
    7
    MIT
  • F
    license
    A
    quality
    D
    maintenance
    Exposes Burp Suite's REST API to AI assistants, enabling users to trigger vulnerability scans, monitor progress, and manage security tasks through natural language. It also provides programmatic access to Burp's security knowledge base for querying vulnerability definitions and remediation advice.
    Last updated
    8
    1

View all related MCP servers

Related MCP Connectors

  • CVE lookups (NVD) and dependency-manifest audits (OSV) for AI agents. No API keys.

  • CVE lookups (NVD) and dependency-manifest audits (OSV) for AI agents. No API keys.

  • Pay-per-call cybersecurity for AI agents: vuln scans, threat intel, compliance, code security.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/xordej/invguard'

If you have feedback or need assistance with the MCP directory API, please join our Discord server