TL;DR : un LLM qui appelle des outils est un client auquel tu ne peux pas faire confiance. Et il a tes accès de production entre les mains. La règle la plus importante tient en une phrase. C'est ton serveur qui décide qui est l'utilisateur, jamais le modèle. Dans cet article, je te montre comment j'ai sécurisé un vrai agent LLM en Go, en production. Tout s'applique aussi aux serveurs MCP.
Cet article s'adresse aux devs Go qui mettent un agent LLM ou un serveur MCP en production. Pas en démo.
Tout le monde construit des serveurs MCP. Presque personne ne les sécurise.
MCP veut dire Model Context Protocol. C'est un standard qui permet à une IA d'utiliser tes outils : lire un fichier, appeler une API, chercher en base. Tout le monde l'adopte, très vite. Il faut dire que créer un serveur MCP en Go est facile. Vingt lignes suffisent avec le SDK officiel :
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type EchoInput struct {
Message string `json:"message" jsonschema:"the text to echo back"`
}
func echo(ctx context.Context, req *mcp.CallToolRequest, in EchoInput) (*mcp.CallToolResult, any, error) {
return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: in.Message}}}, nil, nil
}
func main() {
s := mcp.NewServer(&mcp.Implementation{Name: "echo", Version: "0.1.0"}, nil)
mcp.AddTool(s, &mcp.Tool{Name: "echo", Description: "Echo a message."}, echo)
if err := s.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
Ça, c'est la partie facile. Maintenant, regarde ce que ce serveur est vraiment. C'est un pont entre une IA que l'on peut manipuler avec du texte et tes vrais systèmes. Et la plupart de ces serveurs partent en prod sans aucune protection. En 2026, des chercheurs ont scanné Internet. Ils ont trouvé environ 492 serveurs MCP ouverts à tous, sans authentification. Ce n'étaient pas des démos. C'étaient des serveurs branchés sur de vrais systèmes, accessibles par n'importe qui.
De mon côté, je n'utilise pas MCP. J'utilise le tool-calling du SDK Anthropic. Peu importe : le danger est le même. Le modèle propose des actions. Ton code les exécute avec un accès production. La suite montre comment j'évite que ça tourne mal.
Le contexte
J'ai construit un agent LLM en Go, en production. C'est un assistant dans un domaine régulé. Il appelle des outils pour lire et écrire de vraies données, au nom d'utilisateurs connectés. La stack est classique. SDK Anthropic pour le modèle. Postgres pour les données. Redis pour les limites. Keycloak pour l'identité.
Enlève la partie métier, et il reste la même forme qu'un serveur MCP. Un modèle, des outils, tes systèmes. Tout ce qui suit s'applique donc aux deux.
Je viens de la sécurité, et je suis un peu parano. Brancher une IA sur de vraies données utilisateur sans la blinder d'abord, c'était non. Et l'actualité me donne raison. Anthropic a publié une étude, Agentic Misalignment. Elle montre une chose inquiétante. Sous pression, dans des scénarios de test, les grands modèles peuvent faire fuiter des données confidentielles dès qu'ils ont des outils. Claude compris. Dans la vraie vie, des agents se sont déjà fait piéger. Certains ont vidé les secrets d'une base. D'autres ont envoyé des e-mails en copie cachée à un attaquant. Le problème n'est pas le modèle. Le problème, c'est l'agent sans protection que tu construis autour.
Le threat model, en clair
Un threat model, c'est la liste de ce qui peut mal tourner. Ici, il tient en trois phrases. Un LLM avec des outils est un client d'API. Ses entrées peuvent être écrites par n'importe qui, à travers le prompt. Donc traite chaque argument d'outil comme si un attaquant l'avait tapé.
Cette attaque porte un nom : le prompt injection. On manipule l'IA avec du simple texte. C'est le risque numéro 1 du classement OWASP pour les LLM. Et aucune architecture ne le supprime totalement aujourd'hui.
L'identité vient du serveur, jamais du modèle
C'est la règle numéro 1. Tout le reste tourne autour d'elle.
Le modèle propose, mais ne choisit pas qui il est
D'abord, un mot sur le tenant. Dans une app multi-clients, le tenant est l'espace de données d'un client. Le tenant_id dit donc à qui appartient une donnée.
Prends un outil comme get_record(tenant_id, user_id, record_id). Quand le modèle l'appelle, il propose lui-même un tenant_id. Si tu acceptes cette valeur, tu as un gros problème. N'importe qui peut alors demander à l'IA de lire les données d'un autre client. Et ce n'est pas de la théorie. En production, un modèle m'a un jour envoyé le texte your_tenant_id comme identifiant. Oui, le texte littéral. Avec une confiance totale. Si mon serveur l'avait transmis, la requête partait en base avec du n'importe quoi. Ou pire, avec l'identifiant de quelqu'un d'autre.
La solution est simple. L'identité et le tenant viennent de la session de l'utilisateur connecté. Le serveur écrase toujours la valeur proposée par le modèle. Et si les deux valeurs ne correspondent pas, il écrit un log. Un écart ici veut dire une chose : hallucination ou attaque.
// The model proposes arguments. It does NOT get to choose who it acts as.
func (s *Service) prepareToolInput(sess Session, args map[string]any) map[string]any {
for _, key := range []string{"tenant_id", "user_id"} {
serverVal := sess.Get(key) // resolved from the validated JWT
if v, ok := args[key].(string); ok && v != "" && v != serverVal {
// Divergent value = hallucination or attack. Log it, then enforce.
s.log.Warn("tool_call."+key+"_mismatch",
zap.Int("proposed_len", len(v)), zap.String("enforced", serverVal))
}
args[key] = serverVal // the server always wins
}
return args
}
Une nuance quand même. Le modèle peut choisir un identifiant de document sans danger, si ce document appartient à l'utilisateur. Par exemple pour une demande comme « compare le document A et le document B ». La base vérifie le tenant de toute façon, on le voit plus bas. La règle finale tient en une phrase. Le modèle choisit quoi. Jamais qui.
Garde le token loin du modèle
Pour écraser avec la bonne valeur, il faut une identité fiable. Chez moi, elle vient du JWT. Un JWT est un jeton signé qui prouve qui est l'utilisateur. Mon serveur vérifie ce jeton dès l'entrée de la requête. Ensuite, l'identité voyage avec la requête, jusqu'à chaque appel : le modèle, la base, les outils.
Deux règles ne bougent jamais. Un, les jetons des services et ceux des utilisateurs restent séparés. Un utilisateur ne peut pas se faire passer pour un service. Deux, si le serveur d'identité ne répond plus, on refuse tout. On ne laisse jamais passer par défaut.
Et le jeton lui-même ? Le modèle ne le voit jamais. Il circule de service Go à service Go, côté serveur. Le modèle ne touche que des données.
Pour appeler une API externe, autre règle. Le serveur utilise ses propres identifiants OAuth. Jamais le jeton de l'utilisateur. La spec MCP appelle ça le « no token passthrough ». Entre mes services internes, c'est différent. Le jeton de l'utilisateur est transmis, puis vérifié à nouveau par chaque service. Chaque jeton reste donc utilisé là où il est prévu. Nulle part ailleurs.
Donne à chaque agent une courte liste d'outils
Un agent n'a pas besoin de tous tes outils. Si tu lui donnes le catalogue complet, un prompt piégé peut atteindre un outil sensible. Chez moi, chaque agent a sa propre liste d'outils autorisés. Le serveur cherche l'outil demandé dans cette liste. S'il ne le trouve pas, il refuse. Avant toute exécution :
handler, ok := s.toolsFor(agentType)[toolName]
if !ok {
return toolError("tool %q not available for this agent", toolName) // never execute unknown tools
}
Pourquoi une liste par agent, et pas une seule grande liste ? Parce que si une injection réussit, l'attaquant obtient tous les outils de l'agent en cours. Pas un de plus. Un assistant de recherche qui peut aussi supprimer des comptes, c'est un accident qui attend son prompt. Quant à un nom d'outil inconnu, c'est un bug ou une attaque. Dans les deux cas, on refuse et on écrit un log.
Un dernier point pour MCP. Si tu charges des serveurs MCP créés par d'autres, méfie-toi de leurs descriptions d'outils. Le modèle lit ces descriptions et leur fait confiance. Une description piégée peut donc manipuler ton agent. Ça s'appelle le tool poisoning.
Mets l'isolation des tenants dans la base
Tu peux filtrer les données par tenant dans ton code Go. Mais un seul if oublié crée une fuite. Alors je mets la barrière dans Postgres, pas dans le code. Postgres a une fonction faite pour ça : la Row Level Security, ou RLS. Elle décide quelles lignes chaque requête a le droit de voir :
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON conversations
USING (tenant_id = current_setting('app.current_tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);
À chaque connexion, mon code remplit la variable app.current_tenant_id. La valeur vient du jeton de l'utilisateur. Ensuite, Postgres filtre tout seul. Même une requête avec un bug ne peut pas lire les lignes d'un autre client. Elles sont invisibles. Et le rôle de l'application n'a pas le droit de désactiver la RLS.
Un détail qui compte. Quand une ressource ne t'appartient pas, mon API répond « not found ». Pas « forbidden ». Pourquoi ? « Forbidden » confirme que la ressource existe. C'est un indice gratuit pour un attaquant qui teste des identifiants au hasard. « Not found » ne donne rien.
Masque les données personnelles avant le modèle
Les PII, ce sont les données personnelles : nom, e-mail, téléphone. Le modèle n'a pas besoin du vrai nom de Jane pour lui écrire un message. Alors avant chaque envoi au modèle, mon serveur remplace ces données par des codes neutres. Les vraies valeurs ne reviennent qu'au moment de l'affichage :
"Email the contract to Jane Doe ([email protected])" │ inject ▼ "Email the contract to {PH:customer_name} ({PH:email})" → model sees only tokens │ resolve (UI only) ▼ "Email the contract to Jane Doe ([email protected])"
Trois détails font tenir le système. Un, je stocke le texte masqué et le texte original à deux endroits séparés. Rejouer une conversation n'expose donc jamais les vraies valeurs. Deux, si un utilisateur tape lui-même un code comme {PH:test} dans un message, mon serveur l'échappe. Il ne sera jamais confondu avec un vrai code. Trois, quand un outil a besoin d'une vraie valeur, il reçoit celle-là. Jamais toute la liste. Moins de données qui circulent, c'est moins de risque. Et en bonus, une facture de tokens plus légère.
Donne un budget au modèle, et surveille-le
Un agent qui boucle sur des outils peut coûter très cher, très vite. Il peut aussi se faire attaquer juste pour te faire payer. Alors je pose des limites, en couches :
- Une limite de requêtes par adresse IP, dès l'entrée.
- Une limite par utilisateur pour les outils coûteux, vérifiée avant chaque appel.
- Un plafond de tokens. Une requête trop grosse est réduite au lieu d'échouer.
- Un circuit breaker autour de l'API du modèle. Après plusieurs échecs de suite, il coupe les appels un moment. J'utilise la lib
gobreaker. - Une alerte quand un utilisateur dépense trop dans la journée. Une alerte, pas un blocage.
Reste une question. Que faire quand Redis tombe et que la limite ne peut plus être vérifiée ? Il y a deux écoles. Fail open : on laisse passer. Fail closed : on bloque tout. Mon limiteur par utilisateur laisse passer. Je préfère un service qui marche à une limite parfaite.
Ce choix dépend du rôle du contrôle. Le fail closed sert à stopper les abus et à échouer vite. Mais appliqué partout, il déplace juste la panne. Un simple souci Redis couperait toute la fonctionnalité. Donc je découpe. L'autorisation bloque toujours en cas de doute. On ne laisse jamais entrer quelqu'un parce que la vérification n'a pas pu tourner. La limite de coût, elle, peut laisser passer. D'autres filets restent actifs : la limite par IP, le plafond de tokens, le circuit breaker. Sans ces filets, je ferais l'inverse. Le contexte décide.
Ensuite, tu surveilles. Chaque appel d'outil écrit un log structuré : qui, quel outil, quelle décision, en combien de temps. Les métriques de coût partent vers Prometheus. Le détail par utilisateur va dans une table d'audit. Et le log le plus précieux n'est pas un succès. C'est l'écart d'identité vu plus haut, quand le modèle propose un mauvais tenant_id. Cette ligne est ton détecteur de fumée pour le prompt injection.
Blinde le code de chaque outil
Un attaquant peut influencer chaque argument d'outil. Le code qui reçoit ces arguments est donc une frontière à défendre. Les règles sont connues, et un peu ennuyeuses. Mais elles marchent :
- Jamais de SQL collé à la main. J'utilise
sqlc, qui génère des requêtes paramétrées et typées. L'injection SQL devient impossible. - Jamais de shell. Les arguments vont directement au programme, sans passer par
sh. - Des limites partout : taille des requêtes, taille des contenus, durée maximum.
exec.Command("sh", "-c", "grep "+in.Query+" file") // ❌ shell injection
exec.Command("grep", in.Query, "file") // ✅ args, no shell
// SQL: db.QueryContext(ctx, "... WHERE id = $1", id), always parameterized
La description de l'outil guide le modèle. Mais c'est ton code qui protège. Valide dans le schéma. Puis valide encore dans le code.
Protège le code avant même la prod
Toutes ces protections tournent en prod. Mais la sécurité commence plus tôt, dans le repo. Concrètement, chez moi :
gitleaksscanne chaque commit. Il bloque les secrets, les clés et les fichiers.env.- Les secrets vivent dans un gestionnaire dédié, jamais dans le code.
- Un linter d'architecture interdit à la logique métier d'importer l'infrastructure.
- Les builds de prod suppriment les données de test. Si elles restent présentes, le programme refuse de démarrer.
- La CI lance le lint et les tests à chaque push.
Ce qui change pour un vrai serveur MCP
Tout ce qui précède vaut pour n'importe quel agent. Si tu construis un vrai serveur MCP, ajoute ces points :
- En local, utilise le transport
stdio. À distance, utilise Streamable HTTP. Il remplace l'ancien HTTP+SSE. Et dès que tu quittesstdio, l'authentification devient obligatoire. - Pour l'authentification, la spec impose OAuth 2.1 avec PKCE. Vérifie aussi l'audience du jeton (RFC 8707). Un jeton créé pour un autre serveur ne doit pas marcher chez toi.
- N'expose pas le serveur par accident. Écoute sur
127.0.0.1par défaut. Vérifie les en-têtesOriginetHost. Et n'ouvre jamais le serveur sur Internet sans authentification.
La checklist avant la prod
Avant de mettre un agent LLM ou un serveur MCP en production, coche toutes ces cases.
- L'identité et le tenant viennent du serveur, jamais du modèle
- Chaque écart proposé par le modèle est loggé comme une attaque possible
- Chaque agent a sa liste d'outils, et tout outil inconnu est rejeté
- Le JWT est vérifié dès l'entrée, et on refuse tout si le serveur d'identité tombe
- Les jetons de service et les jetons d'utilisateur sont séparés
- L'isolation des tenants est dans la base avec la RLS, pas seulement dans le code
- Les données personnelles sont masquées avant d'atteindre le modèle
- Plafond de tokens, rate limits, circuit breaker et alertes de dépense en place
- SQL paramétré, pas de shell, limites de taille et de durée partout
- Chaque appel d'outil est loggé, avec une table d'audit
- Serveur MCP distant : OAuth 2.1 + PKCE, audience du jeton vérifiée, pas de token passthrough
- Serveur en écoute locale, en-têtes
OriginetHostvérifiés, auth avant toute exposition - Les secrets sont dans un gestionnaire dédié et scannés à chaque commit
Ce qu'il faut retenir
On appelle encore ces systèmes des chatbots. C'est une erreur. Et cette erreur mène droit à la fuite de données. Un LLM avec des outils est un client d'API qui écrit la moitié de ses propres requêtes. Traite-le comme tel. Go est un excellent choix pour ce travail. Et les devs qui savent construire ces agents et les sécuriser vont rester rares encore un moment.
La sécurité n'est plus une option ici. C'est elle qui sépare un agent utile d'un agent qui fait fuiter les données de tes utilisateurs. Tu mets un agent IA ou un serveur MCP en production ? Tu veux un second regard sur ta sécurité ? C'est exactement ce que je fais. Écris-moi. Livre ton agent. Mais ne livre pas la faille avec.