Security Scanner Plugin System
MCPProxy integrates external security scanners as Docker-based plugins. Scanners analyze quarantined servers before approval, detecting tool poisoning attacks, prompt injection, malware, secrets leakage, and supply chain risks.
CLI reference: for the full breakdown of every
mcpproxy securitysubcommand, flags, and examples see Security Commands.
Overview
Every scanner is a plugin — there is no built-in scanner. MCPProxy exposes a universal plugin interface that scanner authors implement by shipping a Docker image that reads from /scan/source and writes SARIF to /scan/report/results.sarif. Users browse a bundled scanner registry, enable scanners with one command, configure API keys if needed, and start scanning.
Key features
- Plugin-only architecture — every scanner is a Docker-based plugin.
- Universal scanner interface — source filesystem input, SARIF output.
- Parallel scanning — multiple scanners run concurrently; per-scanner failures are isolated.
- Source resolver — detects the right thing to scan for npx/uvx/pipx/bunx package-runner commands, falls back to working dir or tool definitions for HTTP/SSE servers.
- Risk scoring — composite 0–100 risk score from aggregated findings.
- Integrity verification — image digest checks on server restart.
- Multi-UI — REST API, CLI, Web UI all powered by the same backend, consistent status vocabulary.
- Failed-scanner visibility — both CLI and Web UI surface per-scanner failures so silent crashes don't look like "clean".
What changed recently (2026-04)
security approvenow actually unquarantines the server and indexes its tools (was a no-op stub).- Source resolver tries the package cache first for package-runner commands, so filesystem-server-style data-dir args are no longer mistaken for source code.
- The scanner configure path no longer touches the OS keyring by default on macOS. Env values are stored in the scanner record in BBolt. See Security Commands → configure for details and the opt-in flag.
security reportsurfaces the count of failed scanners and their names.security scan --dry-runprints a source-resolution plan without starting any containers.security scanblocking mode has a real wait loop (no more hangs) with a hard timeout.scanner statusvocabulary unified between table and JSON:available/pulling/installed/configured/error.
Quick start
1. List available scanners
mcpproxy security scanners
ID NAME VENDOR STATUS INPUTS
-------------------------------------------------------------------------------------------------------
cisco-mcp-scanner Cisco MCP Scanner Cisco AI Defense available source
mcp-ai-scanner MCP AI Scanner MCPProxy available source
mcp-scan Snyk Agent Scan Snyk (Invariant Labs) available source
nova-proximity Nova Proximity MCPProxy available source
ramparts Ramparts MCP Scanner Javelin available source
semgrep-mcp Semgrep MCP Rules Semgrep available source
tpa-descriptions Tool Description Analy... MCPProxy installed source
trivy-mcp Trivy Vulnerability... Aqua Security available source, container_image
tpa-descriptionsis a built-in, Docker-less scanner and isinstalled(always on) out of the box — there is no image to pull. It analyzes a connected server's tool descriptions/schemas in-process, so it runs even for remotehttp/sseservers that have no source files or Docker container.
2. Enable scanners
mcpproxy security enable nova-proximity
mcpproxy security enable trivy-mcp
mcpproxy security enable mcp-ai-scanner
(install is a hidden alias for enable, kept for backward compatibility.)
Docker-based scanners belong to the opt-in deep-scan layer (Spec 077). Enabling a Docker scanner pulls its image but does not by itself make it run: the whole deep-scan layer is gated behind
security.deep_scan.enabled(defaultfalse). While that switch is off,security enable <docker-scanner>prints a reminder —scanner enabled, but it will not run until security.deep_scan.enabled=true— and only the built-in, Docker-lesstpa-descriptionsbaseline scanner actually runs. Setsecurity.deep_scan.enabled: trueto turn the layer on. See Configuration below.
3. Configure API keys (if the scanner needs them)
Only a subset of scanners requires an API key. mcp-scan (Snyk Agent Scan) needs a free token from Snyk; mcp-ai-scanner can use an optional Anthropic API key or Claude Code OAuth token for richer analysis but works in pattern-only mode without one.
mcpproxy security configure mcp-scan --env SNYK_TOKEN=snyk_xxx
Values are stored directly in the scanner record in BBolt. The scanner container receives them via Docker --env flags at scan time.
4. Scan a quarantined server
mcpproxy security scan github-server
The CLI runs all enabled scanners in parallel, prints live progress, and shows a summary.
5. Review the findings
mcpproxy security report github-server
mcpproxy security status github-server # per-scanner detail including stderr
mcpproxy security report github-server -o sarif > github-server.sarif
6. Approve or reject
# Approve (unquarantines and indexes tools)
mcpproxy security approve github-server
# Or reject (deletes scan artifacts, keeps server quarantined)
mcpproxy security reject github-server
Scanner registry
MCPProxy ships with a bundled registry of 8 scanners. The bundled list lives in internal/security/scanner/registry_bundled.go.
| Scanner | Vendor | Inputs | Required env | Notes |
|---|---|---|---|---|
cisco-mcp-scanner | Cisco AI Defense | source | — | YARA rules + readiness analysis. Needs tools.json in the source dir. |
mcp-ai-scanner | MCPProxy | source | — (optional ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN) | Agent-based AI analysis with a pattern-only fallback. Lives in a separate repo. |
mcp-scan | Snyk (Invariant Labs) | source | SNYK_TOKEN | Tool poisoning, prompt injection, tool shadowing, toxic flows, secrets, rug pulls. |
nova-proximity | MCPProxy (NOVA-inspired rules) | source | — | Keyword-based, fully offline. Very fast. |
ramparts | Javelin | source | — | Rust-based YARA scanner. Runs fully offline: v0.8.x scans a live MCP endpoint, so MCPProxy replays the captured tool definitions to it over stdio (the upstream is never re-executed). (amd64-only image; runs under emulation on arm64 — see Scanner Images.) |
semgrep-mcp | Semgrep | source | — | Static analysis with MCP-specific rules. Uses the upstream returntocorp/semgrep:latest image. |
tpa-descriptions | MCPProxy | source | — | Built-in, Docker-less, always on. In-process analysis of tool descriptions/schemas via the deterministic offline detect engine (Spec 076/077): seven checks across two tiers — hard (hidden-Unicode smuggling, cross-server shadowing, decode-to-shell payloads, curated injection/exfiltration phrases) auto-quarantine and block approval; soft (prompt-injection directives, capability-mismatch, embedded secrets) raise a review item. Each finding carries a confidence score and the contributing check signals. Since Spec 077 the detect engine is the sole in-process detector — the duplicated legacy TPA keyword rules were removed and their approval-blocking posture folded into the hard-tier phrase.injection check. Fully offline (no network/filesystem/Docker), deterministic, and runs for any connected server — including remote http/sse servers with no source or Docker. See Tool Scanner for the full rule reference and the CI eval gate. |
trivy-mcp | Aqua Security | source, container_image | — | Filesystem + CVE scan. Uses the upstream ghcr.io/aquasecurity/trivy:latest image. |
See Scanner Images for the image sources and why vendor images are preferred over custom wrappers.
Custom / out-of-tree scanners
Custom scanners can be added by pushing a Docker image that implements the plugin contract (see the Plugin Interface section below) and registering it via the REST API:
curl -X POST http://localhost:8080/api/v1/security/scanners/my-custom-scanner/enable \
-H "X-API-Key: $API_KEY"
Remote scanner registries (not just the bundled one) are planned but currently opt-in via the security.scanner_registry_url config field.
CLI commands
The complete CLI reference is in docs/cli/security-commands.md. At a glance:
# Scanner lifecycle
mcpproxy security scanners # list with status
mcpproxy security enable <scanner-id> # pull image
mcpproxy security disable <scanner-id> # remove image
mcpproxy security configure <id> --env KEY=VALUE # set env vars (repeatable)
# Scan operations
mcpproxy security scan <server> # blocking, with live progress
mcpproxy security scan <server> --async # return job id
mcpproxy security scan <server> --dry-run # print plan, no containers
mcpproxy security scan --all # batch scan, progress table
mcpproxy security scan <server> --scanners a,b,c # subset of scanners
mcpproxy security rescan <server> # alias for scan
mcpproxy security status <server> # per-scanner detail
mcpproxy security report <server> [-o json|yaml|sarif]
mcpproxy security cancel-all # cancel batch in progress
# Approval workflow
mcpproxy security approve <server> [--force] # unquarantine + index
mcpproxy security reject <server> # delete artifacts, keep quarantined
mcpproxy security integrity <server> # verify against baseline
# Dashboard
mcpproxy security overview
All subcommands support -o json, -o yaml, and --json (shorthand) for scripting.
REST API
Scanner management
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/security/scanners | List all scanners from the registry |
GET | /api/v1/security/scanners/{id}/status | Per-scanner detail |
POST | /api/v1/security/scanners/{id}/enable | Enable (pull image) |
POST | /api/v1/security/scanners/{id}/disable | Disable (remove image) |
PUT | /api/v1/security/scanners/{id}/config | Set env vars (JSON body {"env": {"KEY": "VALUE"}}) |
POST | /api/v1/security/scanners/install | Legacy install endpoint — prefer the per-scanner enable |
DELETE | /api/v1/security/scanners/{id} | Legacy remove endpoint — prefer disable |
Scan operations
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/servers/{name}/scan | Start a scan (body: {"scanners": [...], "dry_run": false}) |
GET | /api/v1/servers/{name}/scan/status | Current/latest scan job with per-scanner statuses |
GET | /api/v1/servers/{name}/scan/report | Aggregated report (add ?include_sarif=true for raw SARIF) |
GET | /api/v1/servers/{name}/scan/files | Source-resolution preview (source_method, path, file list) |
POST | /api/v1/servers/{name}/scan/cancel | Cancel the current scan |
GET | /api/v1/security/scans | Scan history across all servers |
GET | /api/v1/security/scans/{jobId}/report | Fetch a specific scan's aggregated report |
POST | /api/v1/security/scan-all | Kick off a batch scan of all servers |
GET | /api/v1/security/queue | Batch scan progress |
POST | /api/v1/security/cancel-all | Cancel the current batch |
Approval workflow
| Method | Endpoint | Description |
|---|---|---|
POST | /api/v1/servers/{name}/security/approve | Approve: saves integrity baseline + unquarantines + indexes tools. Body {"force": true} bypasses the critical-findings guard. |
POST | /api/v1/servers/{name}/security/reject | Reject: deletes scan artifacts, keeps server quarantined. |
GET | /api/v1/servers/{name}/integrity | Integrity check against the approved baseline. |
Dashboard
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/security/overview | Totals: scanners installed, total scans, findings by severity, Docker availability, last scan time |
SSE events
Emitted on the /events SSE stream:
| Event | Payload fields |
|---|---|
security.scan_started | server_name, scanners[], job_id |
security.scan_progress | server_name, scanner_id, status, progress |
security.scan_completed | server_name, findings_summary |
security.scan_failed | server_name, scanner_id, error |
security.integrity_alert | server_name, alert_type, action |
Plugin interface
Every scanner plugin is a Docker image with the following contract:
| Mount / path | Direction | Description |
|---|---|---|
/scan/source | read-only | The resolved source directory for the server under scan |
/scan/report | read-write | The scanner must emit results.sarif here |
/scan/source/tools.json | read-only | The server's exported MCP tool schemas ([{ "name": ..., "description": ..., "inputSchema": {...} }, ...]). Written by mcpproxy before starting the scanner container. |
Scanners communicate results via SARIF 2.1.0. Exit code 0 indicates "scan completed (with or without findings)". Non-zero exit codes are surfaced as scanner failed in the aggregated report.
Source resolution order
1. Docker extraction (for Docker-isolated servers)
2. Package cache (npx/uvx/pipx/bunx) — PREFERRED for package runners
3. WorkingDir (from server config)
4. Arg-scan fallback — accepts only directories containing source markers
5. Published package fetch (npx/uvx) — download & unpack real source, no execution
6. Tool definitions only — last resort (HTTP / SSE / unresolvable)
For uvx servers the package cache lookup (step 2) covers persistent uv tool install
locations, git checkouts, and the ephemeral ~/.cache/uv/archive-v0 wheel
cache that a plain uvx <pkg> populates — so a locally-run Python MCP server
resolves to its real source from the local cache (no network) before step 5's
published fetch is reached, and works in air-gapped deployments.
The resolved method and path are recorded on the scan job and visible via both the text and JSON report. The source_method field reports how source was obtained: docker_extract, npx_cache, uvx_cache, working_dir, npm_pack, pip_download, url, or tool_definitions_only. See Security Commands → scan for more.
Published package fetch (npx / uvx)
Package-runner servers (npx, uvx, plus pnpm dlx / yarn dlx, pipx run, and bunx) are the primary quarantine/scan target, but a server quarantined on add has never run locally — so the local package cache (step 2) misses and, before this fallback existed, the scan degraded to tool definitions only (no real source-level analysis). Step 5 closes that gap by downloading the published package source so the AI and supply-chain scanners run against real code. The target package is parsed from the launch command — subcommand runners (pipx run X, pnpm dlx X), package-naming flags (npx --package X, uvx --from X), and extra-dependency flags (uvx --with <dep> X, which names X, not the dep) are all handled. When the spec carries an exact version pin (pkg@1.2.3, pkg==1.2.3), the local-cache lookups (steps 2–4) prefer the cache entry matching that version rather than the newest one.
The source is fetched but never executed. A scanner must not run the untrusted code it is scanning. The fetch only ever downloads and unpacks archives:
- Registry name required. Only a server whose configured spec is a bare registry name (PEP 503 for PyPI, npm package name — optionally version-pinned) is fetched. Local-path,
file:, URL, and VCS (git+…,[email protected]:…) specs are rejected and fall back to tool definitions only. This is mandatory: for a non-registry spec,pip download/uv pip downloadstill invokes the package'ssetup.py/ PEP 517 build backend to resolve metadata even with--only-binary=:all:— executing untrusted code on the (static) scan path.--only-binary=:all:alone protects only bare registry names. Validation covers the whole spec including the@-tail: a PEP 508 / npm direct reference such aspkg@./local,pkg@/abs/path, orpkg@git+https://…is rejected (thename@<ref>tail must be a bare version specifier, not a path/URL/VCS); for PyPI, any@is treated as a direct reference and refused. - npm (
npx) —npm pack <pkg>@<version> --ignore-scriptsdownloads the published tarball without running any lifecycle scripts (install/postinstall), then it is extracted (source_method=npm_pack). - PyPI (
uvx) —uv pip download <pkg>==<version> --no-deps --only-binary=:all:(falling back topip download) fetches only a prebuilt wheel, which is unpacked without building or runningsetup.py(source_method=pip_download).--only-binary=:all:is mandatory: downloading an sdist would invoke the package's PEP 517 build backend (setup.py egg_info) to resolve metadata, executing the untrusted code. A package that ships no wheel therefore fails the fetch and falls back to tool definitions only — sdists are never built or extracted.
Extraction is hardened against path traversal (zip-slip), symlink escape, and decompression bombs (bounded file count and total size). The whole fetch (download + extract) is bounded by a timeout, so a hung or throttled registry cannot stall the scan. If the toolchain is missing, the host is offline, or the fetch fails or times out, resolution falls through to tool definitions only with no regression.
This fallback runs only under the opt-in deep-scan layer (Spec 077). Published-package fetch — like every Docker-based scanner — is part of the heavy deep-scan layer, so it runs only when security.deep_scan.enabled: true. With deep scan off (the default), package-runner servers without local source scan tool definitions only, and no network fetch is attempted. When deep scan is on, the fetch is enabled by default and can be turned off (for air-gapped deployments that must forbid the scanner's network egress) with security.deep_scan.fetch_package_source: false. The deprecated top-level security.scanner_fetch_package_source key still parses and is migrated into security.deep_scan.fetch_package_source on load.
SARIF normalization
Scanners produce results in SARIF 2.1.0 format. MCPProxy normalizes findings to a consistent severity vocabulary:
| SARIF level | MCPProxy severity |
|---|---|
error | high |
warning | medium |
note | low |
none | info |
critical severity is reserved for findings explicitly marked critical in the scanner's SARIF properties.severity field or via a rule-level override. Critical findings block security approve unless --force is supplied.
MCPProxy also augments each finding with a user-facing threat_type (tool_poisoning / prompt_injection / rug_pull / supply_chain / malicious_code / uncategorized) and threat_level (dangerous / warning / info) so the Web UI can group findings in a way that's meaningful to MCP server trust decisions.
Configuration
{
"security": {
"scan_timeout_default": "60s",
"integrity_check_interval": "1h",
"integrity_check_on_restart": false,
"scanner_registry_url": "",
"runtime_read_only": false,
"runtime_tmpfs_size": "100M",
"deep_scan": {
"enabled": false,
"fetch_package_source": true,
"disable_no_new_privileges": false,
"scanners": []
}
}
}
| Setting | Default | Description |
|---|---|---|
scan_timeout_default | 60s | Per-scanner timeout. The blocking CLI security scan computes a hard ceiling as scan_timeout_default × scanner_count + 30s, clamped between 15 and 30 minutes. |
integrity_check_interval | 1h | Periodic integrity check interval (when running as a daemon with integrity checks enabled). |
integrity_check_on_restart | false | Re-verify integrity baseline every time an approved server is restarted. |
scanner_registry_url | "" | Remote scanner registry URL (opt-in). When empty, only the bundled registry is used. |
runtime_read_only | false | Run approved server containers with --read-only and a tmpfs overlay. (P2 feature, requires Docker isolation.) |
runtime_tmpfs_size | 100M | Tmpfs size for read-only runtime containers. |
The security.deep_scan block (Spec 077)
The deterministic, offline tpa-descriptions baseline scanner always runs and is the sole source of the approval verdict. Every heavier scan capability — the Docker-based scanner plugins and published-package-source extraction — lives behind the opt-in security.deep_scan block. The whole layer is off by default; a deep-scan failure is surfaced as an informational note and never blocks approval or degrades the baseline verdict (FR-007/FR-008).
| Setting | Default | Description |
|---|---|---|
deep_scan.enabled | false | Master opt-in for the heavy layer. When false, no Docker scanner runs and no source extraction is attempted — only the in-process baseline scanner executes. |
deep_scan.fetch_package_source | true (when deep scan is on) | Whether the scanner fetches (never executes) the published source of npx/uvx package-runner servers when no local source is available. Set false for air-gapped deployments. Only consulted when deep_scan.enabled is true. |
deep_scan.disable_no_new_privileges | false | Omits --security-opt no-new-privileges from scanner container runs — the snap-docker/AppArmor escape hatch for hosts where the default flag makes every scanner fail with EPERM. |
deep_scan.scanners | [] | Optional allow-list restricting which deep scanners may run (by scanner id). Empty ⇒ all enabled deep scanners are eligible. |
Deprecated-key migration. Configs that still carry the old top-level security.scanner_fetch_package_source or security.scanner_disable_no_new_privileges keys continue to parse and are folded into deep_scan.fetch_package_source / deep_scan.disable_no_new_privileges on load (then cleared, so the config serializes only deep_scan.*). The old security.auto_scan_quarantined key was removed; a config still carrying it loads without error and the key is ignored.
The block is hot-reloaded: toggling deep_scan.enabled in mcp_config.json takes effect on the next scan without a restart.
Environment variables
| Variable | Effect |
|---|---|
MCPPROXY_KEYRING_WRITE | Set to 1, true, or yes to opt in to writing secrets to the OS keyring on macOS. Default (empty) routes writes to the in-config fallback. Linux and Windows default to enabled. |
Data storage
Scanner data is stored in the BBolt database (~/.mcpproxy/config.db) in these buckets:
| Bucket | Content |
|---|---|
security_scanners | Installed scanner configurations (including configured_env) |
security_scan_jobs | Scan job records and per-scanner statuses |
security_reports | Aggregated reports, per-scanner SARIF payloads, normalized findings |
integrity_baselines | Per-server approval records (image digest, scan report IDs, approved-by) |
Web UI
The Security page at /security in the Web UI mirrors the CLI and provides:
- Dashboard stats — scanners installed, total scans, findings by severity, Docker availability, Last scan timestamp.
- Scanner table — every scanner in the registry with its current status, vendor link, and configure button.
- Scan All Servers — batch scan trigger with live progress.
- Scan report viewer at
/security/scans/{jobId}— risk-score badge, finding detail cards with rule/location/scanner, per-scanner execution logs including stderr from failed scanners, and scan context (source method + path + file count). - Approve Server / Force Approve / Reject — scanner-gated approval dialog that requires a completed scan (or explicit force) before calling the approval endpoint.
Known limitations
- Ramparts is
amd64-only and runs emulated on arm64 — the GLIBC build break is fixed (builder pinned to bookworm, MCP-2395/#665) and the v0.8.x URL-based invocation is wired via a static stdio replay shim (MCP-2422), but the image is published forlinux/amd64only because the arm64 Rust build exhausts the CI runner budget. On arm64 hosts (e.g. Apple Silicon) it runs under emulation — functional but slower. See Scanner Images for the design. - Cisco scanner is static-analysis only — coverage caveat. The bundled
cisco-mcp-scannerrunsstatic --tools tools.json(YARA + readiness rules over the exported tool definitions). It never connects to or probes the live server endpoint and makes no network request, so anis_safe/SAFEresult reflects the analyzed tool definitions, not the server's live runtime behavior — do not read a clean Cisco result for a remote/URL server as proof the live endpoint was exercised. mcpproxy prepends this caveat to every Cisco execution log. Relatedly, the upstream tool hardcodes a placeholderserver_urlheader (https://mcp.deepwiki.com/mcp) in its stdout; this is cosmetic and does not affect findings, and since #383 mcpproxy strips that line from the user-visible execution log and replaces it with an annotation explaining no network request was made. - Pass 2 (supply-chain audit) currently requires Docker isolation to be enabled, otherwise it fails source resolution. The UI doesn't yet surface this precondition.
Related reading
- Tool Scanner (Spec 076/077) — the built-in offline detect engine behind
tpa-descriptions: the seven checks, two-tier model, and CI eval gate - Security Commands — exhaustive CLI reference
- Scanner Images — where each Docker image comes from
- Security Quarantine — the underlying quarantine mechanism that scanners gate
- Tool Quarantine (Spec 032) — per-tool hash-based approval, a complementary layer