Salta al contenuto principale

06 - Model Context Protocol (MCP): standard di integrazione

Un server MCP in miniatura secondo la revisione 2026-07-28: richieste senza handshake, errori di protocollo e di esecuzione, e un attacco al percorso dei file.

Cosa ne pensi di questo articolo?

06 - Model Context Protocol (MCP): standard di integrazione
Intelligenza Artificiale Modernafedericocalo.dev

In Breve

Un server MCP in miniatura secondo la revisione 2026-07-28: richieste senza handshake, errori di protocollo e di esecuzione, e un attacco al percorso dei file.

Se vuoi che un'applicazione con un modello dentro usi un tuo strumento (leggere una cartella, interrogare un gestionale, fare un calcolo), puoi scrivere la colla a mano, e la riscriverai per ogni applicazione e per ogni strumento. Il Model Context Protocol (MCP) è il tentativo di togliere quella moltiplicazione: un protocollo aperto, basato su messaggi JSON-RPC 2.0, con cui un'applicazione che ospita il modello parla con servizi che offrono strumenti, dati e modelli di prompt. Imparato una volta, lo stesso server serve applicazioni diverse.

Un avvertimento che cambia ciò che leggerai in giro. La revisione corrente della specifica, quella datata 2026-07-28 che ho letto il 2026-11-13, ha tolto l'handshake initialize e le sessioni a livello di protocollo: ogni richiesta porta con sé versione e capacità. Molti tutorial descrivono ancora il vecchio avvio, e per questo l'articolo costruisce un server in miniatura secondo la revisione attuale, lo esegue e ne misura il comportamento. Prima di questo hai letto il ciclo dell'agente, che è il lato che consuma gli strumenti; qui guardiamo il lato che li espone. Basta Python di base e nessun modello viene chiamato.

Che cosa standardizza MCP, e che cosa no

