Introduction
The previous articles covered two problems: the rug pull, an MCP server changing its tools after you approved them, and tool poisoning, instructions hidden inside tool definitions.
This one is hands-on. The goal is simple: if your product depends on one or more MCP servers, you want CI to fail the day one of those servers changes its tools, exactly as it would if an npm dependency changed without you updating it.
The tool is mcpsonar scan: it connects to the server once, lists the tools, runs the tool-poisoning lint and compares everything against a lockfile you keep in git.
The output in this article is real: I got it by running
mcpsonar scanagainst a small test MCP server, first clean and then "poisoned".
Prerequisites
- Python 3.11+
- An MCP server to check, reachable over HTTP/SSE or runnable locally over stdio
- A GitHub repository, for the CI part
Install MCPSonar:
pip install git+https://github.com/alefra93/MCPSonar.git@v0.5.0
Note the @v0.5.0 at the end: you install a specific release, not the latest commit. We'll see why in step 6.
Step 1 — The first scan and the lockfile
Run the scan with --lock:
mcpsonar scan https://mcp.example.com/mcp --lock mcp-tools.lock.json
If the server is protected, add headers with -H:
mcpsonar scan https://mcp.example.com/mcp -H "Authorization: Bearer $MCP_TOKEN" --lock mcp-tools.lock.json
And if it's a stdio server (the usual case for npm or Python packages), use --stdio with the full command:
mcpsonar scan --stdio "npx -y @modelcontextprotocol/server-everything" --lock mcp-tools.lock.json
On the first run the lockfile doesn't exist, so MCPSonar creates it. On my test server, a calculator with two tools:
mcpsonar scan · python server.py
server calculator 1.28.1 · spec 2025-11-25 · 2 tools · 517 ms
✓ add clean
✓ multiply clean
lockfile written: mcp-tools.lock.json
no findings → PASS
The lockfile is readable JSON. For each tool it holds the SHA-256 hash of the definition and the full definition:
{
"mcpsonar_lock": 1,
"server": { "name": "calculator", "version": "1.28.1" },
"spec_version": "2025-11-25",
"generated_at": "2026-10-14T21:33:24+00:00",
"tools": {
"add": {
"sha256": "268388682897c264d2e9e794a282d9b0e5def8861d56bd8620d7879ec94ae25c",
"definition": {
"name": "add",
"description": "Adds two numbers.",
"inputSchema": { "...": "..." }
}
}
}
}
Keeping the definition, not just the hash, is what makes the lockfile useful. When something changes, MCPSonar tells you what changed, and above all you see it yourself in the git diff.
Commit it:
git add mcp-tools.lock.json
git commit -m "pin MCP tool definitions"
Step 2 — What happens when a tool changes
On later runs the scan compares the tools with the lockfile. If nothing changed:
lockfile: ✓ tools match the pinned definitions
no findings → PASS
Now let's simulate a rug pull: the test server's add tool is replaced by the poisoned version from the classic PoC, with an <IMPORTANT> block asking to read ~/.cursor/mcp.json and a new sidenote parameter. Same command, same lockfile:
mcpsonar scan · python server.py
server calculator 1.28.1 · spec 2025-11-25 · 2 tools · 539 ms
✗ add HIGH fake-system-tag — pseudo-markup that imitates system/priority instructions
HIGH hidden-from-user — asks the model to keep something from the user
HIGH sensitive-path — references credentials or client configuration files
MEDIUM hidden-precondition — demands a side action before the tool can be used
MEDIUM suspicious-parameter — parameter 'sidenote' matches a known context-exfiltration pattern
✓ multiply clean
lockfile: 1 change(s) since the tools were pinned
~ add: description, inputSchema changed
3 high, 2 medium → FAIL
(I removed the lines with text excerpts, which appear under each finding in the real output.)
The scan fails for two independent reasons: the lint finds five problems, and the lockfile reports that add changed in its description and schema. Even an attack subtle enough to slip past the lint would still be stopped by the lockfile.
Step 3 — Accepting a legitimate change
Most changes aren't attacks. If the maintainer rewrites the description of multiply from "Multiplies two numbers." to "Multiplies two integers and returns the product.", the scan fails anyway:
lockfile: 1 change(s) since the tools were pinned
~ multiply: description changed
no findings → FAIL
That's on purpose: the lockfile doesn't judge, it reports. To accept the new version, after reading it:
mcpsonar scan https://mcp.example.com/mcp --lock mcp-tools.lock.json --update-lock
git diff mcp-tools.lock.json
git diff shows you the old and new description side by side. If you commit it in a pull request, a change to a third-party tool goes through code review, like any other change to your product. To me that's the most useful part of the whole mechanism.
Step 4 — Exit codes
CI works with exit codes, so it's worth knowing them:
0: no finding above the threshold and no difference from the lockfile.1: at least one finding at or above--fail-on(defaulthigh), or any difference from the lockfile.2: the scan couldn't talk to the server.
mcpsonar scan · http://127.0.0.1:9/mcp
✗ could not connect: ConnectError: All connection attempts failed
Code 2 also covers the most common case in CI: a protected server answering 401 or 403 because the token is missing or expired. In that case the message says so explicitly (authentication required (401) — pass a token with -H 'Authorization: Bearer <token>'), so you don't have to guess.
--fail-on accepts high, medium, low or never. With never the lint becomes informational only, and the lockfile is what fails the scan. For scripting there's also --json, which returns findings and differences in a structured format.
Step 5 — The GitHub Action
MCPSonar ships a GitHub Action that does all of this. A complete workflow, in .github/workflows/mcp-tools.yml:
name: MCP tools
on:
pull_request:
schedule:
- cron: "0 6 * * *" # every morning at 6 (UTC)
workflow_dispatch:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: alefra93/MCPSonar@v0.5.0
with:
target: https://mcp.example.com/mcp
lock: mcp-tools.lock.json
fail-on: high
headers: |
Authorization: Bearer ${{ secrets.MCP_TOKEN }}
The available inputs are target (server URL), stdio (a command instead of the URL), headers (one per line), lock, fail-on and python-version (default 3.12).
Three details of this workflow aren't obvious.
1. The schedule trigger matters most. A third-party server changes without anything changing in your repository. If the scan only runs on pull_request, you find out at the next PR, possibly weeks later. With a daily check you know within 24 hours.
2. You need actions/checkout. The lockfile lives in the repository: without a checkout, the scan can't find it.
3. The token goes in secrets. Never in plain text in the workflow, not even for a test server.
Several servers
If you depend on several servers, use a matrix with one lockfile each:
jobs:
scan:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
server:
- { name: search, url: "https://search.example.com/mcp" }
- { name: payments, url: "https://payments.example.com/mcp" }
steps:
- uses: actions/checkout@v4
- uses: alefra93/MCPSonar@v0.5.0
with:
target: ${{ matrix.server.url }}
lock: locks/${{ matrix.server.name }}.lock.json
fail-fast: false keeps one failing server from stopping the checks on the others.
Step 6 — Pin the Action version too
In the examples the Action is referenced as @v0.5.0, not @main. That's not a detail.
This whole article is about not trusting something that can change after you approved it. It would be ironic to do that with a security check that, with @main, runs whatever code happens to be on the repository's main branch at that moment inside your pipeline. It's the rug-pull problem, one level down.
A tag is already much better than @main, but a tag can be moved. The strongest guarantee is pinning the release's full commit SHA, which you'll find on the Releases page for each version:
- uses: alefra93/MCPSonar@<full-commit-sha> # v0.5.0
The version comment is for you, to see at a glance what you're running. To avoid updating the SHA by hand, let Dependabot propose new versions in a PR:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule: { interval: weekly }
That goes for MCPSonar and for any other Action that runs with access to your secrets.
The easiest mistakes to make
Letting CI create the lockfile. If the lockfile doesn't exist, mcpsonar scan writes it and passes. In CI, though, the file is written on the runner and thrown away when the job ends: on the next run it's missing again, gets rewritten, and the scan passes again. The drift protection was never there. Generate the lockfile locally and commit it before enabling the workflow.
Not pinning stdio server versions. With npx -y some-package you download the latest version every time. The lockfile will flag every update, which is what you want, but if you'd rather update at your own pace, pin the version (some-package@1.4.2) and bump it in a PR.
Treating an unreachable server as an attack. Code 2 fails the job just like 1, but it means something different: the server is down, or the token in your secrets expired (and in that case the output says so). When a job fails, read the output before you panic.
Raising the threshold to silence a false positive. A tool that has to mention .env will always produce a high finding. Today MCPSonar has no way to ignore a single finding, so the alternative is fail-on: medium or never for that server, relying on the lockfile. But do it after reading the description, not just to unblock the build.
Conclusion
With one command and a dozen lines of YAML, the MCP servers you depend on become pinned dependencies: every change to a tool definition fails CI, ends up in a readable diff and goes through code review before it reaches your agents.
It doesn't replace continuous monitoring, which is still the job of the MCPSonar dashboard (see the Docker tutorial, in Italian), but it closes the biggest gap: the one between the moment you approved a server and the moment your agent uses it.
To sum up:
mcpsonar scan <server> --lock mcp-tools.lock.jsonlocally, then commit the lockfile.- A workflow with the Action pinned to a release (better still, to the SHA), on
pull_requestand onschedule. - For every legitimate change:
--update-lock, read the diff, open a PR.
MCPSonar is open source: the Action and the code are on GitHub. If you use it in one of your projects, I'd love to hear about it: open an issue or get in touch.