En une phrase
Le MCP (Model Context Protocol) est un protocole ouvert qui décrit comment un assistant IA parle à un outil extérieur : une base, une API, un référentiel, un dossier de fichiers. Il ne sert pas à faire parler deux modèles entre eux, mais à donner au modèle des actions et des données vérifiables, décrites dans un format qu’il comprend.
Publié fin 2024 par Anthropic et depuis adopté par d’autres éditeurs, le MCP est une spécification, pas un produit : il n’impose ni langage, ni bibliothèque, ni hébergeur. Le serveur de ce site, par exemple, tient dans un fichier Node sans aucune dépendance.
Le problème qu’il résout
Un modèle seul ne connaît que ce qu’il a appris. Pour qu’il réponde juste sur vos données, trois approches ont coexisté, et deux vieillissent mal.
| Approche | Comment | Limite |
|---|---|---|
| Tout dans le prompt | On colle les données dans la conversation | Coûteux, périmé dès la minute suivante, plafonné par la fenêtre de contexte |
| Intégration maison | Du code de collage par assistant et par outil | N × M intégrations à maintenir ; changer d’assistant, c’est tout réécrire |
| MCP | L’outil s’expose une fois, tous les clients s’y branchent | N + M : il faut écrire le serveur, mais une seule fois |
Les trois rôles
- L’hôte : l’application que vous utilisez (Claude Desktop, Claude Code, votre propre agent). C’est elle qui décide quels serveurs lancer et qui demande votre consentement.
- Le client : la partie de l’hôte qui parle le protocole. Une connexion par serveur, isolée des autres : un serveur ne voit ni les autres serveurs, ni la conversation entière.
- Le serveur : votre code. Il déclare ce qu’il sait faire et exécute les appels. Il ne « parle » pas au modèle : il répond au client, qui remet la réponse au modèle.
Les deux transports
| stdio | HTTP (streamable / SSE) | |
|---|---|---|
| Où tourne le serveur | Sur votre machine, lancé par l’hôte | Sur un serveur distant |
| Canal | Entrée/sortie standard du processus | Requêtes HTTP + flux d’événements |
| Authentification | Aucune : c’est déjà votre session | Obligatoire (OAuth, jeton…) |
| Données sensibles | Ne quittent jamais la machine | Sortent de votre réseau |
| Multi-utilisateurs | Non : un processus par client | Oui |
| Bon pour | Fichiers locaux, référentiels, outils personnels | SaaS, API d’entreprise, équipes |
console.log de débogage, une bannière de démarrage, un avertissement de dépendance : n’importe quel octet hors JSON casse la session, et le client affiche une erreur de parsing incompréhensible. Tous les logs vont sur stderr.La poignée de main, étape par étape
Tout passe par JSON-RPC 2.0 : un message JSON par ligne. Une requête porte un id et attend une réponse ; une notification n’en a pas et n’attend rien. Voici une session réelle, réduite à l’essentiel.
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},
"clientInfo":{"name":"claude-code","version":"1.0"}}}
← {"jsonrpc":"2.0","id":1,"result":{
"protocolVersion":"2025-06-18",
"capabilities":{"tools":{"listChanged":false}},
"serverInfo":{"name":"paypedia","version":"1.0.0"}}}
→ {"jsonrpc":"2.0","method":"notifications/initialized"} ← pas d'id : AUCUNE réponse→ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
← {"jsonrpc":"2.0","id":2,"result":{"tools":[
{"name":"iban_check","description":"Valide un ou plusieurs IBAN…",
"inputSchema":{"type":"object","properties":{"input":{"type":"string"}},
"required":["input"],"additionalProperties":false}} ]}}
→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{
"name":"iban_check","arguments":{"input":"FR7630006000011234567890189"}}}
← {"jsonrpc":"2.0","id":3,"result":{
"content":[{"type":"text","text":"## FR76 3000 6000 …\n**VALIDE** · France (FR)…"}],
"isError":false}}| Méthode | Type | Rôle |
|---|---|---|
initialize | requête | Négocier la version du protocole et annoncer ses capacités |
notifications/initialized | notification | Le client est prêt. Ne rien renvoyer |
tools/list | requête | Renvoyer le catalogue : nom, description, schéma d’entrée |
tools/call | requête | Exécuter et renvoyer content[] |
ping | requête | Répondre {} (sert au client à vérifier que le process vit) |
| Toute autre méthode | requête | Répondre l’erreur -32601 (méthode inconnue) |
error JSON-RPC. Une erreur d’exécution (IBAN illisible, fichier absent) se renvoie dans le résultat avec isError: true : le modèle la lit et peut se corriger tout seul. Confondre les deux, c’est soit casser la session, soit rendre l’échec invisible.Les quatre primitives
tools. C’est le cas de celui de ce site : 17 outils, aucune ressource, aucun prompt. Ajouter des primitives « au cas où » augmente la surface à maintenir sans rien apporter au modèle.Anatomie d’un outil
Un outil, c’est quatre choses : un nom, une description, un schéma d’entrée et du code. Voici la déclaration réelle d’un des 17 outils de ce site, telle quelle.
{
name: 'bank_resolve', // identifiant stable, snake_case
title: 'Résoudre un code banque', // libellé lisible (facultatif)
description:
"Donne le nom et le BIC d'une banque à partir de son code banque national (celui " +
"embarqué dans l'IBAN) et du pays. Registre de ~22 900 codes dans 44 pays. " +
"À appeler quand on a un code banque (ex 30004 en France) sans savoir de quel " +
"établissement il s'agit.", // ← c'est ELLE qui déclenche l'appel
inputSchema: {
type: 'object',
properties: {
country: { type: 'string', maxLength: 8, description: 'Code pays ISO 3166-1 alpha-2, ex "FR", "DE", "ES".' },
bankCode: { type: 'string', maxLength: 16, description: 'Code banque national, ex "30004".' },
},
required: ['country', 'bankCode'],
additionalProperties: false,
},
run: async (args) => '…le texte remis au modèle…',
}| Champ | Rôle | Erreur classique |
|---|---|---|
name | Identifiant appelé par le client | Le renommer : toutes les configurations existantes cassent |
description | Décide si le modèle appelle l’outil | Décrire ce que fait l’outil sans dire quand l’appeler |
inputSchema | Contrat d’entrée, affiché au modèle | Le déclarer sans jamais le valider : le schéma devient de la documentation |
| Sortie | Du texte, lu par un modèle | Renvoyer du JSON brut là où une phrase serait comprise du premier coup |
Un serveur minimal, sans dépendance
Il existe des SDK officiels, mais ils ne sont pas obligatoires : le protocole est assez simple pour être implémenté à la main. Voici un serveur stdio complet et fonctionnel. C’est la trame de celui de ce site.
const TOOLS = [{
name: 'somme',
description: 'Additionne deux nombres. À appeler pour tout calcul de somme.',
inputSchema: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } },
required: ['a', 'b'], additionalProperties: false },
run: ({ a, b }) => `${a} + ${b} = ${Number(a) + Number(b)}`,
}]
const send = (m) => process.stdout.write(JSON.stringify(m) + '\n') // stdout = protocole
const log = (m) => process.stderr.write(m + '\n') // stderr = logs
let buf = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', (chunk) => {
buf += chunk
let nl
while ((nl = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
if (!line) continue
let msg
try { msg = JSON.parse(line) } catch { send({ jsonrpc: '2.0', id: null,
error: { code: -32700, message: 'JSON invalide' } }); continue }
if (!msg || typeof msg !== 'object') continue // « null » est un JSON valide
const isRequest = msg.id !== undefined && msg.id !== null
if (msg.method === 'initialize') {
send({ jsonrpc: '2.0', id: msg.id, result: {
protocolVersion: msg.params?.protocolVersion ?? '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'exemple', version: '1.0.0' } } })
} else if (msg.method === 'tools/list' && isRequest) {
send({ jsonrpc: '2.0', id: msg.id, result: {
tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) } })
} else if (msg.method === 'tools/call' && isRequest) {
const tool = TOOLS.find((t) => t.name === msg.params?.name)
if (!tool) return send({ jsonrpc: '2.0', id: msg.id,
error: { code: -32602, message: 'Outil inconnu' } })
try {
send({ jsonrpc: '2.0', id: msg.id,
result: { content: [{ type: 'text', text: tool.run(msg.params.arguments ?? {}) }] } })
} catch (e) { // erreur d'EXÉCUTION : dans le résultat
send({ jsonrpc: '2.0', id: msg.id,
result: { content: [{ type: 'text', text: 'Erreur : ' + e.message }], isError: true } })
}
} else if (isRequest) {
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Méthode inconnue' } })
}
// sans id : c'est une notification → on ne répond RIEN
}
})
log('prêt')printf les trames dans le processus et lisez la sortie. C’est ainsi que le serveur de ce site est vérifié par 28 appels réels couvrant ses 17 outils, cas tordus compris : IBAN illisible, BIN trop court, identifiant d’article qui tente de sortir du dossier.Huit pièges (tous rencontrés en vrai)
Cette liste ne vient pas de la spécification mais de la construction du serveur de ce site : chaque ligne est un défaut qui a existé, avec son symptôme et son correctif.
| Symptôme | Cause réelle | Correctif |
|---|---|---|
| Le client n’arrive pas à parler au serveur | Un console.log (ou une bannière de dépendance) sur stdout | Tout log sur stderr, sans exception |
| Le client se plaint d’une réponse inattendue | Le serveur répond à une notification (message sans id) | Ne répondre que si id est présent et non nul |
| Le serveur meurt en cours de session | Une ligne null (JSON valide) passée au destructuring, puis le gestionnaire d’erreur plante à son tour | Vérifier que le message est bien un objet avant tout accès |
| Une réponse déverse des dizaines de milliers de lignes | limit: -1 : les bornes du inputSchema ne sont validées par personne | Borner les arguments numériques dans le code de l’outil |
| Un outil lit un fichier hors de son dossier | Un argument (country) concaténé dans un chemin : "../autre" sort du dossier | Valider le format (/^[A-Z]{2}$/) avant toute lecture |
| Le serveur sert des données périmées après quelques heures | Un cache mémoire jamais invalidé alors qu’un cron réécrit les fichiers | Invalider sur la date de modification du fichier |
| Les horaires renvoyés sont faux de deux heures | toISOString() puis troncature : la conversion en UTC est conservée, le marqueur de fuseau disparaît | Formater en gardant le décalage explicite (16:45 UTC+02:00) |
| Les tests passent, la démo est vide | Les tests appelaient les outils dans un environnement sans accès aux fichiers : chaque outil tombait dans son repli | Brancher l’accès aux données dans les tests et affirmer sur le contenu, pas sur l’absence d’erreur |
Sécurité : ce qu’il faut avoir en tête
- Consentement par action. C’est l’hôte qui demande l’autorisation, mais c’est votre serveur qui décide de sa granularité : un outil « lire » et un outil « écrire » séparés valent mieux qu’un outil « faire ».
- Contenu lu ≠ instruction. Si votre outil renvoie du texte venu d’ailleurs (page web, fichier, ticket), ce texte peut contenir des instructions destinées au modèle. Ne l’exécutez pas, ne le présentez pas comme une consigne.
- Validez les arguments. Le schéma est une documentation pour le modèle, pas un pare-feu : rien ne le vérifie à votre place.
- Ne renvoyez que ce qui est nécessaire. Un outil qui déverse un fichier entier fait fuir plus de données que prévu, et sature le contexte au passage.
- Secrets. En stdio, ils restent sur la machine ; en HTTP, ils traversent le réseau : ce n’est pas la même analyse de risque.
Étude de cas : le serveur de ce site
Le serveur paypedia expose l’expertise de ce site à n’importe quel assistant : 17 outils, zéro dépendance à l’exécution, zéro clé d’API, zéro appel réseau. Tout est calculé en local sur les mêmes référentiels que le site.
| Famille | Outils | Ce qu’ils résolvent |
|---|---|---|
| 🏦 Bancaire (7) | iban_check bin_check bank_resolve bic_search cib_search iban_country_info referentials_info | Validation ISO 13616 / mod-97 / Luhn, résolution d’établissement, annuaire SWIFT |
| 📰 Actualités (4) | news_latest news_search news_get_article jobs_search | Veille paiement publiée, avec ses sources, et les offres d’emploi du secteur |
| ⚖️ Litige (3) | litige_start litige_step litige_search | Parcours chargeback pas à pas, textes de loi, modèles de courrier |
| 🎓 Formation (3) | formation_search formation_get glossary_lookup | Cours, dossiers encyclopédiques, définitions exactes |
Le choix d’architecture qui rend la démo possible
Les outils sont définis une seule fois, dans le code du site, avec une source de données injectée : le serveur lui passe la lecture disque, la page web lui passe fetch. C’est pour ça que la console de démonstration peut afficher le texte exact qu’un client MCP reçoit. Ce n’est pas une maquette, c’est le même code.
Le brancher chez vous
{
"mcpServers": {
"paypedia": {
"command": "node",
"args": ["/path/to/paypedia/mcp/dist/paypedia-mcp.mjs"],
"env": { "PAYPEDIA_ROOT": "/path/to/paypedia" }
}
}
}| Client | Fichier | Portée |
|---|---|---|
| Claude Code | .mcp.json à la racine du projet | Le projet (approbation demandée au premier lancement) |
| Claude Desktop | ~/Library/Application Support/Claude/claude_desktop_config.json | L’application (redémarrage nécessaire) |
| Votre agent | Ce que vous voulez | À vous : lancez le process et parlez-lui en JSON-RPC |
Aller plus loin
- La spécification (
modelcontextprotocol.io) : la référence, versionnée par date (2024-11-05,2025-03-26,2025-06-18). Un serveur poli accepte la version demandée par le client s’il la connaît, sinon annonce la sienne. - Les SDK officiels : Python et TypeScript en tête, plus une demi-douzaine d’autres langages (Java, Kotlin, C#, Go, Ruby…). Utiles pour les serveurs riches (ressources, prompts, sampling) ; superflus pour un serveur d’outils en stdio.
- L’inspecteur, l’outil de test officiel : il ouvre une session, liste vos outils et laisse les appeler à la main. Le réflexe avant de conclure « le client est cassé ».
- Les serveurs de référence (filesystem, git, fetch, mémoire) : à lire comme des exemples de découpage d’outils, pas comme des modèles d’architecture.