La specifica dichiara che il protocollo mette in comunicazione tre attori e che prende ispirazione dal Language Server Protocol, quello che permette a un editor di supportare molti linguaggi senza riscrivere tutto per ciascuno.

  • L'host è l'applicazione con il modello, che avvia le connessioni.
  • Il client è il connettore dentro l'host, che parla con un server.
  • Il server è il servizio che offre contesto e capacità, in forma di strumenti (funzioni che il modello può far eseguire), risorse (dati) e prompt (messaggi predefiniti per l'utente).

La distinzione che conta nella pratica è fra host e client: l'host è l'applicazione, i client sono i suoi connettori verso i server. Per questo il consenso dell'utente è un dovere dell'host, non del server: la specifica lo scrive fra i principi di sicurezza, e lo ritrovi nella sezione dedicata. Il protocollo non stabilisce come il modello esprima la richiesta di usare uno strumento (testo, JSON, un formato proprietario): descrive i messaggi fra client e server, e ciò che accade prima, dentro l'agente, resta affare dell'host.

Un server MCP in miniatura

I messaggi sono richieste, risposte e notifiche JSON-RPC 2.0, con due precisazioni della specifica: l'id di una richiesta non può essere null (a differenza di JSON-RPC puro) né coincidere con quello di un'altra richiesta ancora senza risposta, e ogni risultato porta un campo resultType (complete per un risultato ordinario). Sul canale stdio, in cui il client avvia il server come sottoprocesso, vale un messaggio per riga, senza a-capo dentro il messaggio, e su stdout non può comparire altro che messaggi validi: i log vanno su stderr.

Il listato mette tutto in un file. Il «trasporto» è simulato (una riga entra, una riga esce) e il server vive nello stesso processo: non ho avviato sottoprocessi né aperto porte, quindi ciò che provo è la logica del protocollo, non uno scambio reale su stdio o HTTP. La cartella con i file di prova la crea il programma, e quel «segreto» è un file mio.

"""Un server MCP in miniatura, in-process, per la revisione 2026-07-28.
Il "trasporto" e' simulato: una riga JSON in ingresso, una riga in uscita
(le regole di stdio: un messaggio per riga, nessun a-capo dentro il messaggio)."""
import json
import os
import pathlib
import tempfile

VERSIONE = "2026-07-28"
SUPPORTATE = [VERSIONE]
TIPI = {"string": str, "integer": int}

STRUMENTI = [  # ordine fisso: la lista deve essere deterministica
    {"name": "leggi_file", "description": "Legge un file di testo dalla cartella pubblica",
     "inputSchema": {"type": "object", "required": ["percorso"],
                     "properties": {"percorso": {"type": "string"}}}},
    {"name": "somma", "description": "Somma due interi",
     "inputSchema": {"type": "object", "required": ["a", "b"],
                     "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}}},
]


def errore(id_, codice, messaggio, dati=None):
    e = {"code": codice, "message": messaggio}
    if dati is not None:
        e["data"] = dati
    return {"jsonrpc": "2.0", "id": id_, "error": e}


def convalida(schema, args):
    """Controllo minimo di required e type; un validatore vero legge tutto lo schema."""
    for campo in schema.get("required", []):
        if campo not in args:
            return f"argomento mancante: {campo}"
    for campo, valore in args.items():
        atteso = schema["properties"].get(campo, {}).get("type")
        if atteso and (not isinstance(valore, TIPI[atteso]) or isinstance(valore, bool)):
            return f"{campo} deve essere {atteso}"
    return None


class Server:
    def __init__(self, cartella_pubblica, sicuro):
        self.radice = pathlib.Path(cartella_pubblica)
        self.sicuro = sicuro

    def leggi_file(self, percorso):
        destinazione = self.radice / percorso
        if self.sicuro:
            destinazione = destinazione.resolve()      # segue anche i link simbolici
            if not destinazione.is_relative_to(self.radice.resolve()):
                raise PermissionError("percorso fuori dalla cartella pubblica")
        return destinazione.read_text()

    def chiama(self, nome, args):
        if nome == "somma":
            return str(args["a"] + args["b"])
        return self.leggi_file(args["percorso"])

    def risposta(self, msg):
        id_, metodo = msg["id"], msg["method"]
        meta = (msg.get("params") or {}).get("_meta") or {}
        if metodo == "initialize":  # handshake delle revisioni precedenti
            return errore(id_, -32601, "initialize non esiste piu'", {"supported": SUPPORTATE})
        for chiave in ("io.modelcontextprotocol/protocolVersion",
                       "io.modelcontextprotocol/clientCapabilities"):
            if chiave not in meta:
                return errore(id_, -32602, f"_meta incompleto: manca {chiave}")
        richiesta = meta["io.modelcontextprotocol/protocolVersion"]
        if richiesta not in SUPPORTATE:
            return errore(id_, -32022, "Unsupported protocol version",
                          {"supported": SUPPORTATE, "requested": richiesta})
        if metodo == "server/discover":
            return {"jsonrpc": "2.0", "id": id_, "result": {
                "resultType": "complete", "supportedVersions": SUPPORTATE,
                "capabilities": {"tools": {}}, "ttlMs": 3600000, "cacheScope": "public",
                "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "mini", "version": "0.1"}}}}
        if metodo == "tools/list":
            return {"jsonrpc": "2.0", "id": id_, "result": {
                "resultType": "complete", "tools": STRUMENTI, "ttlMs": 300000, "cacheScope": "public"}}
        if metodo == "tools/call":
            p = msg["params"]
            tool = next((t for t in STRUMENTI if t["name"] == p.get("name")), None)
            if tool is None:
                return errore(id_, -32602, f"Unknown tool: {p.get('name')}")
            problema = convalida(tool["inputSchema"], p.get("arguments", {}))
            try:
                if problema:
                    raise ValueError(problema)
                testo, ko = self.chiama(tool["name"], p["arguments"]), False
            except (ValueError, OSError) as e:
                testo, ko = f"{type(e).__name__}: {e}", True
            return {"jsonrpc": "2.0", "id": id_, "result": {
                "resultType": "complete", "isError": ko,
                "content": [{"type": "text", "text": testo}]}}
        return errore(id_, -32601, "Method not found")

    def handle(self, riga):
        try:
            msg = json.loads(riga)
        except json.JSONDecodeError:
            return json.dumps(errore(None, -32700, "Parse error"))
        if "id" not in msg:
            return None  # notifica: nessuna risposta
        r = self.risposta(msg)
        assert ("result" in r) != ("error" in r) and r["id"] == msg["id"]
        if "result" in r:
            assert r["result"]["resultType"] == "complete"
        riga_out = json.dumps(r, ensure_ascii=False)
        assert "\n" not in riga_out
        return riga_out


def chiedi(server, id_, metodo, params=None, versione=VERSIONE, con_meta=True):
    p = dict(params or {})
    if con_meta:
        p["_meta"] = {"io.modelcontextprotocol/protocolVersion": versione,
                      "io.modelcontextprotocol/clientCapabilities": {}}
    riga = json.dumps({"jsonrpc": "2.0", "id": id_, "method": metodo, "params": p})
    out = server.handle(riga)
    return json.loads(out) if out else None


def esito(r):
    if "error" in r:
        return f"errore {r['error']['code']}: {r['error']['message']}"
    if r["result"].get("isError"):
        return "isError: " + r["result"]["content"][0]["text"]
    return "ok"


radice = pathlib.Path(tempfile.mkdtemp())          # bersaglio di prova, creato qui
(radice / "pub").mkdir()
(radice / "pub" / "nota.txt").write_text("nota pubblica")
(radice / "segreto.txt").write_text("SEGRETO-DI-PROVA")
os.symlink(radice / "segreto.txt", radice / "pub" / "scorciatoia.txt")

srv = Server(radice / "pub", sicuro=True)
d = chiedi(srv, 1, "server/discover")["result"]
print("discover:", d["supportedVersions"], d["capabilities"], d["ttlMs"])
nomi = [t["name"] for t in chiedi(srv, 2, "tools/list")["result"]["tools"]]
print("tools/list:", nomi, "| stessa lista alla seconda richiesta:",
      nomi == [t["name"] for t in chiedi(srv, 3, "tools/list")["result"]["tools"]])
casi = [
    ("somma 2+3", "tools/call", {"name": "somma", "arguments": {"a": 2, "b": 3}}, {}),
    ("somma con testo", "tools/call", {"name": "somma", "arguments": {"a": 2, "b": "3"}}, {}),
    ("strumento ignoto", "tools/call", {"name": "cancella", "arguments": {}}, {}),
    ("senza _meta", "tools/list", None, {"con_meta": False}),
    ("versione 1900-01-01", "tools/list", None, {"versione": "1900-01-01"}),
    ("initialize", "initialize", {}, {"con_meta": False}),
]
for i, (nome, metodo, params, extra) in enumerate(casi, start=10):
    r = chiedi(srv, i, metodo, params, **extra)
    dettaglio = ""
    if "result" in r and not r["result"].get("isError"):
        dettaglio = " " + r["result"]["content"][0]["text"]
    print(f"{nome:20s} -> {esito(r)}{dettaglio}")
print("notifica senza id -> risposta:", srv.handle('{"jsonrpc":"2.0","method":"notifications/cancelled"}'))

print("--- cartella pubblica: ingenuo contro sicuro")
for nome, sicuro in [("ingenuo", False), ("sicuro", True)]:
    s = Server(radice / "pub", sicuro)
    for etichetta, p in [("nota.txt", "nota.txt"), ("../segreto.txt", "../segreto.txt"),
                         ("scorciatoia.txt", "scorciatoia.txt"),
                         ("(percorso assoluto)", str(radice / "segreto.txt"))]:
        r = chiedi(s, 20, "tools/call", {"name": "leggi_file", "arguments": {"percorso": p}})
        print(f"{nome:8s} {etichetta:20s} {r['result']['content'][0]['text']}")

# secondo calcolo, indipendente: commonpath sul percorso normalizzato con realpath
for p in ["nota.txt", "../segreto.txt", "scorciatoia.txt", "./nota.txt", "a/../nota.txt"]:
    reale = os.path.realpath(radice / "pub" / p)
    base = os.path.realpath(radice / "pub")
    dentro = os.path.commonpath([reale, base]) == base
    s = Server(radice / "pub", True)
    r = chiedi(s, 30, "tools/call", {"name": "leggi_file", "arguments": {"percorso": p}})
    negato = r["result"]["isError"] and "PermissionError" in r["result"]["content"][0]["text"]
    assert (not negato) == dentro, p
print("secondo controllo (commonpath + realpath) concorde su 5 percorsi")

# Output:
# discover: ['2026-07-28'] {'tools': {}} 3600000
# tools/list: ['leggi_file', 'somma'] | stessa lista alla seconda richiesta: True
# somma 2+3            -> ok 5
# somma con testo      -> isError: ValueError: b deve essere integer
# strumento ignoto     -> errore -32602: Unknown tool: cancella
# senza _meta          -> errore -32602: _meta incompleto: manca io.modelcontextprotocol/protocolVersion
# versione 1900-01-01  -> errore -32022: Unsupported protocol version
# initialize           -> errore -32601: initialize non esiste piu'
# notifica senza id -> risposta: None
# --- cartella pubblica: ingenuo contro sicuro
# ingenuo  nota.txt             nota pubblica
# ingenuo  ../segreto.txt       SEGRETO-DI-PROVA
# ingenuo  scorciatoia.txt      SEGRETO-DI-PROVA
# ingenuo  (percorso assoluto)  SEGRETO-DI-PROVA
# sicuro   nota.txt             nota pubblica
# sicuro   ../segreto.txt       PermissionError: percorso fuori dalla cartella pubblica
# sicuro   scorciatoia.txt      PermissionError: percorso fuori dalla cartella pubblica
# sicuro   (percorso assoluto)  PermissionError: percorso fuori dalla cartella pubblica
# secondo controllo (commonpath + realpath) concorde su 5 percorsi

Il cuore è il metodo risposta. Controlla prima _meta (versione e capacità), poi smista su server/discover, tools/list e tools/call. In tools/call accadono due cose distinte: se lo strumento non esiste la risposta è un errore JSON-RPC, se l'argomento non rispetta lo schema (convalida) o lo strumento fallisce, la risposta è un risultato normale con isError a vero. L'asserzione in handle verifica per ogni risposta gli invarianti di base: esattamente uno fra result ed error, stesso id della richiesta, resultType presente, nessun a-capo. Senza STRUMENTI in ordine fisso, la lista cambierebbe da una chiamata all'altra, e la specifica raccomanda un ordine deterministico per aiutare la cache del client e quella del prompt.

L'uscita comincia con la scoperta, che restituisce la versione supportata e le capacità, e con la lista degli strumenti, identica alla seconda richiesta; poi la somma di 2 e 3 dà 5. Seguono cinque vie d'errore: argomento di tipo sbagliato (risultato con isError), strumento sconosciuto (-32602), _meta mancante (-32602), versione sconosciuta (-32022) e il vecchio initialize (-32601). La notifica senza id non riceve risposta, come impone il protocollo. La sezione dopo spiega perché la mappa degli errori è fatta così.

Senza handshake: ogni richiesta porta il suo contesto

Nelle revisioni precedenti il client apriva con initialize, il server rispondeva con le sue capacità e il client confermava con una notifica. La revisione 2026-07-28 elimina quel passaggio, e il suo elenco delle modifiche principali mostra quanto a catena ne discenda.

  • Niente initialize né sessioni di protocollo: l'header Mcp-Session-Id del trasporto HTTP sparisce.
  • Ogni richiesta dichiara versione e capacità del client in _meta, e il server non può dedurle da richieste precedenti.
  • Il server deve implementare server/discover per annunciare versioni, capacità e identità; per il client è facoltativo.
  • Una versione non supportata produce un errore -32022 che elenca le versioni accettate.
  • Le richieste che il server faceva al client (per esempio chiedere un'informazione all'utente) diventano risposte input_required da riprovare.

Sono cinque conseguenze di un'unica scelta: un server che non ricorda nulla del client si può mettere dietro un bilanciatore, riavviare o replicare senza che nessuna richiesta dipenda da quale istanza l'ha vista prima. Nel mio server lo vedi nel fatto che risposta non legge alcuno stato dell'oggetto per decidere versione o capacità. Per i client che parlano ancora il protocollo vecchio la specifica prevede una sonda: un client che supporta entrambe le epoche manda prima server/discover e, se la risposta non è moderna, ripiega su initialize. Un server solo moderno dovrebbe, se riceve initialize, nominare nell'errore le versioni che supporta; il codice -32601 che uso io per quel caso è una mia scelta, la specifica non lo prescrive.

Errori di protocollo ed errori dello strumento

La specifica distingue due canali. Gli errori di protocollo riguardano la struttura della richiesta (strumento sconosciuto, richiesta malformata, guasto del server) e viaggiano come errori JSON-RPC standard, perché è improbabile che il modello sappia correggerli. Gli errori di esecuzione (un'API che fallisce, una data nel formato sbagliato, una regola di dominio) viaggiano nel risultato con isError: true, perché contengono un'indicazione che il modello può usare per riprovare con argomenti diversi. Nel listato, il "3" passato come testo a somma finisce nel secondo canale e cancella, che non esiste, nel primo.

Collegato al ciclo del precedente articolo: un client che passa al modello il testo dell'errore di esecuzione ottiene lo stesso effetto dell'osservazione ERRORE: ... del nostro esecutore ReAct, ma con un campo standard al posto di una convenzione di testo. La specifica dice che i client dovrebbero fornire al modello gli errori di esecuzione e che possono fornire anche quelli di protocollo, con minori speranze di recupero.

Sicurezza: la cartella pubblica che pubblica troppo

Gli strumenti sono codice che qualcuno esegue su richiesta di un modello, e la specifica lo scrive senza giri di parole: rappresentano esecuzione di codice arbitrario e vanno trattati con cautela. Ai server impone di validare gli input, di applicare controlli di accesso, di limitare la frequenza delle chiamate e di ripulire gli output. Agli host chiede il consenso esplicito dell'utente prima di invocare uno strumento, e raccomanda un essere umano nel ciclo con la possibilità di negare. Anche le annotazioni di uno strumento vanno considerate non fidate, a meno che il server lo sia.

Il listato mostra la violazione più classica dell'accesso, la path traversal, contro un bersaglio mio: una cartella temporanea creata dal programma con una sottocartella pubblica, un file «segreto» fuori da essa e un collegamento simbolico dentro la pubblica che punta al segreto. Lo strumento leggi_file deve servire solo la sottocartella. Il server ingenuo concatena la radice con il percorso e legge; il server sicuro chiama resolve(), che normalizza i .. e segue i collegamenti, e solo dopo verifica con is_relative_to che il risultato stia nella radice. Nell'uscita l'ingenuo cede il segreto con tre vie diverse, ../segreto.txt, il collegamento e il percorso assoluto (con pathlib un percorso assoluto sostituisce la base), il sicuro nega tutte e tre e serve ancora il file lecito.

Come secondo calcolo indipendente, in coda allo script confronto il verdetto del server con quello di un altro metodo (realpath più commonpath) su cinque percorsi, tra cui ./nota.txt e a/../nota.txt, che sono leciti: i due concordano. La contromisura va inoltre messa nello strumento, non nel prompt, perché è il modello a scegliere l'argomento e un modello può essere indotto a scegliere qualunque argomento. Il mio server resta incompleto rispetto alla specifica: non limita la frequenza delle chiamate, non ha autenticazione e non ripulisce gli output.

Che cosa non ho verificato

Ho letto, il 2026-11-13, la panoramica della revisione, la pagina del protocollo di base, quella sul versionamento, la scoperta, gli strumenti, il canale stdio e l'elenco delle modifiche. Non ho letto il trasporto HTTP, l'autorizzazione, le risorse né i prompt, e non ho eseguito nessun SDK né un vero scambio fra processi: le affermazioni su quei punti non compaiono qui, salvo la loro esistenza nella panoramica. La specifica elenca fra le funzioni deprecate in questa revisione l'elenco delle radici, il campionamento e il logging, e segnala una finestra minima di dodici mesi prima della rimozione; per un server nuovo conviene non basarsi su di essi. Una revisione più recente può aver cambiato i dettagli: la pagina della specifica è la fonte, questo articolo non la sostituisce.

Domande frequenti

MCP sostituisce la chiamata di funzione dei modelli?

No, stanno su livelli diversi. MCP descrive il dialogo fra il client dell'applicazione e il server degli strumenti; come il modello esprima la richiesta di usarne uno resta fuori dalla specifica. Un host può ricevere una chiamata strutturata dal modello e inoltrarla al server con tools/call.

Devo ancora supportare initialize per i client più vecchi?

Dipende da chi usa il tuo server. La specifica descrive i server che parlano entrambe le epoche e i client che sondano con server/discover per ripiegare sull'handshake vecchio, e indica che un client moderno contro un server solo vecchio fallisce. Se il tuo server è solo moderno, la specifica raccomanda di nominare le versioni che supporta in ogni errore restituito a initialize.

Perché un argomento sbagliato non è un errore JSON-RPC?

Perché la specifica riserva gli errori JSON-RPC ai problemi di struttura della richiesta, che il modello difficilmente sa correggere, e mette gli errori di validazione degli input nel risultato con isError, dove il modello può leggerli e riprovare con valori corretti.

Che cosa sai fare adesso

Sai dire come è fatto un messaggio MCP della revisione attuale e perché ogni richiesta porta versione e capacità, sai distinguere un errore di protocollo da uno di esecuzione e sai scrivere uno strumento che non cede file fuori dalla propria cartella, con una prova che lo dimostra su un bersaglio tuo. Gli strumenti che esponi possono servire dati anche per un sistema di recupero come quello descritto nell'articolo su RAG, embedding e vector store. Per capire come i modelli recenti dedicano più calcolo al ragionamento, vedi l'articolo successivo; per far girare un modello sulla tua macchina e usarlo come motore dietro un host, il tema dell'ultimo articolo della serie.

Fonti

Il listato è stato eseguito con Python 3.14.4 (python3 -I), due volte, con uscita identica. Il trasporto è simulato nello stesso processo e il bersaglio della prova è una cartella temporanea creata dal programma. Le pagine della specifica sono state scaricate e lette nel testo; i dettagli del trasporto HTTP, dell'autorizzazione, delle risorse e dei prompt non sono stati letti.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti