Un lockfile per i tuoi server MCP: bloccare il drift dei tool in CI con GitHub Actions

ai-integration - 15/10/2026

8 min read

Introduzione

Negli articoli precedenti abbiamo visto due problemi: il rug pull, cioè un server MCP che cambia i suoi tool dopo che li hai approvati, e il tool poisoning, cioè istruzioni nascoste dentro le definizioni dei tool.

Qui passiamo alla pratica. L'obiettivo è semplice: se il tuo prodotto dipende da uno o più server MCP, vuoi che la CI fallisca il giorno in cui uno di quei server cambia i suoi tool, esattamente come fallirebbe se una dipendenza npm cambiasse senza che tu l'abbia aggiornata.

Lo strumento è mcpsonar scan: si collega al server una volta, elenca i tool, esegue il lint anti-poisoning e confronta tutto con un lockfile che tieni in git.

Gli output che trovi in questo articolo sono reali: li ho ottenuti facendo girare mcpsonar scan contro un piccolo server MCP di prova, prima pulito e poi "avvelenato".


Prerequisiti

  • Python 3.11+
  • Un server MCP da controllare, raggiungibile via HTTP/SSE oppure lanciabile in locale via stdio
  • Un repository su GitHub, per la parte di CI

Installa MCPSonar:

pip install git+https://github.com/alefra93/MCPSonar.git@v0.5.0

Nota il @v0.5.0 in fondo: installi una release precisa, non l'ultimo commit. Il motivo lo vediamo al passo 6.


Passo 1 — Il primo scan e il lockfile

Lancia lo scan con l'opzione --lock:

mcpsonar scan https://mcp.example.com/mcp --lock mcp-tools.lock.json

Se il server è protetto, aggiungi gli header con -H:

mcpsonar scan https://mcp.example.com/mcp -H "Authorization: Bearer $MCP_TOKEN" --lock mcp-tools.lock.json

E se è un server stdio (il caso tipico dei pacchetti npm o Python), usa --stdio con il comando completo:

mcpsonar scan --stdio "npx -y @modelcontextprotocol/server-everything" --lock mcp-tools.lock.json

Al primo giro il lockfile non esiste, quindi MCPSonar lo crea. Sul mio server di prova, una calcolatrice con due tool:

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

Il lockfile è un JSON leggibile. Per ogni tool contiene l'hash SHA-256 della definizione e la definizione completa:

{
  "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": { "...": "..." }
      }
    }
  }
}

Tenere anche la definizione, e non solo l'hash, è quello che rende il lockfile utile. Quando qualcosa cambia, MCPSonar ti dice cosa è cambiato, e soprattutto lo vedi tu stesso nel diff di git.

Committalo:

git add mcp-tools.lock.json
git commit -m "pin MCP tool definitions"

Passo 2 — Cosa succede quando un tool cambia

Ai giri successivi lo scan confronta i tool con il lockfile. Se è tutto uguale:

  lockfile: ✓ tools match the pinned definitions

  no findings → PASS

Ora simuliamo un rug pull: il tool add del server di prova viene sostituito dalla versione avvelenata del PoC classico, con un blocco <IMPORTANT> che chiede di leggere ~/.cursor/mcp.json e un nuovo parametro sidenote. Stesso comando, stesso 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

