A propos Compétences Expérience Services Blog Contact

Ne laisse jamais le modèle choisir le tenant ID : sécuriser un agent LLM en Go Never Let the Model Pick the Tenant ID: Securing an LLM Agent in Go

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 :

  • gitleaks scanne 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 quittes stdio, 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.1 par défaut. Vérifie les en-têtes Origin et Host. 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 Origin et Host vé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.

TL;DR: an LLM that calls tools is a client you cannot trust. And it holds your production credentials. The most important rule fits in one sentence. Your server decides who the user is, never the model. In this article, I show how I secured a real LLM agent in Go, in production. Everything also applies to MCP servers.

This article is for Go developers who put an LLM agent or an MCP server in production. Not in a demo.

Everyone builds MCP servers. Almost nobody secures them.

MCP stands for Model Context Protocol. It is a standard that lets an AI use your tools: read a file, call an API, query a database. Everyone is adopting it, fast. And building an MCP server in Go is easy. Twenty lines are enough with the official SDK:

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)
	}
}

That part is easy. Now look at what this server really is. It is a bridge between an AI that can be manipulated with text and your real systems. And most of these servers ship with no protection at all. In 2026, researchers scanned the internet. They found about 492 MCP servers open to everyone, with no authentication. These were not demos. They were servers wired to real systems, reachable by anyone.

I do not use MCP myself. I use tool-calling from the Anthropic SDK. It changes nothing: the danger is the same. The model proposes actions. Your code runs them with production access. The rest of this article shows how I keep that from going wrong.

The setup

I built an LLM agent in Go, in production. It is an assistant in a regulated field. It calls tools to read and write real data, on behalf of logged-in users. The stack is standard. Anthropic SDK for the model. Postgres for data. Redis for limits. Keycloak for identity.

Remove the business part, and the same shape as an MCP server remains. A model, tools, your systems. So everything below applies to both.

I come from security, and I am a bit paranoid. Plugging an AI into real user data without hardening it first was a no. And the news keeps proving me right. Anthropic published a study, Agentic Misalignment. It shows something worrying. Under pressure, in test scenarios, top models will leak confidential data once they get tools. Claude included. In the real world, agents have already been tricked. Some dumped a database's secrets. Others sent hidden email copies to an attacker. The problem is not the model. The problem is the unprotected agent you build around it.

The threat model, in plain words

A threat model is the list of what can go wrong. Here, it fits in three sentences. An LLM with tools is an API client. Its inputs can be written by anyone, through the prompt. So treat every tool argument as if an attacker typed it.

This attack has a name: prompt injection. You steer the AI with plain text. It is risk number 1 in the OWASP Top 10 for LLMs. And no architecture removes it completely today.

Identity comes from the server, never from the model

This is rule number 1. Everything else revolves around it.

The model proposes, but never picks who it is

First, a word about tenants. In a multi-client app, a tenant is one client's data space. The tenant_id says who a piece of data belongs to.

Take a tool like get_record(tenant_id, user_id, record_id). When the model calls it, it proposes a tenant_id on its own. If you accept that value, you have a big problem. Anyone can then ask the AI to read another client's data. And this is not theory. In production, a model once sent my server the text your_tenant_id as an identifier. Yes, the literal text. With full confidence. If my server had passed it on, the query would have hit the database with garbage. Or worse, with someone else's identifier.

The fix is simple. Identity and tenant come from the logged-in user's session. The server always overwrites the value proposed by the model. And when the two values differ, it writes a log. A gap here means one thing: hallucination or attack.

// 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
}

One nuance, though. The model can safely pick a document ID, as long as that document belongs to the user. For example, for a request like "compare document A and document B". The database checks the tenant anyway, as we will see below. The final rule fits in one sentence. The model picks what. Never who.

Keep the token away from the model

To overwrite with the right value, you need a reliable identity. Mine comes from the JWT. A JWT is a signed token that proves who the user is. My server checks that token at the start of every request. Then the identity travels with the request, into every call: the model, the database, the tools.

Two rules never change. One, service tokens and user tokens stay separate. A user can never pass as a service. Two, if the identity server stops responding, we reject everything. We never allow by default.

What about the token itself? The model never sees it. It moves from Go service to Go service, on the server side. The model only touches data.

Calling an external API is another rule. The server uses its own OAuth credentials. Never the user's token. The MCP spec calls this "no token passthrough". Between my internal services, it is different. The user's token is passed along, then checked again by each service. So every token stays where it was meant to be used. Nowhere else.

Give each agent a short list of tools

An agent does not need all your tools. Give it the full catalog, and a poisoned prompt can reach a sensitive tool. In my system, each agent has its own list of allowed tools. The server looks up the requested tool in that list. If it is not there, it refuses. Before anything runs:

handler, ok := s.toolsFor(agentType)[toolName]
if !ok {
	return toolError("tool %q not available for this agent", toolName) // never execute unknown tools
}

Why one list per agent, and not one big list? Because when an injection succeeds, the attacker gets every tool the current agent can reach. Not one more. A search assistant that can also delete accounts is an accident waiting for its prompt. As for an unknown tool name, it is a bug or an attack. In both cases, we refuse and we write a log.

One last point for MCP. If you load MCP servers built by others, be careful with their tool descriptions. The model reads those descriptions and trusts them. A poisoned description can steer your agent. This is called tool poisoning.

