A lockfile for your MCP servers: catching tool drift in CI with GitHub Actions

ai-integration - 15/10/2026

8 min read

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 scan against 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 (default high), 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:

  1. mcpsonar scan <server> --lock mcp-tools.lock.json locally, then commit the lockfile.
  2. A workflow with the Action pinned to a release (better still, to the SHA), on pull_request and on schedule.
  3. 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.