(Ho tolto le righe con gli estratti di testo, che nell'output reale compaiono sotto ogni finding.)

Lo scan fallisce per due motivi indipendenti: il lint trova cinque problemi, e il lockfile segnala che add è cambiato nella descrizione e nello schema. Anche un attacco così sottile da sfuggire al lint verrebbe comunque fermato dal lockfile.


Passo 3 — Accettare un cambiamento legittimo

La maggior parte dei cambiamenti non è un attacco. Se il manutentore riscrive la descrizione di multiply da "Multiplies two numbers." a "Multiplies two integers and returns the product.", lo scan fallisce lo stesso:

  lockfile: 1 change(s) since the tools were pinned
    ~ multiply: description changed

  no findings → FAIL

È voluto: il lockfile non giudica, segnala. Per accettare la nuova versione, dopo averla letta:

mcpsonar scan https://mcp.example.com/mcp --lock mcp-tools.lock.json --update-lock
git diff mcp-tools.lock.json

Il git diff ti mostra la vecchia e la nuova descrizione una accanto all'altra. Se la committi in una pull request, il cambiamento di un tool di terze parti passa da una code review, come qualunque altra modifica al tuo prodotto. Secondo me è la parte più utile di tutto il meccanismo.


Passo 4 — I codici di uscita

La CI ragiona con i codici di uscita, quindi è bene conoscerli:

  • 0: nessun finding sopra la soglia e nessuna differenza con il lockfile.
  • 1: almeno un finding con gravità pari o superiore a --fail-on (default high), oppure una qualsiasi differenza con il lockfile.
  • 2: lo scan non è riuscito a parlare con il server.
mcpsonar scan · http://127.0.0.1:9/mcp
  ✗ could not connect: ConnectError: All connection attempts failed

Il codice 2 copre anche il caso più frequente in CI: un server protetto che risponde 401 o 403 perché il token manca o è scaduto. In quel caso il messaggio lo dice esplicitamente (authentication required (401) — pass a token with -H 'Authorization: Bearer <token>'), così non devi indovinare.

--fail-on accetta high, medium, low o never. Con never il lint diventa solo informativo e a far fallire lo scan resta il lockfile. Per un uso in script c'è anche --json, che restituisce findings e differenze in formato strutturato.


Passo 5 — La GitHub Action

MCPSonar include una GitHub Action che fa tutto questo. Un workflow completo, in .github/workflows/mcp-tools.yml:

name: MCP tools

on:
  pull_request:
  schedule:
    - cron: "0 6 * * *"   # ogni mattina alle 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 }}

Gli input disponibili sono target (URL del server), stdio (comando alternativo all'URL), headers (uno per riga), lock, fail-on e python-version (default 3.12).

Tre dettagli di questo workflow non sono ovvi.

1. Il trigger schedule è il più importante. Un server di terze parti cambia senza che nel tuo repository cambi niente. Se lo scan gira solo su pull_request, te ne accorgi alla prossima PR, magari dopo settimane. Con un controllo giornaliero lo sai entro 24 ore.

2. Serve actions/checkout. Il lockfile sta nel repository: senza checkout, lo scan non lo trova.

3. Il token va nei secrets. Mai in chiaro nel workflow, nemmeno per un server di test.

Più server

Se dipendi da più server, usa una matrice con un lockfile per ciascuno:

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 fa sì che un server che fallisce non interrompa il controllo degli altri.


Passo 6 — Fissa la versione della Action (anche questa)

Negli esempi la Action è referenziata con @v0.5.0, non con @main. Non è un dettaglio.

Tutto questo articolo parla di non fidarsi di qualcosa che può cambiare dopo che l'hai approvato. Sarebbe ironico farlo con un controllo di sicurezza che, con @main, esegue nella tua pipeline qualunque codice ci sia sul branch principale del repository in quel momento. È lo stesso problema del rug pull, spostato un livello più in basso.

Un tag è già molto meglio di @main, ma un tag si può spostare. La garanzia più forte è fissare lo SHA completo del commit della release, che trovi nella pagina Releases di ogni versione:

- uses: alefra93/MCPSonar@<sha-completo-del-commit>  # v0.5.0

Il commento con la versione serve a te, per capire a colpo d'occhio cosa stai usando. Per non dover aggiornare lo SHA a mano, lascia che sia Dependabot a proporti le nuove versioni con una PR:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule: { interval: weekly }

Vale per MCPSonar e per qualsiasi altra Action che gira con accesso ai tuoi secrets.


Gli errori più facili da fare

Lasciare che sia la CI a creare il lockfile. Se il lockfile non esiste, mcpsonar scan lo scrive e passa. In CI però il file viene scritto nel runner e buttato via a fine job: al giro successivo non c'è di nuovo, viene riscritto, e lo scan passa ancora. Così la protezione sul drift non c'è mai stata. Genera il lockfile in locale e committalo prima di attivare il workflow.

Non fissare la versione dei server stdio. Con npx -y qualche-pacchetto scarichi ogni volta l'ultima versione. Il lockfile segnalerà ogni aggiornamento, ed è quello che vuoi, ma se preferisci aggiornare con calma conviene fissare la versione (qualche-pacchetto@1.4.2) e cambiarla con una PR.

Trattare un server irraggiungibile come un attacco. Il codice 2 fa fallire il job come il 1, ma vuol dire una cosa diversa: il server è giù, o il token nei secrets è scaduto (e in quel caso l'output lo dice). Quando un job fallisce, guarda l'output prima di allarmarti.

Alzare la soglia per far tacere un falso positivo. Un tool che deve nominare .env produrrà sempre un finding high. Oggi MCPSonar non ha un modo per ignorare un singolo finding, quindi l'alternativa è usare fail-on: medium o never per quel server e affidarsi al lockfile. Ma fallo dopo aver letto la descrizione, non per sbloccare la build.


Conclusione

Con un comando e una decina di righe di YAML, i server MCP da cui dipendi diventano dipendenze bloccate: ogni cambiamento nelle definizioni dei tool fa fallire la CI, finisce in un diff leggibile e passa da una code review prima di arrivare ai tuoi agenti.

Non sostituisce il monitoring continuo, che resta il lavoro della dashboard di MCPSonar (ne parlo nel tutorial con Docker), ma chiude il buco più grande: quello tra il momento in cui hai approvato un server e quello in cui il tuo agente lo usa.

Riassumendo:

  1. mcpsonar scan <server> --lock mcp-tools.lock.json in locale, poi commit del lockfile.
  2. Workflow con la Action fissata a una release (meglio ancora allo SHA), su pull_request e su schedule.
  3. Ogni cambiamento legittimo: --update-lock, lettura del diff, PR.

MCPSonar è open source: trovi la Action e il codice su GitHub. Se lo usi in un tuo progetto, mi farebbe piacere saperlo: apri una issue o scrivimi.