MCP : Model Context Protocol, pas « Monstre Chamanique Pittoresque »
La plupart des gens branchent un serveur MCP de la même façon : copier un bloc JSON depuis un README, le coller dans un fichier de config, redémarrer le client. Ça marche, et on passe à autre chose. Le bloc en question fait cinq lignes et contient au moins quatre décisions qu'on vient de prendre sans les voir.
Cet article, c'est la lecture ligne par ligne de ce bloc, avec en fil rouge les deux serveurs MCP que j'ai écrits et publiés, et un troisième que je m'apprête à brancher.
Ce que MCP résout, en une phrase
MCP (Model Context Protocol) est un contrat d'interface entre un agent (Claude Code, Claude Desktop, Cursor) et un outil externe. Avant, chaque agent réimplémentait ses intégrations dans son coin. Depuis, on écrit un serveur une fois, et n'importe quel client qui parle MCP sait s'en servir.
Le serveur expose des tools : des fonctions avec un nom, une description, un schéma d'entrée. L'agent lit les descriptions, décide lesquelles appeler, récupère le résultat dans son contexte. C'est une API, mais dont le consommateur est un modèle de langage et pas un humain qui lit une doc.
Le nom trompe, alors autant le dire tout de suite : le serveur ne contient aucun modèle. C'est du code ordinaire, au même titre qu'une API REST. Toute l'intelligence est côté client, c'est-à-dire côté agent. Un serveur MCP tout seul, sans agent branché dessus, ne fait rien.
Mes deux serveurs : l'un expose une mémoire personnelle (six tools, search, add, list_facts, delete...), l'autre une base de décisions d'équipe (search_decisions, get_decision...). Même intérêt dans les deux cas : rendre consultable, par l'agent, quelque chose qui vivait dans une CLI que moi seul savais lancer.
Anatomie de la config
Le bloc type ressemble à ça :
{
"mcpServers": {
"memory": {
"type": "stdio",
"command": "/Users/moi/perso/.venv/bin/mmcp",
"args": ["serve"],
"env": { "OLLAMA_HOST": "http://localhost:11434" }
}
}
}
command, c'est l'exécutable à lancer. Piège numéro un : si le serveur est installé dans un venv ou via uv, le binaire n'est pas dans le PATH du client. mmcp tout court ne suffit pas, il faut le chemin absolu (.venv/bin/mmcp) ou passer par uv run --project. J'ai perdu une soirée là-dessus.
args, ce sont les arguments passés à l'exécutable. Ici serve, parce que la CLI a une sous-commande dédiée qui, au lieu d'afficher quelque chose, ouvre le canal MCP.
env, ce sont les variables d'environnement du process. C'est là que vont les secrets (clé d'API, token) et les URLs de services locaux. Ce qui est dans env n'atterrit pas dans l'historique du shell, c'est un des rares bons côtés.
type, c'est le transport. Le paramètre le plus structurant, et celui qu'on recopie sans réfléchir.
Le transport : stdio ou Streamable HTTP
MCP a deux transports. stdio : le client lance le serveur comme un sous-process et lui parle via l'entrée/sortie standard. Tout est local, rien n'écoute sur le réseau, aucune authentification à gérer. Streamable HTTP (qui a remplacé l'ancien HTTP+SSE en mars 2025) : le serveur tourne ailleurs, le client l'appelle en HTTP POST, avec un token bearer ou OAuth pour l'authentification.
stdio : local, le défaut agent ──lance le process──► serveur agent ◄──stdin / stdout───► serveur rien sur le réseau, aucune auth Streamable HTTP : serveur distant ou partagé agent ──HTTP POST──► https://hôte/mcp agent ◄──flux SSE / JSON── serveur token bearer ou OAuth
Règle simple : si le serveur et l'agent tournent sur la même machine, c'est stdio. Le HTTP ne se justifie que si le serveur est partagé, distant ou managé. Mes quatre serveurs MCP sont tous en stdio. Aucun n'a jamais eu besoin d'autre chose.
Un point qui embrouille : un serveur stdio peut très bien faire des requêtes HTTP vers l'extérieur. Ma mémoire perso appelle Ollama en local, un autre serveur appelle une API distante. Ça reste du transport stdio. Le transport MCP, c'est le lien entre l'agent et le serveur, pas ce que le serveur fait de son côté.
Quand ça ne démarre pas
Un serveur stdio qui ne remonte pas, c'est le cas où on perd sa soirée, et le client ne dit presque rien : le tool n'apparaît pas, point. Le premier geste, toujours : sortir du client et lancer la commande à la main dans un shell, exactement comme elle est écrite dans la config, command suivi de ses args. Là, le vrai message tombe : binaire introuvable, import manquant, crash au démarrage, mauvaise version de Python. C'est ce que le client avale en silence.
Ensuite, claude mcp list donne l'état de chaque serveur déclaré, et claude mcp get <nom> la config telle que le client l'a résolue (utile quand on croit éditer le bon fichier et que non). Les logs du client gardent le stderr du sous-process, un fichier par serveur (sur macOS, ~/Library/Caches/claude-cli-nodejs/<projet>/mcp-logs-<nom>/) : c'est là que se lit un serveur qui a bien démarré puis planté au premier appel.
Le scope : où vit la config, et laquelle est lue
Dans Claude Code, un serveur se déclare à trois portées : local (cette machine, ce projet, non partagé), project (un fichier .mcp.json versionné à la racine du repo, partagé avec l'équipe), user (toutes vos sessions, tous projets).
Le choix n'est pas anodin, parce que chaque serveur chargé coûte du contexte : ses définitions de tools sont injectées dans le prompt à chaque session. Un serveur utile partout, ma mémoire perso, va en user. Un serveur qui ne sert qu'à un projet va dans le .mcp.json de ce projet. C'est ce que j'ai fait pour un outil d'extraction de décisions sur un de mes repos : le serveur suit le code et ne pollue pas les autres sessions.
Piège vécu : mon serveur de mémoire a une commande setup qui écrivait dans ~/.claude/mcp.json. Sauf que le CLI Claude Code ne lit pas ce fichier, il lit ~/.claude.json. Résultat, une config valide, et parfaitement ignorée.
Trois angles qu'on découvre à l'usage
Le coût en tokens. Chaque tool appelé renvoie un résultat qui atterrit dans le contexte. list_facts sur ma mémoire, sans filtre, c'est environ 70 tokens par fait, soit 12 000 tokens pour 176 faits d'un coup. La parade : préférer un search ciblé à un list complet, tronquer les résultats côté serveur. Et pour les appels en boucle, changer de modèle : un import qui parcourt 500 conversations, chacune passée à un tool d'extraction, tourne très bien avec un petit modèle rapide. La qualité suffit pour du tri, et la note est divisée par cinq ou dix. Un serveur MCP mal cadré, c'est un SELECT * qui rentre dans le prompt.
La sécurité. Un serveur MCP, c'est du code tiers avec un accès : à vos fichiers, à une API, à une base. Je passe les miens à un scanner statique avant chaque publication. Le score brut fait peur (un serveur local a structurellement un rayon d'impact au plafond), mais ce qui compte, c'est l'exploitabilité réelle et les findings concrets. Sur mon serveur de mémoire, le scanner a signalé delete comme destructif. La réponse n'a pas été de le retirer, mais de garder un garde-fou déjà en place : un confirm_id qui doit correspondre à l'id visé pour que la suppression parte. Depuis mars 2025, le protocole a d'ailleurs des annotations pour ça : un tool peut se déclarer en lecture seule ou destructif, et le client adapte son comportement.
Le versionnement du SDK. Le SDK Python mcp est passé de la 1.x à la 2.0 avec un changement cassant (FastMCP remplacé par MCPServer). Un de mes serveurs déclarait sa dépendance sans plafond de version : une install fraîche depuis PyPI tirait la 2.0 alors que le code était encore en 1.x. L'autre épingle mcp~=1.0 volontairement, le temps que la migration en vaille la peine. Épinglez la version de votre SDK MCP comme n'importe quelle dépendance qui peut casser.
Quand écrire son propre serveur
Ma règle : une CLI stable, un usage répété, et le fait que l'agent n'y a pas accès aujourd'hui. Si les trois sont réunis, wrapper la CLI en serveur MCP coûte presque rien. Si la CLI bouge encore toutes les semaines, on attend : on maintiendrait deux interfaces instables au lieu d'une.
Une fois qu'on écrit le serveur, le levier n'est pas le code, c'est la description des tools. L'agent ne voit jamais votre implémentation : il a le nom, la description, le schéma d'entrée, et il décide d'appeler ou pas à partir de ça seul. Un tool nommé search décrit par « cherche des trucs » sera ignoré, ou appelé à contretemps avec n'importe quels arguments ; le même renommé search_decisions et décrit par « cherche par mots-clés dans les décisions d'architecture de l'équipe, renvoie titre, contexte et date » sera appelé au bon moment. Les annotations du protocole (lecture seule, destructif) servent la même idée : donner à l'agent de quoi choisir juste. C'est la partie qu'on bâcle et qui décide de tout.
Cas pratique : brancher mon agrégateur de veille
J'ai un agrégateur de veille perso, kiosque : environ 110 flux RSS techno FR et EN, agrégés par Miniflux (un lecteur de flux auto-hébergé) et réaffichés dans une interface de lecture unique. Il tourne sur un VPS. Je voudrais que Claude Code puisse fouiller ce firehose (le flot continu de tout ce qui tombe, sans tri) quand j'écris un article, pour repérer ce qui sort en ce moment sur le sujet que je traite.
kiosque a déjà une architecture en deux couches : seules ses routes serveur parlent à Miniflux, le token reste caché derrière ; le front ne consomme que la route /api/entries du site. Le serveur MCP devient un client de cette même route, un de plus. Aucune modification de kiosque, aucun secret à sortir, parce que /api/entries est déjà publique. C'est l'illustration de « MCP = client d'une API, pensé pour l'agent ».
Il expose deux tools, chercher_actus(q, categorie, langue, depuis) et categories(), en lecture seule, sans écriture ni accès disque. La route ne filtre ni par date ni par langue (kiosque le fait côté navigateur), donc le serveur MCP calcule depuis et langue lui-même, sur les champs published_at et lang déjà renvoyés.
Reste la config, valeur par valeur :
{
"mcpServers": {
"kiosque": {
"type": "stdio",
"command": "/Users/moi/.../.venv/bin/python",
"args": ["-m", "kiosque_mcp"],
"env": { "KIOSQUE_BASE_URL": "https://kiosque.interstice.work" }
}
}
}
type: stdio: le serveur tourne sur ma machine, à côté de Claude Code, rien à exposer. J'aurais misStreamable HTTPsi l'agent n'était pas sur la même machine : plusieurs personnes interrogeant le même kiosque depuis leurs postes, ou un pilotage depuis un téléphone. Le serveur tournerait alors en permanence sur le VPS, derrière une URL et un token.commanden chemin absolu, parce que le binaire est hors PATH.envsans secret : juste l'URL de base, pour pouvoir viserhttp://localhost:4322quand je développe kiosque en local. Une vraie clé irait là, jamais dansargsoù elle traînerait en clair dans la liste des process.- Scope
project, dans le.mcp.jsondu repo du blog : il ne sert qu'à écrire des articles. Enuser, il serait chargé dans toutes mes sessions de code pour rien ; enlocal, je le perdrais en changeant de machine.
Le coût revient là aussi : un firehose de 110 flux noie le contexte en un appel si le tool renvoie tout. Le tool refuse donc un appel sans mot-clé (q) ni borne de date (depuis) : impossible de demander « tout », il faut cibler un sujet ou une fenêtre de temps. Par-dessus, une quinzaine de résultats par défaut, résumés tronqués et nettoyés de leur HTML côté serveur.
J'ai écarté une autre voie : brancher l'agent directement sur Miniflux, qui a sa propre API. Ça court-circuite tout ce que kiosque fait par-dessus (résolution de la langue d'un flux, catégories dérivées d'un fichier de référence, troncature des résumés) et ça ressort le token Miniflux de derrière la couche serveur où il est bien rangé. Le wrapper sur /api/entries réutilise ce travail au lieu de le refaire.
Le bloc de config fait toujours cinq lignes. La différence, c'est qu'on sait maintenant ce que chacune engage.