Il Model Context Protocol (MCP) è un protocollo che standardizza il modo in cui un'applicazione basata su un modello linguistico scopre e usa capacità esterne. In concreto: invece di scrivere un'integrazione su misura fra il tuo agente e il tuo gestionale, ne scrivi una sola fra il tuo gestionale e il protocollo, e da quel momento qualunque applicazione che parla MCP può usarlo. È la stessa idea di un connettore fisico standard: un protocollo al posto di N integrazioni. La specifica è pubblica, i messaggi sono JSON-RPC 2.0, e le primitive del server sono tre.
Versioni provate. Python 3.14.4,
mcp2.3.0 eopenai-agents0.24.0, il 2026-11-12, con il modello fintoModelloFinto, il cui codice è nell'articolo sugli handoff (qui lo importo da un modulofaketuo). Le revisioni della specifica sono datate: la più recente che ho letto è la 2026-07-28, che segue la 2025-11-25. Gli scambi riportati sotto sono della revisione 2025-06-18, che l'SDK installato parla ancora di default; che cosa cambia nella più recente lo dico nella sezione sullo scambio. Nella serie 1.x dell'SDK Python l'oggetto server si chiamaFastMCP(mcp.server.fastmcp); nella 2.x si chiamaMCPServer(mcp.server.mcpserver), e il vecchio import non esiste più.
Il problema: N client per M servizi
Prima dell'analogia, il conto che giustifica l'esistenza del protocollo. Hai cinque applicazioni che usano modelli (un assistente interno, un agente che fa triage dei ticket, uno strumento di revisione del codice, una chat di supporto, uno script di reportistica) e otto servizi a cui vorresti che accedessero: il gestionale, il repository, il tracker dei ticket, il database, il wiki, il calendario, il sistema di posta, il monitoraggio.
Senza un protocollo ogni coppia è un'integrazione: fino a quaranta pezzi di codice da scrivere e mantenere, ciascuno con la propria autenticazione, il proprio formato di errore, la propria logica di paginazione, il proprio modo di descrivere le capacità al modello. E quando il tracker dei ticket cambia API, cinque integrazioni si rompono. Con un protocollo scrivi otto server (uno per servizio) e cinque client (uno per applicazione, e in pratica lo fornisce la libreria): tredici pezzi invece di quaranta, e il cambio di API del tracker tocca un solo server. È il passaggio da N×M a N+M, e la differenza fra un'integrazione che si mantiene e una che si abbandona.
L'analogia: il connettore standard
Prima dei connettori standardizzati ogni dispositivo aveva il suo cavo e il suo alimentatore: un cassetto pieno di adattatori, ognuno compatibile con una cosa sola. Il connettore standard non ha reso i dispositivi più potenti, ha reso intercambiabili i cavi. MCP fa questo per le capacità di un modello: non rende l'agente più intelligente, rende il tuo gestionale collegabile a qualunque applicazione che parli il protocollo, senza che tu sappia quali applicazioni esistono. Ed è anche il limite dell'analogia: un connettore standard non ti protegge da un alimentatore difettoso. Ne parliamo nella sezione sulla supply chain, perché è il rischio principale dell'adozione.
L'architettura: host, client, server
I tre ruoli vanno tenuti distinti perché è dove si collocano le responsabilità. L'host è la tua applicazione: contiene il modello, il ciclo dell'agente e la decisione su a quali server connettersi. È il punto di controllo, l'unico posto dove si decide che cosa un modello può raggiungere. Il client è il componente che gestisce una connessione con un server; nella pratica non lo scrivi, te lo dà la libreria. Il server incapsula un sistema reale e ne espone le capacità in forma standard; gira come processo locale o come servizio remoto, e non vede né gli altri server né il contesto completo della conversazione.
La separazione ha una conseguenza di sicurezza: il modello non parla con i server. Parla con il ciclo dell'host, che chiama i client. Ogni anello della catena è un posto in cui puoi filtrare quali tool esporre, verificare un'autorizzazione e registrare che cosa è passato, ed è lì che va messo il filtro (lo faccio nella sezione sull'integrazione).
Le tre primitive del server
Un server MCP può esporre tre tipi di cosa, e la distinzione cambia chi decide di usarle.
Tool. Funzioni che il modello decide di chiamare. Hanno un nome, una descrizione e uno schema JSON degli argomenti, la stessa struttura dell'articolo sui tool, vista attraverso un protocollo. Sono la primitiva «model-controlled».
Resource. Dati identificati da un URI, che l'applicazione decide di includere nel contesto: un file, una tabella, un documento del wiki. La differenza con un tool è la direzione dell'iniziativa: una risorsa non viene «chiamata» dal modello, viene allegata dall'host perché pertinente.
Prompt. Modelli di interazione parametrizzati, che l'utente tipicamente seleziona: «rivedi questa funzione», «scrivi il messaggio di commit per questo diff». Il server fornisce la struttura del prompt e l'host la presenta come un comando. È la primitiva meno usata e quella più utile per rendere ripetibile un buon prompt costruito da chi conosce il dominio.
Dalla parte del client la specifica prevede capacità simmetriche: il sampling (un server può chiedere all'host di eseguire una generazione del modello), i roots (l'host dichiara al server quali radici del filesystem sono in perimetro) e l'elicitation (un server può chiedere all'host di raccogliere un'informazione dall'utente). Sono capacità opzionali e dichiarate, quindi non dare per scontato che un server o un host le supportino.
Resource contro tool: la decisione che si sbaglia più spesso
Prendiamo un server che incapsula il wiki aziendale, con due modi di esporre lo stesso contenuto. Come tool: cerca_nel_wiki(domanda: str). Il modello decide se gli serve, formula la domanda, riceve i frammenti: costa un giro in più e porta nel contesto solo ciò che serve. Conviene quando non sai in anticipo se il wiki sarà rilevante, e quando è grande. Come risorsa: wiki://procedure/rimborsi. L'host, sapendo che la conversazione riguarda un rimborso, allega il documento prima del primo giro: costa zero giri e porta nel contesto tutto il documento. Conviene quando sai con certezza quale contesto serve e il documento è piccolo.
Il criterio è una domanda sola: chi sa, in quel momento, se questo contenuto è pertinente? Se lo sa l'applicazione (l'utente ha cliccato su un ticket di rimborso, la richiesta arriva da un canale dedicato, un classificatore a monte ha già deciso l'argomento), allega la risorsa: hai eliminato un giro e una decisione probabilistica. Se non lo sa nessuno in anticipo, esponi un tool. Un errore ricorrente è esporre come risorse cose grandi e allegarle sempre «per sicurezza»: il contesto gonfio si paga a ogni giro (il meccanismo della memoria conversazionale). L'errore simmetrico è esporre come tool qualcosa che l'applicazione conosce già: un tool leggi_utente_corrente() fa spendere un giro per un dato che l'host ha in mano, ed è un campo da mettere nel contesto della run.
Perché questo protocollo e non un'API REST
È una domanda legittima, perché un server MCP somiglia a un servizio web con pochi endpoint. Le differenze sono tre, tutte orientate a un modello che deve scoprire che cosa può fare.
La descrizione è parte del protocollo, non della documentazione. In un'API REST ciò che fa un endpoint sta nello OpenAPI, nel README o nella testa di chi l'ha scritto. In MCP tools/list restituisce nome, descrizione e schema in forma leggibile da un modello, al momento della connessione: un'interfaccia pensata per un consumatore che la scopre a runtime.
Il server può prendere l'iniziativa. Può notificare che il catalogo è cambiato, chiedere all'host una generazione del modello (sampling), chiedere un'informazione all'utente (elicitation). Su HTTP puro sono cose che si fanno con fatica. Nella revisione 2025-06-18 la connessione ha anche un handshake che negozia versione e capacità; la revisione 2026-07-28 lo ha tolto, come vedremo.
L'esito di un tool ha un posto definito. In REST si usa un codice di stato, con le ambiguità note: un 404 è «risorsa inesistente» o «endpoint sbagliato»? In MCP un errore di esecuzione del tool sta nel risultato con isError: true, che il modello può leggere, mentre un errore JSON-RPC è un guasto di protocollo. La specifica dei tool elenca come errori di protocollo i tool sconosciuti, gli argomenti non validi e gli errori del server, e come errori di esecuzione i guasti di API, i dati non validi e gli errori di logica di business. Per molti casi un'API REST esistente va benissimo, e la scelta sensata è un server MCP sottile che la incapsula, aggiungendo le descrizioni leggibili dal modello e la riduzione dei payload.
Lo scambio, riga per riga
Tutto il protocollo è JSON-RPC 2.0: messaggi con jsonrpc, id, method, params. Per vedere una sessione vera ho lanciato il server del paragrafo successivo come processo figlio (trasporto stdio, nessuna porta) e gli ho parlato a mano, una riga JSON per messaggio, senza nessuna libreria client. Il file del server è riportato più sotto; qui conta il traffico, in particolare l'ultima parte, dove ho provato i diversi modi in cui una chiamata può andare male. Le risposte lunghe sono accorciate nelle descrizioni.
import json
import subprocess
import sys
# Lancia il server come processo figlio e gli parla in JSON-RPC, una riga per messaggio.
server = subprocess.Popen([sys.executable, "server_magazzino.py"], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def invia(messaggio: dict, attendi_risposta: bool = True) -> None:
print("-->", json.dumps(messaggio, ensure_ascii=False))
server.stdin.write(json.dumps(messaggio) + "\n")
server.stdin.flush()
if attendi_risposta:
risposta = json.loads(server.stdout.readline())
for t in risposta.get("result", {}).get("tools", []): # accorcia le descrizioni
t["description"] = t["description"].split("\n")[0]
t["inputSchema"] = {"required": t["inputSchema"]["required"]}
print("<--", json.dumps(risposta, ensure_ascii=False))
invia({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "agente-magazzino", "version": "1.0.0"}}})
invia({"jsonrpc": "2.0", "method": "notifications/initialized"}, attendi_risposta=False)
invia({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
invia({"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": "leggi_giacenza", "arguments": {"sku": "SKU-11"}}})
invia({"jsonrpc": "2.0", "id": 4, "method": "tools/call", # errore di dominio
"params": {"name": "leggi_giacenza", "arguments": {"sku": "SKU-999"}}})
invia({"jsonrpc": "2.0", "id": 5, "method": "tools/call", # eccezione qualunque
"params": {"name": "leggi_giacenza_fragile", "arguments": {"sku": "SKU-999"}}})
invia({"jsonrpc": "2.0", "id": 6, "method": "tools/call", # tool che non esiste
"params": {"name": "cancella_tutto", "arguments": {}}})
invia({"jsonrpc": "2.0", "id": 7, "method": "metodo/inesistente"})
server.stdin.close()
server.wait(timeout=10)
righe = server.stderr.read().strip().splitlines()
print("stderr del server, prime righe:", righe[:2], "... +", len(righe) - 2, "righe di traceback")
# Output:
# --> {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "agente-magazzino", "version": "1.0.0"}}}
# <-- {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {"prompts": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}, "tools": {"listChanged": false}}, "protocolVersion": "2025-06-18", "serverInfo": {"name": "server-magazzino", "version": ""}}}
# --> {"jsonrpc": "2.0", "method": "notifications/initialized"}
# --> {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
# <-- {"jsonrpc": "2.0", "id": 2, "result": {"tools": [{"description": "Giacenza attuale e soglia di riordino di un codice prodotto (SKU).", "inputSchema": {"required": ["sku"]}, "name": "leggi_giacenza"}, {"description": "Come leggi_giacenza, ma solleva un'eccezione qualunque (è qui per mostrare la differenza).", "inputSchema": {"required": ["sku"]}, "name": "leggi_giacenza_fragile"}]}}
# --> {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "leggi_giacenza", "arguments": {"sku": "SKU-11"}}}
# <-- {"jsonrpc": "2.0", "id": 3, "result": {"content": [{"text": "{\n \"sku\": \"SKU-11\",\n \"giacenza\": 4,\n \"soglia\": 10\n}", "type": "text"}], "isError": false}}
# --> {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "leggi_giacenza", "arguments": {"sku": "SKU-999"}}}
# <-- {"jsonrpc": "2.0", "id": 4, "result": {"content": [{"text": "Error executing tool leggi_giacenza: sku_inesistente: SKU-999. SKU noti: ['SKU-11', 'SKU-42']", "type": "text"}], "isError": true}}
# --> {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {"name": "leggi_giacenza_fragile", "arguments": {"sku": "SKU-999"}}}
# <-- {"jsonrpc": "2.0", "id": 5, "result": {"content": [{"text": "Error executing tool leggi_giacenza_fragile", "type": "text"}], "isError": true}}
# --> {"jsonrpc": "2.0", "id": 6, "method": "tools/call", "params": {"name": "cancella_tutto", "arguments": {}}}
# <-- {"jsonrpc": "2.0", "id": 6, "result": {"content": [{"text": "Unknown tool: cancella_tutto", "type": "text"}], "isError": true}}
# --> {"jsonrpc": "2.0", "id": 7, "method": "metodo/inesistente"}
# <-- {"jsonrpc": "2.0", "id": 7, "error": {"code": -32601, "message": "Method not found", "data": "metodo/inesistente"}}
# stderr del server, prime righe: ['avvio server magazzino', 'Tool \'leggi_giacenza\' failed: "Error executing tool leggi_giacenza: sku_inesistente: SKU-999. SKU noti: [\'SKU-11\', \'SKU-42\']"'] ... + 40 righe di traceback
Cinque cose da leggere. Il passo 1 è una proposta del client e il passo 2 la risposta del server, che ripete la versione 2025-06-18 perché l'ha accettata e dichiara le capacità: tools, resources e prompts, nessuna con listChanged o sottoscrizioni attive. La riga senza id (notifications/initialized) è una notifica: non si attende risposta. tools/list è il momento della scoperta, e il client non conosce i tool in anticipo: li chiede.
La parte che ho trovato più istruttiva è la gestione degli errori, perché l'SDK non fa tutto ciò che farebbe pensare la specifica. Una chiamata riuscita ha isError: false. Un errore di dominio sollevato con ToolError dà isError: true con il mio messaggio («sku_inesistente… SKU noti: …»), che il modello può leggere e da cui può correggersi. Un'eccezione qualunque (ValueError nel secondo tool) dà isError: true ma con il solo testo «Error executing tool leggi_giacenza_fragile»: l'SDK 2.x tratta ciò che non è un ToolError come un crash, nasconde il dettaglio al client e scrive il traceback sullo standard error (40 righe, nell'ultima riga dell'uscita). Un tool sconosciuto dà un risultato con isError: true, mentre la specifica 2025-06-18 lo elenca come errore di protocollo (-32602): è una differenza fra la specifica e l'SDK, che oggi conviene conoscere. Solo un metodo inesistente produce un vero errore JSON-RPC (-32601).
La regola pratica per scrivere un server è quindi una sola: l'errore che deve tornare al modello si solleva con ToolError (o si restituisce un risultato con isError esplicito), e ogni altra eccezione è, per l'SDK, un guasto da non far leggere. Sbagliarlo non rompe il ciclo dell'agente: gli toglie l'informazione per correggersi.
Che cosa cambia nella revisione 2026-07-28
Il changelog della revisione più recente elenca fra le modifiche principali la rimozione dei sessioni a livello di protocollo e dell'handshake initialize/notifications/initialized: ogni richiesta porta la versione del protocollo e le capacità del client in _meta, e un nuovo metodo server/discover permette al client di conoscere versioni, capacità e identità del server. Le sottoscrizioni cambiano (subscriptions/listen al posto di resources/subscribe) e vengono tolti ping e logging/setLevel. Non ho potuto eseguire questo flusso: il server dell'SDK 2.3.0 in modalità predefinita risponde a server/discover con «Method not found» e rifiuta una richiesta con la nuova busta («this connection serves the handshake protocol era»). Per questo il transcript sopra è quello della 2025-06-18, e se scrivi un server oggi devi dichiarare nel README quale revisione supporta.
I due trasporti, e quando usarli
stdio. Il server è un processo figlio; il client gli scrive sullo standard input e legge dallo standard output. Nessuna rete, nessuna porta, nessuna autenticazione: il confine di sicurezza è il processo stesso, e chi lo lancia decide con quali permessi gira. È il trasporto giusto per tutto ciò che vive sulla stessa macchina. La specifica è netta: il server può scrivere stringhe sullo standard error per il logging, e non deve scrivere sullo standard output nulla che non sia un messaggio MCP valido. HTTP. Il server è un servizio raggiungibile in rete, e serve quando è condiviso fra più utenti o gira altrove; porta con sé autenticazione, autorizzazione, gestione delle sessioni, TLS. Le revisioni recenti definiscono un trasporto HTTP con streaming, che sostituisce quello basato su SSE della revisione 2024-11-05; è la parte che si è mossa di più, ed è quella su cui leggere la revisione esatta prima di implementare.
Il criterio di scelta è semplice: stdio se il server può girare accanto all'host, HTTP solo se deve essere condiviso. Un server HTTP è un servizio esposto, con tutto il lavoro di sicurezza che comporta; uno stdio è un processo figlio, e il suo perimetro è quello che gli dai quando lo lanci. Non ho provato nessun server HTTP per questo articolo (richiederebbe una porta in ascolto), quindi di quel trasporto riporto solo ciò che dice la specifica.
Un server minimo
Vediamo il server scritto. Uso l'SDK Python ufficiale, che offre un'astrazione a decoratori molto simile a quella dei tool: chi ha scritto un tool sa già scrivere un server. Il file ha un tool, una risorsa e un prompt, più un secondo tool, leggi_giacenza_fragile, che esiste soltanto per mostrare nel transcript la differenza fra ToolError ed eccezione qualunque.
# pip install "mcp>=2" (nella serie 1.x lo stesso oggetto si chiama FastMCP)
import sys
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("server-magazzino")
MAGAZZINO = {
"SKU-11": {"giacenza": 4, "soglia": 10, "in_arrivo": 20},
"SKU-42": {"giacenza": 120, "soglia": 30, "in_arrivo": 0},
}
@mcp.tool()
def leggi_giacenza(sku: str) -> dict:
"""Giacenza attuale e soglia di riordino di un codice prodotto (SKU).
Usa questo tool ogni volta che devi conoscere una quantità in magazzino.
Non stimare mai la giacenza a memoria.
"""
riga = MAGAZZINO.get(sku)
if riga is None:
# errore di DOMINIO: ToolError diventa un risultato con isError=true e il TUO messaggio,
# che il modello legge e da cui può correggersi
raise ToolError(f"sku_inesistente: {sku}. SKU noti: {sorted(MAGAZZINO)}")
return {"sku": sku, "giacenza": riga["giacenza"], "soglia": riga["soglia"]}
@mcp.tool()
def leggi_giacenza_fragile(sku: str) -> dict:
"""Come leggi_giacenza, ma solleva un'eccezione qualunque (è qui per mostrare la differenza)."""
if sku not in MAGAZZINO:
raise ValueError(f"sku_inesistente: {sku}")
return {"sku": sku, "giacenza": MAGAZZINO[sku]["giacenza"]}
@mcp.resource("magazzino://catalogo")
def catalogo() -> str:
"""L'elenco completo degli SKU gestiti. Allegabile al contesto dall'host."""
return "\n".join(f"{s}: soglia {d['soglia']}" for s, d in sorted(MAGAZZINO.items()))
@mcp.prompt()
def analizza_riordini(fornitore: str) -> str:
"""Prompt pronto per analizzare i riordini di un fornitore."""
return (f"Analizza gli SKU del fornitore {fornitore}. Per ciascuno leggi la "
f"giacenza con leggi_giacenza, tieni conto di ciò che è in arrivo, "
f"e produci un elenco di quelli da riordinare con la quantità.")
if __name__ == "__main__":
print("avvio server magazzino", file=sys.stderr) # log su STDERR, mai su stdout
mcp.run() # trasporto stdio per default
Tre scelte da leggere. La docstring del tool diventa la sua description nel protocollo (si vede in tools/list), quindi vale ciò che si è detto sui tool: dice quando usarlo, non come è implementato. L'errore di dominio con ToolError è la traduzione in MCP della regola «errore sui dati, non guasto»: l'esito sta nel risultato e porta il messaggio che il modello deve leggere. E il log su standard error è il dettaglio che la specifica impone. Per vedere che cosa succede se lo si viola ho fatto stampare una riga sullo standard output.
import asyncio
import sys
from pathlib import Path
from agents.mcp import MCPServerStdio
# Un server identico, con UNA print di debug sullo standard output.
Path("server_con_print.py").write_text(
Path("server_magazzino.py").read_text().replace(
'print("avvio server magazzino", file=sys.stderr)', 'print("avvio server magazzino")'))
async def main():
try:
async with MCPServerStdio(name="magazzino", client_session_timeout_seconds=10,
params={"command": sys.executable,
"args": ["server_con_print.py"]}) as server:
tools = await server.list_tools()
print("connessione riuscita, tool:", [t.name for t in tools])
except BaseException as errore: # l'eccezione esatta dipende dalla versione
print("tipo:", type(errore).__name__)
print("messaggio:", str(errore)[:200])
asyncio.run(main())
# Output:
# connessione riuscita, tool: ['leggi_giacenza', 'leggi_giacenza_fragile']
L'uscita dice che la connessione è riuscita: nella versione dell'SDK che ho provato il client scarta la riga non valida, segnalando sul proprio canale di errore un errore di validazione («Invalid JSON»), e la sessione prosegue. Non è una garanzia: la specifica dice che il server non deve scrivere altro sullo standard output, e un altro client potrebbe reagire in modo diverso, in particolare con output stampato nel mezzo di una risposta. Il fatto che il mio SDK perdoni non rende lecita la print.
Collegarlo a un agente
Il blocco lancia il server come processo figlio all'ingresso del blocco async with e lo termina all'uscita. Il modello è finto: chiama prima leggi_giacenza con uno SKU valido, poi con uno inesistente, poi conclude. Il blocco esegue la prova due volte, la prima con tutti i tool e senza cache, la seconda con una allowlist (tool_filter) e con cache_tools_list=True, e conta quante volte il client chiede davvero tools/list al server.
import asyncio
import sys
from agents import Agent, Runner
from agents.mcp import MCPServerStdio
from fake import ModelloFinto, chiama, testo
CONSENTITI = {"leggi_giacenza"} # allowlist, non denylist
def contatore_di_tools_list(server) -> list:
"""Conta quante volte il client chiede davvero `tools/list` al server."""
chiamate, originale = [], server.session.list_tools
async def conta(*args, **kwargs):
chiamate.append(1)
return await originale(*args, **kwargs)
server.session.list_tools = conta
return chiamate
async def prova(cache: bool, filtro) -> None:
async with MCPServerStdio(
name="magazzino",
params={"command": sys.executable, "args": ["server_magazzino.py"]},
cache_tools_list=cache, # evita un tools/list a ogni giro
tool_filter=filtro, # decide quali tool del server arrivano al modello
) as server:
richieste_di_lista = contatore_di_tools_list(server)
visto = []
agente = Agent(
name="Assistente magazzino",
instructions="Decidi se riordinare. Leggi sempre i dati con i tool.",
mcp_servers=[server], # i tool del server entrano nel catalogo
model=ModelloFinto([chiama("leggi_giacenza", sku="SKU-11"),
chiama("leggi_giacenza", sku="SKU-999"),
testo("RIORDINA 6")], visto),
)
esito = await Runner.run(agente, "Lo SKU-11 va riordinato?", max_turns=6)
print(f"cache={cache!s:<5} filtro={'sì' if filtro else 'no'} | "
f"tool visti dal modello: {visto[0]['tool']} | tools/list richiesti: {len(richieste_di_lista)}")
return visto, esito
async def main():
await prova(cache=False, filtro=None)
visto, esito = await prova(cache=True, filtro=lambda contesto, tool: tool.name in CONSENTITI)
letti = [m["output"] for m in visto[2]["input"] if m.get("type") == "function_call_output"]
print("il modello ha letto:", letti)
print("risposta:", esito.final_output)
asyncio.run(main())
# Output:
# cache=False filtro=no | tool visti dal modello: ['leggi_giacenza', 'leggi_giacenza_fragile'] | tools/list richiesti: 3
# cache=True filtro=sì | tool visti dal modello: ['leggi_giacenza'] | tools/list richiesti: 1
# il modello ha letto: [[{'type': 'input_text', 'text': '{\n "sku": "SKU-11",\n "giacenza": 4,\n "soglia": 10\n}'}], [{'type': 'input_text', 'text': "Error executing tool leggi_giacenza: sku_inesistente: SKU-999. SKU noti: ['SKU-11', 'SKU-42']"}]]
# risposta: RIORDINA 6
La prima riga mostra due fatti insieme. Senza filtro il modello vede entrambi i tool del server, compreso quello che avrei preferito non esporre; senza cache il client ha chiesto tools/list tre volte, una per ciascun giro dell'agente. La seconda riga li corregge: il filtro lascia passare solo leggi_giacenza e la cache riduce le richieste a una. Dal punto di vista del modello non c'è differenza fra un tool locale e un tool MCP: i tool scoperti entrano nel catalogo e, quando ne sceglie uno, il runner lo instrada al client come tools/call. La terza riga mostra ciò che il modello legge: il risultato in JSON per lo SKU valido e il messaggio d'errore con i codici noti per quello sbagliato.
Il filtro è obbligatorio, non opzionale: collegare un server e prendere tutti i suoi tool è la cosa che funziona nei tutorial e che non va fatta in produzione, perché ogni tool è una porta e un server di terze parti decide da sé quante porte aprirti. È un'allowlist, non una denylist, per il motivo di sempre: una denylist va aggiornata ogni volta che il server aggiunge un tool, e l'aggiornamento del server non lo controlli tu. La cache, invece, ha un prezzo: non ti accorgi se il server cambia il catalogo a sessione aperta, e per un server tuo che non lo fa è la scelta giusta.
Il rischio di supply chain
È il costo dell'adozione che le presentazioni del protocollo tendono a sorvolare. Collegare un server MCP di terze parti è eseguire codice di terze parti con i permessi del tuo processo, e dare a un modello la facoltà di invocarlo. Su trasporto stdio, letteralmente: stai lanciando un comando. I rischi concreti, in ordine di quanto sono sottovalutati, sono quattro.
Il server esegue codice sulla tua macchina. Un server stdio installato da un registro pubblico gira con i permessi dell'utente che lo lancia, accede al suo filesystem e alla sua rete: non è diverso da una dipendenza qualunque, con la differenza che una dipendenza la invochi tu dove decidi, mentre un server MCP viene invocato da un modello in risposta a testo che può arrivare da fuori. Le descrizioni dei tool sono prompt forniti da altri. La description entra nel contesto del tuo modello a ogni giro: un server ostile può scriverci istruzioni, ed è una injection consegnata dal canale che consideravi di configurazione (l'attacco dell'articolo precedente, con un vettore diverso).
Il server può cambiare dopo l'approvazione. Se il catalogo può cambiare, un server può esporre un tool innocuo durante la valutazione e uno pericoloso dopo: per questo la allowlist è sui nomi che hai approvato e la versione del server si fissa come quella di una libreria. La combinazione di più server. Due server innocui presi separatamente possono comporre la lethal trifecta (dati privati, contenuto non fidato, canale verso l'esterno): nessuno è il colpevole, la combinazione lo è. Da qui la pratica che consiglio: valutare i permessi dell'insieme dei server connessi a un agente, non quelli di ciascuno. Le contromisure sono quelle dell'articolo sulle trappole: allowlist dei tool, un agente che legge contenuto esterno senza tool che agiscono, il server lanciato con permessi minimi (utente dedicato, filesystem e rete limitati), e la lettura del codice del server prima di collegarlo.
Per l'esercizio e il testing dei server, la serie dedicata approfondisce in MCP: testing e best practice per la produzione; l'introduzione al protocollo e le primitive sono in introduzione al Model Context Protocol e le primitive MCP: tools, resources, prompts, e un server completo in TypeScript in creare il primo server MCP in TypeScript. Qui mi sono concentrato sullo scambio e sull'integrazione con un agente Python.
Trade-off
- Protocollo contro integrazione diretta. Il protocollo conviene quando hai più client o più servizi, o vuoi che il tuo servizio sia usabile da applicazioni che non conosci. Per un agente e un servizio scritti dalla stessa persona, un tool diretto è meno codice, meno processi e meno punti di guasto.
- Un anello in più. Fra la funzione e il modello ci sono ora un client, un trasporto e un server. Guadagni standardizzazione e sostituibilità; paghi un processo da avviare e supervisionare e un posto in più dove qualcosa può rompersi in silenzio.
- Scoperta dinamica contro catalogo noto. Scoprire i tool a runtime rende il prompt del modello non più costante fra le versioni, quindi i tuoi casi di valutazione misurano un sistema che può cambiare. La allowlist riporta determinismo, al prezzo di autorizzare a mano le novità.
- stdio contro HTTP. stdio è semplice e locale, e non si condivide. HTTP si condivide e richiede autenticazione, autorizzazione e TLS: tutto il lavoro di esporre un servizio.
Le quattro coppie dicono la stessa cosa da angoli diversi: ogni anello di astrazione compra sostituibilità e la paga in controllo. Adotta il protocollo quando il numero di integrazioni lo giustifica, e comincia dal perimetro più stretto (stdio, allowlist, versione fissata) allargandolo solo con una ragione scritta.
Le trappole in cui si cade davvero
- Scrivere sullo standard output in un server stdio. La specifica lo vieta; il log va su standard error, sempre, anche se un client indulgente non si accorge dell'errore.
- Sollevare un'eccezione qualunque per un errore di dominio. Nell'SDK 2.x il modello legge soltanto «Error executing tool …». Per far arrivare il motivo serve
ToolError. - Collegare un server e prendere tutti i tool. Funziona nel tutorial, e in produzione è una delega di superficie d'attacco a un terzo. Allowlist, sempre.
tools/lista ogni giro. Misurato: tre richieste su tre giri senza cache. Con un server tuo, attiva la cache.- Non fissare la versione del server e dell'SDK. Un server è una dipendenza eseguibile; e l'SDK Python ha già cambiato nome all'oggetto principale fra la serie 1.x e la 2.x.
- Dimenticare il ciclo di vita del processo. Fuori dal blocco
async withil server è terminato; un client usato dopo la chiusura dà errori che sembrano di protocollo e sono di gestione delle risorse. - Dare per scontate le capacità opzionali. Sampling, roots ed elicitation sono dichiarati dal client. Un server che le pretende senza controllare funziona con un host e non con un altro.
- Fidarsi delle descrizioni dei tool di un server altrui. Entrano nel contesto del tuo modello a ogni giro: sono prompt scritti da qualcun altro. Leggile una volta, prima di collegare il server.
Le otto trappole si raggruppano in tre famiglie: errori di implementazione del server (1, 2), errori di integrazione nell'host (3, 4, 6, 7) ed errori di governo della dipendenza (5, 8). Le prime due si trovano in pochi minuti con un transcript come quello sopra; le altre richiedono una decisione esplicita, scritta nel README del tuo agente: quali server, quali tool, quale versione, con quali permessi.
Che cosa sai fare adesso
Sai leggere una sessione MCP riga per riga, distinguere errore di esecuzione ed errore di protocollo, e sai che nell'SDK Python 2.x serve ToolError perché il modello legga il motivo di un errore. Sai scegliere fra tool, resource e prompt in base a chi ha l'iniziativa, e fra stdio e HTTP in base a chi deve raggiungere il server. Sai collegare un server a un agente con un filtro a lista chiusa e con la cache del catalogo, e hai visto con un contatore che cosa cambia. Sai anche che la revisione più recente della specifica ha tolto l'handshake e che il tuo server va scritto contro una revisione dichiarata. Nell'articolo successivo cambiamo livello: dalle capacità del modello alla scrittura del prompt.
Fonti
- MCP, specifica 2025-06-18: Lifecycle - l'handshake initialize/initialized e la negoziazione di versione e capacità, consultata il 2026-11-12
- MCP, specifica 2025-06-18: Transports - stdio (un messaggio per riga, stderr per il logging, divieto di scrivere altro su stdout) e Streamable HTTP che sostituisce HTTP+SSE, consultata il 2026-11-12
- MCP, specifica 2025-06-18: Tools - errori di protocollo contro errori di esecuzione (
isError), consultata il 2026-11-12 - MCP, specifica 2025-06-18: Sampling - la capacità del client di eseguire generazioni su richiesta di un server, consultata il 2026-11-12
- MCP, specifica 2026-07-28: Key Changes - rimozione di sessioni e handshake,
server/discover,subscriptions/listen, consultata il 2026-11-12 - OpenAI Agents SDK: Model context protocol (MCP) -
MCPServerStdio,cache_tools_liste il filtro dei tool (tool_filter, statico o dinamico); la firma l'ho verificata anche nel codice del pacchetto installato, consultata il 2026-11-12
I listati sono stati eseguiti due volte con Python 3.14.4, mcp 2.3.0 e openai-agents 0.24.0 in un ambiente virtuale temporaneo: le uscite sono identiche. Il server gira come processo figlio su stdio (nessuna porta, nessuna rete) e termina alla fine di ogni blocco. Le decisioni del modello vengono dal modello finto. Il trasporto HTTP e il flusso della revisione 2026-07-28 non sono stati eseguiti. Gli scenari (magazzino, SKU) sono inventati per l'esempio.
Commenti
Caricamento commenti...
Accedi per commentare