Local and CI Preflight Scanning
Overview
Preflight scanning is a static analysis pass over MCP configuration files. It runs entirely offline — no MCP server binaries are started, no network calls are made, and no Pavri backend connection is required.
Use preflight scanning to catch supply-chain problems before they reach a running environment:
- Local development — scan your own machine before pushing changes that include a new MCP server
- CI pipeline — block merges or deployments when a configuration introduces critical or high-severity findings
- Pre-deployment checks — validate team or organisation-wide MCP configs as part of a release gate
Preflight scanning produces the same McpScanReport JSON that the Pavri dashboard consumes. You can use the report locally, pipe it to another tool, or upload it to the dashboard for team visibility.
Quick Start
Install
pip install pavri
The pavri-preflight command is included in the pavri package.
Scan Your Machine
pavri-preflight scan
With no arguments, the scanner auto-discovers MCP configuration files for all supported clients (Cursor, Claude Desktop, VS Code, Gemini CLI) and scans them all.
Scan a Specific Config File
pavri-preflight scan --config ~/.cursor/mcp.json
Get Human-Readable Output
pavri-preflight scan --format markdown
The default output format is JSON. Use --format markdown for a formatted report suitable for reading in a terminal or pasting into a PR comment.
CI Integration (GitHub Actions)
- name: MCP Preflight Scan
run: |
pip install pavri
pavri-preflight scan --format json --severity-threshold medium
The command exits with code 1 if any findings meet or exceed the severity threshold, causing the CI step to fail. Set --severity-threshold to critical if you only want the pipeline to fail on the most severe findings.
Command Reference
scan Command
Scan one or more MCP configuration files and report findings.
pavri-preflight scan [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--config PATH | auto-discover | Path to a specific MCP config file to scan. If omitted, all supported client configs are discovered automatically. |
--source NAME | derived from path | Override the source label (cursor, claude_desktop, vscode, gemini_cli). Useful when scanning a copy of a config file. |
--format FORMAT | json | Output format: json or markdown. |
--output PATH | stdout | Write output to a file instead of stdout. |
--baseline PATH | auto-discover | Path to a baseline JSON file to compare against. If a matching baseline exists at the default location, it is used automatically. |
--no-baseline | — | Disable baseline comparison even if a baseline file exists. |
--severity-threshold LEVEL | info | Exit with code 1 if any finding meets or exceeds this severity. Levels: info, low, medium, high, critical. |
--suppress FINDING_TYPE | — | Suppress a finding type (e.g. hardcoded_secret, tool_shadowing). Repeatable. Suppressed findings are excluded from the report and do not affect the exit code. |
--org-id ORG_ID | — | Organisation ID to embed in the scan report. Required when uploading reports to the Pavri dashboard. |
baseline create Command
Create a baseline from the current configuration. The baseline is saved as a JSON file and used by future scan runs for drift detection.
pavri-preflight baseline create [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--config PATH | auto-discover | Path to the MCP config file to baseline. |
--output PATH | .pavri/mcp-baseline.json | Where to write the baseline file. |
--approved-by NAME | current user | Name or identifier of the person approving the baseline. Recorded in the baseline for audit purposes. |
baseline diff Command
Compare the current configuration against a saved baseline and show what has changed.
pavri-preflight baseline diff [OPTIONS]
| Flag | Default | Description |
|---|---|---|
--baseline PATH | .pavri/mcp-baseline.json | Path to the baseline file to compare against. |
--config PATH | auto-discover | Path to the current MCP config file. |
--format FORMAT | json | Output format: json or markdown. |
Output Formats
JSON Output
The scan command produces a JSON-serialised McpScanReport. Here is an annotated example from scanning the Cursor config fixture:
{
"report_id": "rpt_01j9kx2m3n4p5q6r7s8t9u0v1",
"org_id": "org_acme",
"source": "cursor",
"config_path": "/Users/alice/.cursor/mcp.json",
"status": "complete",
"servers_scanned": 4,
"findings_count": 4,
"critical_count": 0,
"high_count": 2,
"scan_duration_ms": 38.4,
"scanned_at": "2026-03-25T10:14:02Z",
"observed_at": "2026-03-25T10:14:02Z",
"collected_at": "2026-03-25T10:14:02Z",
"processed_at": "2026-03-25T10:14:02Z",
"markdown_summary": "## MCP Scan Report\n\nScanned 4 servers, found 4 findings (2 high, 2 medium).\n",
"findings": [
{
"finding_id": "fnd_01j9kx2m3n4p5q6r7s8t9u0v2",
"finding_type": "hardcoded_secret",
"severity": "high",
"server": "postgres-db",
"detail": "Database connection string with plaintext password in POSTGRES_CONNECTION env var",
"remediation": "Move the connection string to a secrets manager (e.g., 1Password, AWS Secrets Manager) and inject it at runtime. Do not store credentials in MCP config files.",
"config_path": "/Users/alice/.cursor/mcp.json",
"source": "cursor",
"suppressed": false,
"suppressed_by": "",
"observed_at": "2026-03-25T10:14:02Z",
"collected_at": "2026-03-25T10:14:02Z",
"processed_at": "2026-03-25T10:14:02Z",
"extensions": {}
},
{
"finding_id": "fnd_01j9kx2m3n4p5q6r7s8t9u0v3",
"finding_type": "hardcoded_secret",
"severity": "high",
"server": "github",
"detail": "GitHub PAT token in GITHUB_PERSONAL_ACCESS_TOKEN env var",
"remediation": "Use a secrets manager or environment variable injection at shell startup rather than storing the token in the config file.",
"config_path": "/Users/alice/.cursor/mcp.json",
"source": "cursor",
"suppressed": false,
"suppressed_by": "",
"observed_at": "2026-03-25T10:14:02Z",
"collected_at": "2026-03-25T10:14:02Z",
"processed_at": "2026-03-25T10:14:02Z",
"extensions": {}
},
{
"finding_id": "fnd_01j9kx2m3n4p5q6r7s8t9u0v4",
"finding_type": "suspicious_path",
"severity": "medium",
"server": "shadowed-fetch",
"detail": "Server binary loaded from hidden temp directory /tmp/.hidden/",
"remediation": "Investigate the origin of this server binary. Legitimate MCP servers are installed via npm or pip, not placed in temporary or hidden directories. Remove this server unless you can verify its provenance.",
"config_path": "/Users/alice/.cursor/mcp.json",
"source": "cursor",
"suppressed": false,
"suppressed_by": "",
"observed_at": "2026-03-25T10:14:02Z",
"collected_at": "2026-03-25T10:14:02Z",
"processed_at": "2026-03-25T10:14:02Z",
"extensions": {}
},
{
"finding_id": "fnd_01j9kx2m3n4p5q6r7s8t9u0v5",
"finding_type": "tool_shadowing",
"severity": "medium",
"server": "shadowed-fetch",
"detail": "Server name 'shadowed-fetch' shadows built-in 'fetch' capability",
"remediation": "Rename the server to avoid shadowing built-in capabilities, or remove it if it is not a legitimate server.",
"config_path": "/Users/alice/.cursor/mcp.json",
"source": "cursor",
"suppressed": false,
"suppressed_by": "",
"observed_at": "2026-03-25T10:14:02Z",
"collected_at": "2026-03-25T10:14:02Z",
"processed_at": "2026-03-25T10:14:02Z",
"extensions": {}
}
],
"extensions": {}
}
Markdown Output
With --format markdown, the same scan produces a formatted report:
## MCP Scan Report — cursor
**Config:** /Users/alice/.cursor/mcp.json
**Scanned:** 2026-03-25 10:14:02 UTC
**Servers scanned:** 4
**Findings:** 4 (0 critical, 2 high, 2 medium, 0 low, 0 info)
---
### 🔴 HIGH — hardcoded_secret — postgres-db
Database connection string with plaintext password in POSTGRES_CONNECTION env var
**Remediation:** Move the connection string to a secrets manager and inject it at runtime.
---
### 🔴 HIGH — hardcoded_secret — github
GitHub PAT token in GITHUB_PERSONAL_ACCESS_TOKEN env var
**Remediation:** Use a secrets manager or environment variable injection at shell startup.
---
### 🟡 MEDIUM — suspicious_path — shadowed-fetch
Server binary loaded from hidden temp directory /tmp/.hidden/
**Remediation:** Investigate the origin of this server binary and remove it if you cannot verify its provenance.
---
### 🟡 MEDIUM — tool_shadowing — shadowed-fetch
Server name 'shadowed-fetch' shadows built-in 'fetch' capability
**Remediation:** Rename the server or remove it if it is not legitimate.
Exit Codes
| Code | Meaning | CI Behaviour |
|---|---|---|
0 | No findings at or above the severity threshold | Pipeline step passes |
1 | One or more findings at or above the severity threshold | Pipeline step fails (configurable via --severity-threshold) |
2 | Scan error (config file not found, parse error, internal error) | Pipeline step fails unconditionally |
A partial scan status (some servers could not be parsed) exits with code 0 unless findings are also present above the threshold. The partial status is visible in the JSON status field.
Baseline Management
Creating a Baseline
After reviewing a clean scan result, create a baseline to record the approved state:
pavri-preflight baseline create \
--config ~/.cursor/mcp.json \
--approved-by "alice@example.com"
This writes a baseline JSON file to .pavri/mcp-baseline.json (relative to the current directory). The file contains a snapshot of all approved assets and a hash of the config file.
Commit the baseline file to version control. This lets the whole team share the approved state, and CI can detect drift against it.
Reviewing Drift
When the config changes after a baseline exists, scan automatically detects drift:
pavri-preflight baseline diff --config ~/.cursor/mcp.json
The output shows three sections:
- Added assets — servers or tools present in the current config but not the baseline
- Removed assets — servers or tools in the baseline that are no longer present
- Modified assets — servers where one or more fields changed, with the list of changed field names
Example diff output (JSON):
{
"baseline_id": "bsl_01j9kx2m3n4p5q6r7s8t9u0v9",
"added_assets": [
{
"asset_id": "ast_01j9kx2m3n4p5q6r7s8t9u0va",
"kind": "server",
"server_name": "new-search",
"name": "new-search",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env_keys": {"BRAVE_API_KEY": "***"},
"auth_mode": "",
"tool_count": 1,
"resource_count": 0,
"description": "",
"config_hash": "sha256:abc123..."
}
],
"removed_assets": [],
"modified_assets": [],
"drift_findings": [
{
"finding_id": "fnd_01j9kx2m3n4p5q6r7s8t9u0vb",
"finding_type": "baseline_drift",
"severity": "high",
"server": "new-search",
"detail": "Server 'new-search' was added since the last approved baseline",
"remediation": "Review the new server and approve a new baseline if the addition is intentional."
}
]
}
Approving Changes
After reviewing the diff and confirming the changes are intentional, update the baseline:
pavri-preflight baseline create \
--config ~/.cursor/mcp.json \
--approved-by "alice@example.com"
This overwrites the previous baseline. Future scans will compare against the new approved state.
Advanced CI Patterns
Matrix Scanning (Multiple Configs)
Scan all team members' shared MCP configs in a single job:
strategy:
matrix:
config:
- configs/cursor-team.json
- configs/claude-desktop-team.json
steps:
- name: MCP Preflight Scan (${{ matrix.config }})
run: |
pip install pavri
pavri-preflight scan \
--config ${{ matrix.config }} \
--severity-threshold high
Severity Gating
Block the pipeline on critical findings only, but emit a warning for high:
- name: MCP Preflight — critical gate
run: |
pavri-preflight scan --format json --severity-threshold critical \
--output scan-report.json
- name: MCP Preflight — high warning
continue-on-error: true
run: |
pavri-preflight scan --severity-threshold high
Uploading Results to the Pavri Dashboard
Include --org-id to embed your organisation ID in the report, then POST it to the Pavri API:
- name: MCP Preflight Scan
run: |
pip install pavri
pavri-preflight scan \
--format json \
--org-id "$PAVRI_ORG_ID" \
--output mcp-report.json
- name: Upload to Pavri
run: |
curl -s -X POST \
-H "Authorization: Bearer $PAVRI_API_KEY" \
-H "Content-Type: application/json" \
--data @mcp-report.json \
"$PAVRI_BACKEND_URL/api/v1/mcp/reports"
Caching Baselines in CI
Store the baseline file in your repository so CI always has a reference point:
- name: Restore MCP baseline
uses: actions/cache@v4
with:
path: .pavri/mcp-baseline.json
key: mcp-baseline-${{ hashFiles('.pavri/mcp-baseline.json') }}
Or commit the baseline file directly to the repository and check it in with your config files.
Troubleshooting
Config file not found
Error: config file not found at /Users/alice/.cursor/mcp.json
The specified path does not exist. Check that the MCP client is installed and has been configured at least once (the config file is created on first use). If you are specifying a custom path with --config, verify the path is correct.
Partial scan status
The report status field is partial when one or more servers in the config could not be parsed. This usually means the config file has a syntax error in one server entry that does not affect the others. Check the markdown_summary field for a list of servers that were skipped and why.
To debug, validate your config file against the client's expected schema:
# For Cursor / Claude Desktop — validate JSON syntax
python3 -m json.tool ~/.cursor/mcp.json
False positives
If the scanner generates a finding that does not represent a real risk in your environment, you have two options:
- Suppress by finding type — use
--suppress FINDING_TYPEin thescancommand (e.g.--suppress hardcoded_secret). Findings of that type are excluded from the report and do not affect the exit code. - Adjust the threshold — use
--severity-thresholdto ignore findings below a certain severity level. This affects all findings, not just the false positive.
For persistent suppressions in CI, maintain a suppressions file and pass each ID via --suppress:
pavri-preflight scan \
--suppress hardcoded_secret \
--suppress tool_shadowing \
--severity-threshold medium