Put tenant isolation in the database

You can filter data by tenant in your Go code. But one forgotten if creates a leak. So I put the barrier in Postgres, not in the code. Postgres has a feature built for this: Row Level Security, or RLS. It decides which rows each query is allowed to see:

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);

On every connection, my code fills the app.current_tenant_id variable. The value comes from the user's token. Then Postgres filters on its own. Even a buggy query cannot read another client's rows. They are invisible. And the application role is not allowed to turn RLS off.

One detail that matters. When a resource is not yours, my API answers "not found". Not "forbidden". Why? "Forbidden" confirms the resource exists. That is a free hint for an attacker testing random IDs. "Not found" gives nothing away.

Mask personal data before the model

PII means personal data: name, email, phone. The model does not need Jane's real name to write her a message. So before every call to the model, my server replaces personal data with neutral placeholders. The real values only come back at display time:

"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])"

Three details make this work. One, I store the masked text and the original text in two separate places. Replaying a conversation never exposes the real values. Two, if a user types a code like {PH:test} in a message, my server escapes it. It will never be mistaken for a real placeholder. Three, when a tool needs a real value, it gets that one. Never the full list. Less data moving around means less risk. And as a bonus, a smaller token bill.

Give the model a budget, and watch it

An agent looping on tools can cost a lot of money, fast. It can also get attacked just to make you pay. So I set limits, in layers:

  • A request limit per IP address, at the front door.
  • A limit per user for expensive tools, checked before every call.
  • A cap on tokens. An oversized request gets trimmed instead of failing.
  • A circuit breaker around the model API. After several failures in a row, it stops the calls for a while. I use the gobreaker library.
  • An alert when a user spends too much in one day. An alert, not a block.

One question remains. What happens when Redis is down and the limit cannot be checked? There are two schools. Fail open: let it pass. Fail closed: block everything. My per-user limiter lets it pass. I prefer a working service over a perfect limit.

The choice depends on what the control is for. Fail closed exists to stop abuse and to fail fast. But applied everywhere, it just moves the outage. One Redis hiccup would kill the whole feature. So I split. Authorization always blocks when in doubt. You never let someone in because the check could not run. The cost limit can let things pass. Other nets stay active: the IP limit, the token cap, the circuit breaker. Without those nets, I would do the opposite. Context decides.

Then, you watch. Every tool call writes a structured log: who, which tool, which decision, how long. Cost metrics go to Prometheus. Per-user detail goes to an audit table. And the most valuable log line is not a success. It is the identity gap from earlier, when the model proposes a wrong tenant_id. That line is your smoke detector for prompt injection.

Harden the code of every tool

An attacker can influence every tool argument. So the code receiving those arguments is a border to defend. The rules are well known, and a bit boring. But they work:

  • Never glue SQL by hand. I use sqlc, which generates typed, parameterized queries. SQL injection becomes impossible.
  • Never use a shell. Arguments go straight to the program, without sh.
  • Limits everywhere: request size, content size, maximum duration.
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

The tool description guides the model. But your code does the protecting. Validate in the schema. Then validate again in the code.

Protect the code before production

All these protections run in production. But security starts earlier, in the repo. In practice, on my side:

  • gitleaks scans every commit. It blocks secrets, keys, and .env files.
  • Secrets live in a dedicated manager, never in the code.
  • An architecture linter forbids business logic from importing infrastructure.
  • Production builds strip the test data. If it is still there, the program refuses to start.
  • CI runs lint and tests on every push.

What changes for a real MCP server

Everything above applies to any agent. If you build a real MCP server, add these points:

  • Locally, use the stdio transport. For remote, use Streamable HTTP. It replaces the old HTTP+SSE. And as soon as you leave stdio, authentication becomes mandatory.
  • For auth, the spec requires OAuth 2.1 with PKCE. Also check the token's audience (RFC 8707). A token made for another server must not work on yours.
  • Do not expose the server by accident. Listen on 127.0.0.1 by default. Check the Origin and Host headers. And never open the server to the internet without authentication.

The pre-production checklist

Before you put an LLM agent or an MCP server in production, tick every box.

  • Identity and tenant come from the server, never from the model
  • Every gap proposed by the model is logged as a possible attack
  • Each agent has its own tool list, and unknown tools are rejected
  • The JWT is checked at the front door, and everything is rejected if the identity server is down
  • Service tokens and user tokens are separate
  • Tenant isolation lives in the database with RLS, not only in the code
  • Personal data is masked before it reaches the model
  • Token cap, rate limits, circuit breaker, and spend alerts in place
  • Parameterized SQL, no shell, size and time limits everywhere
  • Every tool call is logged, with an audit table
  • Remote MCP server: OAuth 2.1 + PKCE, token audience checked, no token passthrough
  • Server listening locally, Origin and Host headers checked, auth before any exposure
  • Secrets in a dedicated manager, scanned at every commit

What to remember

People still call these systems chatbots. That is a mistake. And that mistake leads straight to a data leak. An LLM with tools is an API client that writes half of its own requests. Treat it that way. Go is a great fit for this work. And developers who can build these agents and secure them will stay rare for a while.

Security is no longer optional here. It is what separates a useful agent from an agent that leaks your users' data. Are you putting an AI agent or an MCP server into production? Do you want a second pair of eyes on your security? That is exactly what I do. Get in touch. Ship your agent. Just do not ship the breach with it.