Skip to main content

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]
FlagDefaultDescription
--config PATHauto-discoverPath to a specific MCP config file to scan. If omitted, all supported client configs are discovered automatically.
--source NAMEderived from pathOverride the source label (cursor, claude_desktop, vscode, gemini_cli). Useful when scanning a copy of a config file.
--format FORMATjsonOutput format: json or markdown.
--output PATHstdoutWrite output to a file instead of stdout.
--baseline PATHauto-discoverPath to a baseline JSON file to compare against. If a matching baseline exists at the default location, it is used automatically.
--no-baselineDisable baseline comparison even if a baseline file exists.
--severity-threshold LEVELinfoExit with code 1 if any finding meets or exceeds this severity. Levels: info, low, medium, high, critical.
--suppress FINDING_TYPESuppress 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_IDOrganisation 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]
FlagDefaultDescription
--config PATHauto-discoverPath to the MCP config file to baseline.
--output PATH.pavri/mcp-baseline.jsonWhere to write the baseline file.
--approved-by NAMEcurrent userName 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]
FlagDefaultDescription
--baseline PATH.pavri/mcp-baseline.jsonPath to the baseline file to compare against.
--config PATHauto-discoverPath to the current MCP config file.
--format FORMATjsonOutput 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

CodeMeaningCI Behaviour
0No findings at or above the severity thresholdPipeline step passes
1One or more findings at or above the severity thresholdPipeline step fails (configurable via --severity-threshold)
2Scan 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_TYPE in the scan command (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-threshold to 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