Claude Code sait lire ton repo, lancer ta CLI, requêter ta base — à condition que tu lui en donnes la porte d'entrée. Cette porte, c'est un serveur MCP (Model Context Protocol). Le jour où j'ai voulu que Claude interroge notre API interne de facturation sans que je copie-colle des réponses JSON à la main, j'ai écrit un petit serveur MCP. En Go, pas en Python — et avec le recul, c'était le bon choix pour une raison précise qu'on verra plus bas.
Le SDK Go officiel (github.com/modelcontextprotocol/go-sdk) a mûri en 2025. Il est aujourd'hui tout à fait utilisable en production, mais la doc reste centrée sur le « hello world ». Voici ce qui compte vraiment quand le serveur quitte ta machine : le choix du transport, le typage des outils, l'auth, et les pièges de design que j'ai pris en pleine figure.
MCP en 30 secondes : un serveur qui expose des outils à un LLM
MCP est un protocole client-serveur. Le client (Claude Code, Claude Desktop, ton agent maison) se connecte à un ou plusieurs serveurs, chacun exposant trois choses : des tools (des fonctions que le modèle peut appeler), des resources (des données qu'il peut lire), et des prompts (des gabarits réutilisables). 90 % de l'usage réel, c'est les tools.
Le point clé : tu n'écris pas de prompt. Tu déclares des outils avec un nom, une description et un schéma d'entrée. Le modèle lit ces descriptions et décide tout seul quand les appeler. La qualité de tes descriptions est ton interface.
Le serveur minimal avec le SDK officiel
Trois étapes : créer le serveur, enregistrer un outil avec son handler typé, le faire tourner sur un transport. Le SDK infère le schéma JSON de l'outil directement depuis ton struct Go.
package main
import (
"context"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// L'entrée de l'outil : le SDK en déduit le schéma JSON exposé au modèle.
type InvoiceArgs struct {
ClientID string `json:"client_id" jsonschema:"the client account ID"`
Year int `json:"year" jsonschema:"fiscal year, e.g. 2026"`
}
func getInvoices(ctx context.Context, req *mcp.CallToolRequest, args InvoiceArgs) (*mcp.CallToolResult, any, error) {
rows, err := billing.Lookup(ctx, args.ClientID, args.Year) // ton vrai code
if err != nil {
return nil, nil, err
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: rows.Markdown()}},
}, nil, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{
Name: "billing",
Version: "v1.0.0",
}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "get_invoices",
Description: "List a client's invoices for a given fiscal year.",
}, getInvoices)
// transport stdio : Claude Code lance ce binaire comme sous-processus
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}
Côté Claude Code, tu déclares ce serveur dans la config MCP (commande + arguments). Au démarrage, Claude lance le binaire, négocie le protocole, et découvre get_invoices. Aucun prompt à écrire : la description suffit.
stdio ou HTTP streamable : le choix qui détermine tout le reste
Le SDK supporte deux transports, et c'est la décision d'architecture. Ils ne servent pas le même usage :
| stdio | HTTP streamable | |
|---|---|---|
| Exécution | sous-processus local lancé par le client | service réseau autonome |
| Clients | un seul, local | plusieurs, distants |
| Auth | héritée de la machine | à ta charge (token, mTLS…) |
| Usage type | outil perso, dev, CLI | serveur d'équipe, SaaS, prod partagée |
La règle simple : stdio pour un outil que toi seul utilises sur ta machine, HTTP dès que c'est partagé. Le piège classique, c'est de prototyper en stdio puis de vouloir l'exposer à l'équipe sans réaliser que tu passes d'un modèle « zéro auth » à « surface d'attaque réseau ». Le transport HTTP se branche presque comme un handler HTTP classique — ce qui veut dire que toutes les best practices middleware (recover, timeout, auth, logs) s'appliquent :
handler := mcp.NewStreamableHTTPHandler(
func(r *http.Request) *mcp.Server { return server },
nil,
)
// on enveloppe avec les mêmes middlewares qu'une API normale
http.ListenAndServe(":8080", Chain(handler, Recover, Auth, Logger))
Des outils typés : laisser les types Go faire le travail
C'est ici que Go prend l'avantage sur Python pour ce métier précis. Le schéma JSON exposé au modèle est dérivé de ton struct d'entrée. Pas de schéma écrit à la main, pas de désynchronisation entre la validation et la doc :
// ❌ schéma manuel : il dérive du code, se désynchronise, et ne valide rien
tool := &mcp.Tool{
Name: "search",
InputSchema: rawJSON(`{"type":"object","properties":{"q":{"type":"string"}}}`),
}
// ✅ struct typé : schéma inféré, validation gratuite, un seul point de vérité
type SearchArgs struct {
Query string `json:"query" jsonschema:"full-text search query"`
Limit int `json:"limit" jsonschema:"max results, default 10"`
}
Quand le modèle envoie des arguments, le SDK les valide contre le schéma avant d'appeler ton handler. Un year envoyé comme string ? Rejeté avant ton code. C'est exactement la philosophie Go de rendre l'état invalide impossible, appliquée à la frontière avec le LLM — la couche la moins fiable de ton système.
Auth et sécurité : un serveur MCP est une surface d'attaque
Un serveur MCP exécute du code à la demande d'un modèle, lui-même piloté par un prompt potentiellement adverse (prompt injection). Trois règles que je me suis fixées après coup :
1. Principe du moindre privilège par outil. N'expose pas run_sql avec un accès en écriture « au cas où ». Expose get_invoices(client_id, year), borné, en lecture. Chaque outil est une capacité accordée au modèle — traite-le comme une route d'API publique.
2. En HTTP, l'auth est non négociable. Un bearer token vérifié dans un middleware avant d'atteindre le serveur MCP, comme pour n'importe quelle API. Un serveur MCP ouvert sur le réseau sans auth, c'est un RCE en self-service.
3. Le contexte est ton fil d'annulation. Si le client se déconnecte, ctx est annulé — propage-le jusqu'à tes appels DB. Un handler MCP qui ignore ctx.Done() est une fuite de goroutine qui attend son heure sous charge.
Les erreurs de design que j'ai faites
Des outils trop granulaires. Ma première version exposait get_client, get_invoice, get_line_item séparément. Le modèle enchaînait cinq appels là où un seul get_client_summary bien pensé suffisait — et chaque appel coûte un aller-retour et des tokens de contexte. Conçois tes outils pour la tâche du modèle, pas pour le schéma de ta base.
Des réponses trop grosses. Renvoyer 2 000 lignes de JSON brut, c'est saturer la fenêtre de contexte et faire payer le modèle (et la facture) pour rien. Résume, pagine, renvoie du Markdown lisible plutôt qu'un dump. Le résultat d'un tool n'est pas une réponse d'API REST : c'est de la matière à raisonnement pour un humain-modèle.
Des descriptions floues. « search » ne dit rien. « Full-text search across invoices, returns up to limit matches sorted by date » dit au modèle quand et comment l'appeler. Tes descriptions sont littéralement le prompt système de ton outil — soigne-les comme du copywriting.
Conclusion
Écrire un serveur MCP en Go, ce n'est pas écrire de l'IA — c'est écrire une API propre, typée, sécurisée, dont le seul client est un modèle. Tout ce que tu sais déjà sur les bons services Go s'applique : types stricts à la frontière, contexte propagé, middlewares, moindre privilège. Le MCP ne fait qu'ajouter une nouvelle catégorie de client, le plus imprévisible de tous.
Et c'est précisément pour ça que Go gagne ici : face à un appelant non déterministe, tu veux le maximum de garanties au moment de la compilation. Python te laisse écrire le serveur plus vite ; Go te laisse dormir tranquille quand le modèle l'appelle d'une façon que tu n'avais pas prévue.