Aller au contenu
Développement medium

Anatomie du protocole MCP : JSON-RPC et cycle de vie

10 min de lecture

logo python

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.

  • 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.

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.

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.

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.

  1. 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.

  2. Confirmation. Le client envoie la notification notifications/initialized. Elle clôt le handshake : à partir de là, la session est ouverte.

  3. 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é.

  4. Invocation. Le client appelle ce dont il a besoin : tools/call pour exécuter un outil, resources/read pour lire une ressource. C'est la phase utile, qui se répète autant que nécessaire.

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.

Voici les messages effectivement capturés sur le serveur blog-helper. Suivons-les dans l'ordre.

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

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

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

Si vous ne deviez retenir qu'une chose de ce protocole, ce serait celle-ci. Le champ id change tout.

RequêteNotification
Champ idprésentabsent
Réponse attendueouinon
Exemples MCPinitialize, tools/list, tools/callnotifications/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 ?

  • MCP repose sur JSON-RPC 2.0 : des objets JSON d'une ligne, de trois types.
  • Une requête porte un id et attend une réponse au même id ; une notification n'a pas d'id et n'attend rien.
  • Le cycle de vie est strict : initializenotifications/initialized → découverte → invocation.
  • L'initialize est une négociation : client et serveur s'accordent sur la version et les capacités.
  • Une réponse de tools/call porte content, structuredContent et isError.
  • Un proxy de capture rend le protocole visible — l'outil de debug numéro un d'un serveur MCP.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn