# mcp_prescan.py — MCP config & tool-poisoning pre-scan

Deterministic, offline pre-scan for **Model Context Protocol**
integrations — the config files and tool-registration source that wire
external tools into an agent. MCP's documented risks are all
manifest-level: a `description` field can steer the model
(tool poisoning), an `env` block can hand your credentials to a remote
host, a `command` line can fetch unpinned remote code at every launch,
and `autoApprove` can remove the human gate entirely. These are exactly
the shapes a line/JSON-level scanner *can* see.

ARION service artifact. Stdlib only. Honest scope: this is a **pre-scan
for human review**, not an audit — it flags dangerous integration
shapes; it cannot prove a server is malicious, and a clean report is
not a poison-free guarantee.

## What it scans

- `*.mcp.json`, `mcp.json`, `.mcp.json`, `claude_desktop_config.json`,
  `mcp_settings.json`, `mcp-servers.json`, `mcp_config.json`,
  `cline_mcp_settings.json`, `roo_mcp_settings.json`, `mcphub.json`,
  `mcp.yaml`, `mcp.yml`
- any `*.json` containing `"mcpServers"` (Cursor/Windsurf/VS Code
  nested configs)
- source files (`.py .js .ts .mjs .cjs .go .rs .java .kt`) carrying
  tool-registration vocabulary (`@mcp.tool`, `server.tool(`,
  `registerTool`, `addTool`, `Tool(`, `list_tools`, `FastMCP`, …) —
  including FastMCP-style **docstring** tool descriptions

## Codes

| code | sev | shape |
|---|---|---|
| `MCP-DESC-INJECT` | HIGH | description/instruction field directs model behaviour — ignore-instructions, must-call-first, concealment, fake user-approval |
| `MCP-DESC-EXFIL` | HIGH | description pairs sensitive-local-data nouns (ssh keys, .env, creds, cookies) with a collect/send verb — the exfil tool |
| `MCP-SECRET-LITERAL` | HIGH | credential-shaped literal committed in config under a secret-named key |
| `MCP-SECRET-TOREMOTE` | HIGH/LOW | secret-named env passed to a remote-URL server — HIGH when the remote declares no auth; LOW (declared-auth passthrough note) otherwise |
| `MCP-REMOTE-PLAINTEXT` | HIGH | non-loopback `http://` transport |
| `MCP-AUTOAPPROVE-ALL` | HIGH | `autoApprove`/`alwaysAllow`/`allowedTools`/`permissions` contains `*` |
| `MCP-REMOTE-UNPINNED` | MEDIUM | `npx`/`uvx`/`pipx`/`dlx`/`bunx`/`deno`/`docker run` on a bare name, `@latest`, or a git URL without a commit/semver ref |
| `MCP-CMD-SHELLWRAP` | MEDIUM | `sh -c` / `bash -c` / `cmd /c` / `powershell -Command` wrapper hides the real command |
| `MCP-REMOTE-NOAUTH` | MEDIUM | remote endpoint with no headers/auth/oauth/token config on the entry |
| `MCP-AUTOAPPROVE-WRITE` | MEDIUM | auto-approved list includes write/exec-capable tool names |
| `MCP-HOMEDIR-SCOPE` | MEDIUM | server argument grants `/`, `~`, `$HOME`, `/Users/<u>`, `/home/<u>`, `C:\` scope |
| `MCP-BIND-ALLIFACE` | MEDIUM | `host`/`bind`/`listen`/url on `0.0.0.0` or `::` |
| `MCP-VERSION-RANGE` | LOW | floating range pin (`@^x`, `@~x`, `@beta`) instead of exact version/digest |
| `MCP-TOOL-SHADOW` | LOW | duplicate tool name across entries — shadow/collision |

## Suppression & downgrade rules

- **Loopback transports** (`localhost`, `127.0.0.1`, `[::1]`) suppress
  all remote findings.
- **Exact pins** (`name@1.2.3`, `name==1.2.3`, `image@sha256:`,
  `git-url#<commit>` / `#v<semver>`) suppress `MCP-REMOTE-UNPINNED`.
- **`${VAR}`/`$VAR`/`%VAR%` placeholders** never fire
  `MCP-SECRET-LITERAL` — they feed SECRET-TOREMOTE logic only, and only
  on remote transports.
- **Secret-named env on stdio (command) servers** produces no finding —
  credentials to a local subprocess are the standard shape.
- **Declared-auth remotes** (`headers`/`auth`/`oauth`/`token` fields
  present) downgrade `MCP-SECRET-TOREMOTE` to LOW.
- **Read-only autoApprove lists** (`read`, `get`, `list`, `fetch`,
  `search`, `view`, `find`, `describe`) produce no finding.
- Description heuristics apply only to description/instruction/prompt/
  title fields (structured configs) or description-ish literals within
  20 lines of tool-registration vocabulary (source) — random UI
  `"description"` strings are ignored.
- Generic `.yaml`/`.toml`/`.json` files are scanned **only when they
  carry MCP vocabulary** — file name alone is not enough.
- Test/spec/fixture paths downgrade one tier. Comments are stripped.

## Usage

```sh
python3 mcp_prescan.py <file-or-dir>          # text report
python3 mcp_prescan.py <file-or-dir> --json   # JSON findings
python3 mcp_prescan.py --selftest             # 27-vector fixture suite
```

Exit codes: 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.

## Sample report

`sample-repo/` ships a hostile `mcp.json` (nine servers exercising
every code), a FastMCP-style `poisoned_tool.py` (docstring exfil), and
a `clean.mcp.json` (pinned versions, declared auth, loopback, read-only
auto-approvals → only the LOW declared-auth note). `sample-report.md`
is the scanner's own output on it: 5 HIGH / 7 MEDIUM / 1 LOW on the
hostile fixture, one LOW on the clean one.

## Calibration

- 27/27 selftest vectors pass.
- 3,590-file working tree (mixed JS/TS/Py/JSON): only findings are the
  tool's own fixture strings plus one genuine detection in a sibling
  prescan's hostile `mcp.json` — zero false-positive noise on real
  application code.
- Realistic production-style config (canonical
  `@modelcontextprotocol/server-*` entries): fires exactly the three
  `MCP-REMOTE-UNPINNED` rows for the unpinned-default package specs —
  the pattern every official example ships, and the fix the report
  recommends.
