How to run Faintprint
Faintprint is installed once with pipx from the private package index, with the access token you were sent, a name and a secret (the README gives the details: getting pipx, keeping the secret out of your shell history, and the lines for an installation without pipx). The token goes to this one command only; nothing on your machine keeps it:
PIP_INDEX_URL="https://$FAINTPRINT_USER:$FAINTPRINT_TOKEN@gitlab.com/api/v4/projects/faintprint%2Ffaintprint/packages/pypi/simple" pipx install --force "faintprint[connect]==0.5.0"
or run as a container image with the same token (docker login registry.gitlab.com --username "$FAINTPRINT_USER", the secret as the password), where a scan becomes docker run --rm -v "$PWD:/work" registry.gitlab.com/faintprint/faintprint:0.5.0 scan /work/… — the image reads and writes only the folder you run it from, so --discover, which reads this machine's own config files, needs the pipx or pip installation.
Once Faintprint is installed, the faintprint command runs from any folder. The commands below are written as faintprint …; in a .venv installation, activate it first (source .venv/bin/activate). Reports are written to scan-reports/<date and time>/ (ignored by git) unless you pass --out DIR. Typed in a terminal, the run opens index.html in your browser when it ends (--no-open skips that); otherwise open it yourself, in that folder, to see every server of the run. Each server has its own page (HTML) and the same content as JSON. For a server fetched from a web address, its tool list is saved beside the report as <server>.tools.json, so the same report can be rebuilt later with scan <server>.tools.json and no network.
1. Scan what your AI tools use
faintprint scan
With nothing named, the command does what faintprint scan --discover does: it reads the usual user-level config files of Claude Desktop, Claude Code, Cursor, Windsurf and VS Code on this machine — read only — and prints every place it looked and what it found there (a file that is not present, a file with no MCP server list, or how many servers it lists). Remote servers are fetched (list only: no tool is ever called). Local servers are never started; they are listed "not scanned", with the option that includes them.
Servers added inside an app's own account (claude.ai connectors, for example) are not in these files and cannot be found this way. If nothing is found, the command says so and exits with code 2, so a pipeline that expects servers does not go green on zero.
2. Scan one remote server by its address
faintprint scan https://example.com/mcp
Write the address in full, with https://. A name without it is read as a file name; if it looks like an address, the command says so and shows the full form.
If it needs a sign-in, put the token in an environment variable and name it (the token then goes to that one address only, never to anything else):
MY_TOKEN=… faintprint scan https://example.com/mcp --bearer-env MY_TOKEN
3. Include a local server
The scanner never starts a program on your machine. To scan a local server, run it once yourself with the official MCP Inspector (version 2) and save its answer to "list tools". Your server's command goes before the --; the Inspector's own options go after it:
npx -y @modelcontextprotocol/inspector --cli <your server command> -- --method tools/list --format json > NAME-tools.json
The command that was tested, for the reference filesystem server:
npx -y @modelcontextprotocol/inspector --cli npx -y @modelcontextprotocol/server-filesystem /tmp/mcp-test -- --method tools/list --format json --connect-timeout 120000 > filesystem-tools.json
The output is wrapped as {"result": {"tools": [...]}}; the scanner reads that form. The first run may need the longer connection timeout while npx downloads the packages. Before using the Inspector on a shared machine: its README says it stores OAuth tokens and the env: values it is given in the system keychain or in ~/.mcp-inspector/secrets.json.
Then:
faintprint scan ~/.cursor/mcp.json --tools NAME=NAME-tools.json
NAME is the server's name in the config file. A saved tool list can also be scanned on its own: scan NAME-tools.json.
When you scan a config file, the terminal prints this line for each local server with that server's own command and arguments already filled in. Any argument that looks like a key is shown as <hidden> — put yours back before you run it — and the config's environment settings (env) are not copied: set them in your terminal first. The filled-in line is printed only in the terminal, never in a report or file. It starts the server exactly as the config says, as your AI tool would: run it only for a config you trust.
4. The AI review
Off unless you ask. With --review the static scan runs first and the reports are written; then the command prints the borderline passages it would read — the sentence around each match, from the servers' own text — how many calls that is, the estimated cost and the worst case, the model, and what leaves your machine, and asks before spending anything. It needs ANTHROPIC_API_KEY in your environment (in ~/.zshenv); without it the reports say "AI review: not run". A Claude subscription (Pro, Max, Team) is not an API key: create one at https://platform.claude.com/settings/keys (API use is billed separately). Before the question about the price, the command checks the key with one free token count that sends the key and one fixed word, never a server's text; a refused key is said there — "Your Anthropic key was refused — nothing was spent." In a pipeline, --yes confirms in advance, and the review then runs only if its estimate is within --max-cost (default $1.00).
5. The build check
The run ends with one answer, also given as the exit code:
| code | meaning |
|---|---|
| 0 | pass: everything checked, nothing at or above the level |
| 1 | fail: a server shows signs of an attack at or above the level |
| 2 | could not run: wrong arguments, an unreadable file, or nothing to scan |
| 3 | not finished: something was not checked (a review not run, a server not reachable, a local server without a tool list) |
The level is "Signs of an attack: Suspicious or worse" unless you choose another with --fail-on minor | suspicious | likely-attack | never. --allow-unfinished lets an unfinished check pass, with a warning.
6. Reading a report
Each server's page starts with one line that answers May we use this server?, and one sentence of what to do:
| the line | when |
|---|---|
| Do not use | a sign rated Likely attack |
| Do not use until checked | a sign rated Suspicious |
| Check before use | only Minor signs |
| Not finished | nothing found so far, but borderline passages were not read |
| No signs of an attack | checked, nothing found — then what it can do: "nothing powerful found", or what it can do and "give it only to people who need that" |
The line is worked out from the two ratings below it and never changes the build check. Under it, two ratings: Signs of an attack (is this server trying to trick your AI?) and What it can do (how much power it has, and how clearly its schemas say so). Under every sign of an attack is the tool's whole description, with the matched words marked. The advice under each finding is labelled "What the publisher should change": it is for the server's author. A Not finished page starts with the passages nobody has read yet, and the same tool's notes on steering between tools under "Worth reading before you decide". The run's page lists each server by its own name, with the file or config entry beside it. The scanner's older single grade (PASS, WARN, FAIL, CRITICAL) is under "Technical details" at the bottom. That grade counts everything on the page, not only signs of an attack, so it can be high while the attack rating is clean; the build check reads the attack rating only.
faintprint scan --help prints the essentials of all of this.
7. In a pipeline
A ready-made step runs the same scan in GitHub Actions or GitLab CI. It installs Faintprint with your token at the library versions it was tested on, scans what you name, keeps the reports as the job's artefact (also when the job fails), and ends the job with the exit code of section 5. Store the token's name and secret as pipeline secrets, FAINTPRINT_USER and FAINTPRINT_TOKEN: the step gives them to the install only, and pip shows the secret as ****. Your purchase comes with two tokens, one for people and one for pipelines, so that one leaking from a pipeline can be switched off without cutting off your people.
GitHub Actions (secrets: Settings → Secrets and variables → Actions):
- uses: faintprint/scan-action@714a55b7270c66d97276c687557f4284b0aeb448 # v1.3.0
with:
scan: |
.vscode/mcp.json
servers/notes-tools.json
deploy-user: ${{ secrets.FAINTPRINT_USER }}
deploy-token: ${{ secrets.FAINTPRINT_TOKEN }}
A pull request from a fork gets no secrets on GitHub, so the step stops there with one plain line.
GitLab CI (variables: Settings → CI/CD → Variables, the token marked Masked; a Protected variable reaches protected branches only):
include:
- component: $CI_SERVER_FQDN/faintprint/pipeline/scan@7f17722aff486b22446effa85649453ba179a6de # 1.3.0
inputs:
scan: |
.vscode/mcp.json
servers/notes-tools.json
On a GitLab you run yourself, include the same file by its address, pinned to a commit and checked by its SHA-256 (GitLab 17.9 or later), or copy it into your repository:
include:
- remote: https://gitlab.com/faintprint/pipeline/-/raw/7f17722aff486b22446effa85649453ba179a6de/templates/scan.yml
integrity: sha256-M/8INd8NcOO+zORqVDf8uDVyg0GusEz/EmcGt/pXHJ0=
inputs:
scan: .vscode/mcp.json
The lines above are pinned to a commit (version 1.3.0 of the step), so your check changes only when you change the line. faintprint/scan-action@v1 and scan@1.3.0 are the shorter forms; v1 takes every 1.x release. Give each GitLab include its own job-name to run several scans.
Inputs, the same on both:
| input | what it does | default |
|---|---|---|
scan | what to scan, one per line: a saved tool list, a web address, or a client config file in the repository | required |
tools | NAME=FILE lines: saved tool lists for the local servers in a config file (section 3) | none |
fail-on | the "Signs of an attack" level that fails the job | suspicious |
review | run the AI review on your own key (GitHub: pass anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}; GitLab: the variable ANTHROPIC_API_KEY) | false |
max-cost | the most one run's review may cost, in US dollars | 1.00 |
allow-unfinished | let a "not finished" run pass, with a warning | false |
version | the Faintprint version | 0.5.0 |
reports | the report folder and artefact name | faintprint-reports |
upload-sarif | GitHub only: upload the findings to the Security tab (section 8) | false |
sarif-category | GitHub only: the upload's category; two Faintprint steps in one job, or a matrix, need one each | faintprint-<job id> |
Without the review, a server with a borderline passage ends "not finished" and fails the job; the job's last line says the two ways on: turn on review, or set allow-unfinished: true. On a public repository anyone can download the reports.
8. Findings in GitHub's Security tab (SARIF)
--sarif FILE writes the findings in SARIF 2.1.0, the standard format code scanners hand their findings over in, beside the reports:
faintprint scan .vscode/mcp.json --out faintprint-reports --sarif faintprint-reports/faintprint.sarif
What goes in: every sign of an attack, at its level: Likely attack as GitHub's critical, Suspicious as high, Minor as medium. A passage the AI reviewer confirmed is an alert of its own, with the reviewer's reason. A borderline passage the review did not read, or read and could not decide on, is a note. Passages the reviewer cleared and "What it can do" stay on the report page.
Where each alert points: at the line that holds the passage, when the tool list is a saved file in the folder you scan from (in a pipeline, the repository); at the server's entry, when a config file in that folder names a remote server; otherwise at the copy of the tool list the run saves in the report folder. The Inspector saves a tool list on one line, so every alert in such a file is on line 1; python3 -m json.tool NAME-tools.json spreads it over lines first if you want them apart. The same finding stays the same alert from run to run, even when lines move; run by hand, give --out a fixed folder, or each run's new folder name makes new alerts.
In a pipeline, the step writes faintprint.sarif into the reports every time. On GitHub, upload-sarif: true uploads it, also when the scan failed. The workflow must allow it, because a step cannot give itself permissions:
permissions:
contents: read
actions: read # private repositories only
security-events: write
Code scanning is free on public repositories. On a private repository it needs GitHub Code Security on your organisation (GitHub Team or Enterprise); without it GitHub refuses the upload and the job fails with one plain line naming the two causes. Two Faintprint steps in one job, or a matrix, need their own sarif-category, or the second upload replaces the first's alerts. On GitLab the file is in the job's artifacts for any tool that reads SARIF; GitLab's own security pages are not filled by it.