
Vous savez utiliser un serveur MCP. Ce guide regarde sous le capot : qu'est-ce qui circule réellement entre un client et un serveur ? La réponse tient en deux notions — le format JSON-RPC 2.0 et le cycle de vie d'une session. Plutôt que de les décrire en théorie, vous allez capturer les vrais messages d'un échange MCP avec un petit proxy, puis les décortiquer un par un. Comprendre ce protocole, c'est pouvoir déboguer un serveur qui ne répond pas et anticiper ce qu'un hôte attend. Public visé : développeur ayant déjà manipulé un serveur MCP.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Lire un message JSON-RPC 2.0 : requête, réponse, notification.
- Comprendre le cycle de vie d'une session MCP, étape par étape.
- Capturer les messages réels échangés entre un client et un serveur.
- Distinguer une requête (qui attend une réponse) d'une notification.
- Repérer où un échange MCP échoue quand quelque chose ne va pas.
Prérequis
Section intitulée « Prérequis »Ce guide prolonge Comprendre le MCP et réutilise le serveur du guide Créer son premier serveur MCP. Vous avez besoin de Python 3.10+ et du serveur blog-helper déjà en place, avec son environnement virtuel.
JSON-RPC 2.0 : le format des messages
Section intitulée « JSON-RPC 2.0 : le format des messages »MCP ne réinvente pas la communication : il s'appuie sur JSON-RPC 2.0, un standard simple et ancien. Chaque message est un objet JSON tenant sur une ligne. Il en existe trois types, et c'est tout.
Une requête demande une action et attend une réponse. Elle porte un id unique, un method (l'action voulue) et des params.
{"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}Une réponse répond à une requête. Elle reprend le même id et contient soit un result, soit un error.
{"jsonrpc": "2.0", "id": 1, "result": { "tools": [ ... ] }}Une notification est une requête sans id : elle signale un événement et n'attend rien en retour. Aucune réponse ne lui est renvoyée.
{"jsonrpc": "2.0", "method": "notifications/initialized"}C'est tout le vocabulaire du protocole. La présence ou l'absence du champ id distingue une requête d'une notification — retenez ce détail, il explique beaucoup de comportements.
Le cycle de vie d'une session MCP
Section intitulée « Le cycle de vie d'une session MCP »Une session MCP suit toujours le même enchaînement. Le respecter n'est pas optionnel : appeler un outil avant l'initialisation provoque une erreur.
-
Initialisation. Le client envoie une requête
initialize: il annonce la version du protocole qu'il parle et ses capacités. Le serveur répond en annonçant les siennes. C'est une négociation : chacun sait désormais ce que l'autre sait faire. -
Confirmation. Le client envoie la notification
notifications/initialized. Elle clôt le handshake : à partir de là, la session est ouverte. -
Découverte. Le client demande ce que le serveur propose :
tools/list,resources/list,prompts/list. Le serveur renvoie la liste, avec le schéma de chaque capacité. -
Invocation. Le client appelle ce dont il a besoin :
tools/callpour exécuter un outil,resources/readpour lire une ressource. C'est la phase utile, qui se répète autant que nécessaire.
Observer le protocole en pratique
Section intitulée « Observer le protocole en pratique »La théorie suffit rarement. Pour voir les messages, on intercale un proxy entre le client et le serveur. Le client lance le proxy ; le proxy lance le vrai serveur et journalise tout ce qui passe.
Le cœur du proxy tient en une fonction qui relaie chaque ligne en la copiant dans un journal :
def pump(src, dst, direction: str) -> None: """Relaie ligne par ligne en journalisant chaque message.""" for line in src: journal(direction, line) # copie dans jsonrpc.log dst.write(line) dst.flush()Deux fils d'exécution suffisent : un pour le sens client → serveur, un pour serveur → client. Le client MCP ne lance plus server.py directement, mais proxy_logger.py — qui se charge de démarrer le vrai serveur derrière lui. Un script de capture déclenche ensuite un échange complet :
async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() await session.list_tools() await session.call_tool("list_notes", {})Le fichier jsonrpc.log contient alors la transcription brute de la session.
Décortiquer un échange réel
Section intitulée « Décortiquer un échange réel »Voici les messages effectivement capturés sur le serveur blog-helper. Suivons-les dans l'ordre.
L'initialisation
Section intitulée « L'initialisation »Le client ouvre la session. Il annonce parler la version 2025-11-25 du protocole.
{"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "mcp", "version": "0.1.0"}}}Le serveur répond — même id 0 — en déclarant ce qu'il sait faire : il expose des tools, des resources et des prompts.
{"jsonrpc": "2.0", "id": 0, "result": {"protocolVersion": "2025-11-25", "capabilities": {"tools": {"listChanged": false}, "resources": {"subscribe": false, "listChanged": false}, "prompts": {"listChanged": false}}, "serverInfo": {"name": "blog-helper", "version": "1.27.1"}}}Le client clôt le handshake par une notification — remarquez l'absence de id :
{"jsonrpc": "2.0", "method": "notifications/initialized"}La découverte
Section intitulée « La découverte »Le client demande la liste des outils. La requête est minimale :
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}La réponse décrit chaque outil avec son schéma d'entrée (inputSchema) et son schéma de sortie (outputSchema). C'est ce schéma — et la description issue de votre docstring — que le modèle utilise pour décider d'un appel.
{"jsonrpc": "2.0", "id": 1, "result": {"tools": [ {"name": "read_note", "description": "Lit le contenu d'une note Markdown par son nom de fichier.", "inputSchema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}}]}}L'invocation
Section intitulée « L'invocation »Le client appelle un outil via tools/call, en passant le name de l'outil et ses arguments.
{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "list_notes", "arguments": {}}}La réponse contient le résultat sous deux formes : content (des blocs lisibles, ici un par note) et structuredContent (le résultat typé). Le champ isError signale si l'appel a échoué.
{"jsonrpc": "2.0", "id": 2, "result": {"content": [{"type": "text", "text": "kubernetes.md"}, {"type": "text", "text": "mcp.md"}, {"type": "text", "text": "quantification.md"}], "structuredContent": {"result": ["kubernetes.md", "mcp.md", "quantification.md"]}, "isError": false}}Requête ou notification : la distinction clé
Section intitulée « Requête ou notification : la distinction clé »Si vous ne deviez retenir qu'une chose de ce protocole, ce serait celle-ci. Le champ id change tout.
| Requête | Notification | |
|---|---|---|
Champ id | présent | absent |
| Réponse attendue | oui | non |
| Exemples MCP | initialize, tools/list, tools/call | notifications/initialized, notifications/cancelled |
Cette distinction explique des comportements courants. Un client qui attend une réponse à une notification se bloquera indéfiniment. Un serveur qui répond à une notification viole le protocole. Et quand un échange se fige, la première question à se poser est : ce message attendait-il une réponse, et l'a-t-il reçue ?
À retenir
Section intitulée « À retenir »- MCP repose sur JSON-RPC 2.0 : des objets JSON d'une ligne, de trois types.
- Une requête porte un
idet attend une réponse au mêmeid; une notification n'a pas d'idet n'attend rien. - Le cycle de vie est strict :
initialize→notifications/initialized→ découverte → invocation. - L'
initializeest une négociation : client et serveur s'accordent sur la version et les capacités. - Une réponse de
tools/callportecontent,structuredContentetisError. - Un proxy de capture rend le protocole visible — l'outil de debug numéro un d'un serveur MCP.