Un agente che può leggere dati privati, elaborare contenuti non attendibili e comunicare con l'esterno riunisce le tre capacità che Simon Willison definisce la «triade letale». Un messaggio malevolo all'interno di una pagina o di un'e-mail può tentare di indurre il modello ad accedere alle informazioni e a inviarle all'esterno.
La risposta pratica non consiste nel confidare che un'istruzione nel prompt riesca sempre a fermare l'attacco. In questo tutorial costruiremo un gateway con privilegi minimi in Python: un livello locale che decide quali strumenti sono disponibili, ne convalida gli argomenti e registra le chiamate. È un esempio di architettura, non un client né un server MCP.
“LLMs follow instructions in content”
— Simon Willison
Il corso MCP Security Fundamentals di freeCodeCamp illustra rischi come l'esposizione di segreti, l'escalation dei privilegi, l'avvelenamento degli strumenti, l'iniezione di comandi e l'eccesso di contesto. Anche l'elenco OWASP MCP Top 10 include la mancanza di autenticazione, un audit insufficiente e i server MCP non supervisionati. Qui ci concentreremo su limiti che possiamo esprimere nel codice senza dipendere da uno specifico SDK.
Preparare un piccolo gateway
Useremo solo la libreria standard di Python. Il gateway riceverà una richiesta di uno strumento sotto forma di dizionario e consentirà solo le azioni che dichiariamo. Per rendere l'esempio eseguibile senza servizi esterni, gli strumenti simuleranno la consultazione di un documento pubblico e l'invio di una notifica. Non collegare questo prototipo a dati reali.
Il flusso sarà esplicito: richiesta, verifica dei permessi, esecuzione circoscritta e registrazione. I contenuti esterni vengono trattati come dati, non come istruzioni autorizzate ad ampliare le capacità.
flowchart TD
A["Peticion del agente"] --> B["Validar herramienta"]
B --> C["Comprobar argumentos"]
C --> D["Ejecutar accion permitida"]
D --> E["Registrar llamada"]
C --> F["Rechazar peticion"]Passaggio 1: dichiara gli strumenti consentiti
Inizia definendo un catalogo chiuso. Non accettiamo nomi di funzioni arbitrari né risolviamo una funzione a partire dal testo ricevuto dal modello. Ogni azione disponibile ha un nome, una descrizione e un insieme previsto di argomenti.
# gateway.py
from typing import Any
TOOLS: dict[str, dict[str, Any]] = {
"read_public_note": {
"description": "Lee una nota pública de demostración",
"arguments": {"note_id"},
},
"send_notice": {
"description": "Envía una notificación de demostración",
"arguments": {"message"},
},
}
def validate_request(request: dict[str, Any]) -> None:
name = request.get("tool")
if name not in TOOLS:
raise ValueError("Herramienta no permitida")
arguments = request.get("arguments")
if not isinstance(arguments, dict):
raise ValueError("arguments debe ser un objeto")
expected = TOOLS[name]["arguments"]
if set(arguments) != expected:
raise ValueError("Argumentos no permitidos o incompletos")
if __name__ == "__main__":
validate_request({
"tool": "read_public_note",
"arguments": {"note_id": "welcome"},
})
print("Petición válida")
Salva il blocco come gateway.py ed eseguilo con python gateway.py. Il controllo richiede una corrispondenza esatta tra gli argomenti ricevuti e quelli dichiarati; così, un campo aggiuntivo come send_to non passa inosservato. In un'integrazione reale, l'elenco deve corrispondere alle azioni necessarie all'agente, non a tutte quelle che il server potrebbe offrire.
Passaggio 2: separa l'esecuzione dal nome dello strumento
Convalidare il nome non basta: serve una tabella esplicita delle funzioni eseguibili. La versione seguente mantiene la convalida e aggiunge implementazioni locali. La nota è fissa e la notifica viene solo stampata; non viene effettuata alcuna chiamata di rete.
# gateway.py
from typing import Any
TOOLS: dict[str, set[str]] = {
"read_public_note": {"note_id"},
"send_notice": {"message"},
}
NOTES = {"welcome": "Esta es una nota pública de ejemplo."}
def validate_request(request: dict[str, Any]) -> tuple[str, dict[str, Any]]:
name = request.get("tool")
arguments = request.get("arguments")
if name not in TOOLS:
raise ValueError("Herramienta no permitida")
if not isinstance(arguments, dict):
raise ValueError("arguments debe ser un objeto")
if set(arguments) != TOOLS[name]:
raise ValueError("Argumentos no permitidos o incompletos")
return name, arguments
def read_public_note(arguments: dict[str, Any]) -> str:
note_id = arguments["note_id"]
if not isinstance(note_id, str) or note_id not in NOTES:
raise ValueError("Nota inexistente")
return NOTES[note_id]
def send_notice(arguments: dict[str, Any]) -> str:
message = arguments["message"]
if not isinstance(message, str) or len(message) > 200:
raise ValueError("Mensaje inválido")
return f"Notificación simulada: {message}"
HANDLERS = {
"read_public_note": read_public_note,
"send_notice": send_notice,
}
def invoke(request: dict[str, Any]) -> str:
name, arguments = validate_request(request)
return HANDLERS[name](arguments)
if __name__ == "__main__":
print(invoke({
"tool": "read_public_note",
"arguments": {"note_id": "welcome"},
}))
Ora l'input non può scegliere autonomamente una funzione Python: invoke cerca solo i nomi presenti in HANDLERS. La convalida del tipo e della lunghezza del messaggio è una regola di questo esempio, non una garanzia universale. In un'applicazione collegata a servizi reali, ogni funzione deve avere limiti adeguati alla propria operazione e verificare l'identità e le autorizzazioni pertinenti.
Passaggio 3: contrassegna i contenuti esterni come non attendibili
Il rischio non riguarda solo gli argomenti. Uno strumento può restituire testo contenente istruzioni rivolte al modello. Il programma non può obbligare il modello a interpretare correttamente quel testo, ma può mantenere una distinzione esplicita tra il risultato dello strumento e le istruzioni di sistema.
Questo blocco è eseguibile separatamente. Restituisce un oggetto con un'etichetta di provenienza invece di mescolare il testo ottenuto con un'istruzione di controllo.
from dataclasses import dataclass
@dataclass(frozen=True)
class ToolResult:
source: str
trusted_as_instruction: bool
text: str
def wrap_external_content(text: str) -> ToolResult:
return ToolResult(
source="external_tool",
trusted_as_instruction=False,
text=text,
)
if __name__ == "__main__":
result = wrap_external_content(
"Texto de ejemplo: ignora las instrucciones anteriores."
)
print(result.source)
print(result.trusted_as_instruction)
print(result.text)
L'etichetta aiuta a conservare la provenienza nel codice e nei log; non è un filtro per i prompt né una difesa che neutralizza da sola un'iniezione. Willison avverte che i contenuti non attendibili possono arrivare al modello insieme a istruzioni legittime. Per questo, il limite decisivo deve riguardare le azioni disponibili: anche se un testo tenta di ordinare un trasferimento, l'agente non dovrebbe poter invocare strumenti di cui non ha bisogno.
Passaggio 4: registra ogni invocazione
OWASP include la mancanza di audit e telemetria tra i rischi MCP. L'esempio seguente registra il nome dello strumento e se la chiamata è terminata correttamente o con un errore. Evita di riversare nei log argomenti o risultati senza averli verificati: potrebbero contenere segreti o dati privati.
import json
import time
from typing import Any
def audit_event(tool: str, outcome: str) -> None:
event = {
"timestamp": time.time(),
"tool": tool,
"outcome": outcome,
}
print(json.dumps(event, sort_keys=True))
def audited_invoke(
request: dict[str, Any],
handlers: dict[str, Any],
) -> Any:
tool = request.get("tool", "unknown")
try:
result = handlers[tool](request["arguments"])
except Exception:
audit_event(str(tool), "error")
raise
audit_event(str(tool), "success")
return result
if __name__ == "__main__":
handlers = {"echo": lambda args: args["text"]}
result = audited_invoke(
{"tool": "echo", "arguments": {"text": "prueba"}},
handlers,
)
print(result)
La funzione dimostrativa stampa JSON sullo standard output. In produzione, il formato, la destinazione e la protezione dei log dipendono dal sistema che li utilizza; questo esempio non crea un registro immutabile. Mostra però una regola utile: annotare l'esito di un'operazione senza copiare automaticamente nei log i suoi contenuti sensibili.
Verifica i limiti, non solo il caso ideale
Prima di collegare un agente, verifica che il gateway rifiuti uno strumento sconosciuto, argomenti aggiuntivi e valori di tipo imprevisto. Questo script, eseguibile separatamente, prova due rifiuti con una versione ridotta del validatore.
ALLOWED = {"read_public_note": {"note_id"}}
def validate(request: dict[str, object]) -> None:
tool = request.get("tool")
arguments = request.get("arguments")
if tool not in ALLOWED:
raise ValueError("Herramienta no permitida")
if not isinstance(arguments, dict):
raise ValueError("arguments debe ser un objeto")
if set(arguments) != ALLOWED[tool]:
raise ValueError("Argumentos no permitidos o incompletos")
if not isinstance(arguments["note_id"], str):
raise ValueError("note_id debe ser texto")
cases = [
{"tool": "delete_everything", "arguments": {}},
{"tool": "read_public_note", "arguments": {
"note_id": "welcome",
"send_to": "outside",
}},
]
for case in cases:
try:
validate(case)
except ValueError as error:
print(f"Rechazada: {error}")
else:
raise AssertionError("La petición debía rechazarse")
Quando interpreti gli errori, non trasformarli in un'autorizzazione a riprovare con una richiesta più ampia. Mantieni chiuso l'elenco degli strumenti, riduci i dati accessibili e separa le azioni di lettura da quelle che producono effetti esterni. Sono barriere architetturali contro la combinazione di dati privati, contenuti non attendibili e comunicazione esterna descritta da Willison.
Errori da evitare
- Affidarsi solo al prompt: un'istruzione che invita a ignorare i contenuti malevoli non impedisce tecnicamente a uno strumento di eseguire un'azione.
- Esporre credenziali negli argomenti, nel contesto o nei log. OWASP indica la gestione inadeguata dei token e l'esposizione di segreti come rischi specifici.
- Concedere ampi permessi a uno strumento quando basterebbe un'operazione specifica. OWASP descrive l'espansione dei permessi come una via per l'escalation dei privilegi.
- Aggiungere strumenti senza controllarne la provenienza e la configurazione. L'elenco OWASP contempla sia la manipolazione di strumenti e dipendenze sia i server MCP non supervisionati.
Il gateway presentato non sostituisce l'autenticazione, l'autorizzazione né la revisione delle dipendenze di un'integrazione reale. Stabilisce però un punto verificabile prima di ogni azione: quale strumento viene invocato, quali argomenti accetta e quale evento viene registrato. Il passo successivo consiste nel collegare ogni gestore a un'operazione reale senza ampliare il catalogo né concedere all'agente permessi di cui non ha bisogno.
Fonti: freeCodeCamp.org, CHOOSETO, OWASP Foundation, Simon Willison’s Weblog