
Un serveur MCP qui « marche sur ma machine » n'est pas un serveur fiable. Dès qu'un agent l'appelle en autonomie, la moindre régression — un tool qui change de signature, une erreur non gérée, un chemin mal validé — se traduit par un comportement faux que personne ne voit passer. Ce guide met en place une stratégie de test à trois niveaux pour le serveur blog-helper construit dans le guide Créer son premier serveur MCP : tests unitaires sur la logique des tools, test d'intégration en mémoire sur le protocole, tests end-to-end sur le serveur réel lancé en sous-processus. Vous verrez aussi le MCP Inspector, l'outil officiel pour déboguer un serveur à la main. Tout le code a été exécuté et validé en lab. Public visé : développeur Python ayant déjà écrit un serveur MCP.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Distinguer les trois niveaux de test d'un serveur MCP et savoir lequel écrire en premier.
- Tester la logique des tools en isolation avec pytest, sans toucher au protocole.
- Faire dialoguer un client et un serveur en mémoire, sans sous-processus.
- Écrire des tests end-to-end qui lancent le vrai serveur en transport stdio.
- Déboguer un serveur interactivement avec le MCP Inspector.
Prérequis
Section intitulée « Prérequis »Ce guide part du serveur blog-helper du guide Créer son premier serveur MCP : trois tools (list_notes, read_note, search_notes), une resource note://, et une fonction _safe_path qui interdit la traversée de chemin. Il vous faut :
- Python 3.10 ou plus récent, et le serveur blog-helper fonctionnel.
- Des bases en pytest et une familiarité avec
async/await. - Node.js — uniquement pour la section MCP Inspector, qui se lance via
npx.
Installez les deux dépendances de test dans le même environnement virtuel que le serveur :
./.venv/bin/pip install --only-binary=:all: pytest==9.0.3 pytest-asyncio==1.3.0pytest-asyncio est nécessaire parce que les deux niveaux supérieurs de test sont asynchrones : le protocole MCP est intégralement asynchrone.
Trois niveaux de test, du plus rapide au plus complet
Section intitulée « Trois niveaux de test, du plus rapide au plus complet »Tester un serveur MCP, ce n'est pas une seule chose. Un serveur a deux faces : d'un côté de la logique métier — lire un fichier, filtrer une liste — et de l'autre une interface protocolaire — la négociation MCP, la découverte des tools, le format des réponses. Les deux peuvent casser indépendamment, et on ne les teste pas de la même manière.
D'où une pyramide à trois étages. Chaque niveau teste une surface différente et coûte plus cher que le précédent en temps d'exécution :
| Niveau | Ce qu'il vérifie | Vitesse | Mécanisme |
|---|---|---|---|
| Unitaire | La logique des tools, fonctions appelées directement | Quasi instantané | pytest seul |
| Intégration en mémoire | Le protocole MCP, client ↔ serveur, même processus | Rapide | helper in-memory du SDK |
| End-to-end | Le serveur réel en sous-processus, transport stdio | Plus lent | stdio_client |
La règle est la même que pour n'importe quelle pyramide de tests : beaucoup d'unitaires, quelques tests d'intégration, une poignée de end-to-end. Les premiers attrapent les bugs de logique en quelques millisecondes ; les derniers attrapent les bugs d'intégration — un décorateur oublié, un transport mal configuré — que les unitaires ne peuvent pas voir.
Le MCP Inspector : déboguer à la main
Section intitulée « Le MCP Inspector : déboguer à la main »Avant d'automatiser, il faut voir. Le MCP Inspector est l'outil officiel du projet MCP : une interface web qui se connecte à votre serveur, liste ses capacités et vous laisse appeler les tools à la main. C'est l'équivalent de Postman pour une API REST.
Il se lance sans installation, via npx :
npx @modelcontextprotocol/inspector ./.venv/bin/python server.pyLa commande démarre votre serveur en transport stdio et ouvre une interface web locale dans le navigateur. Vous y retrouvez trois onglets correspondant aux trois primitives du protocole — Tools, Resources, Prompts. Dans l'onglet Tools, chaque tool apparaît avec son schéma d'arguments généré à partir de la signature Python : un formulaire vous laisse saisir les paramètres, lancer l'appel et lire le résultat brut.
L'intérêt de l'Inspector dépasse le simple essai. Un panneau dédié affiche les messages JSON-RPC échangés — la requête tools/call, la réponse, les notifications. C'est l'endroit où l'on comprend réellement ce que le client et le serveur se disent, et donc où l'on diagnostique un schéma d'argument mal formé ou une réponse au format inattendu.
Niveau 1 — tester les tools en isolation
Section intitulée « Niveau 1 — tester les tools en isolation »Le premier niveau ignore complètement MCP. Un tool comme search_notes est, avant tout, une fonction Python normale. FastMCP enregistre la fonction décorée mais la laisse appelable directement : on peut donc la tester sans protocole, sans client, sans rien.
Le seul piège est le dossier de notes. Le serveur lit un répertoire notes/ réel ; un test ne doit jamais dépendre de son contenu du moment. La parade est une fixture qui crée un dossier temporaire au contenu connu et le substitue à la variable NOTES_DIR du module, le temps du test. monkeypatch annule la substitution automatiquement à la fin de chaque test.
Voici l'en-tête du fichier test_blog_helper.py et la fixture :
import sysfrom contextlib import asynccontextmanagerfrom pathlib import Path
import pytestfrom mcp import ClientSession, StdioServerParametersfrom mcp.client.stdio import stdio_clientfrom mcp.shared.memory import create_connected_server_and_client_session
import server
SERVER = str(Path(__file__).with_name("server.py"))
def texts(result) -> list[str]: """Extrait les blocs texte d'une réponse de tool (qui en contient plusieurs).""" return [block.text for block in result.content if hasattr(block, "text")]
@pytest.fixturedef notes(tmp_path, monkeypatch): """Un dossier notes/ isolé, au contenu connu, pour des assertions stables.""" repertoire = tmp_path / "notes" repertoire.mkdir() (repertoire / "alpha.md").write_text( "# Alpha\nUn terme rare : xylophone.\n", encoding="utf-8" ) (repertoire / "beta.md").write_text( "# Beta\nRien de notable ici.\n", encoding="utf-8" ) monkeypatch.setattr(server, "NOTES_DIR", repertoire) return repertoireLes tests unitaires deviennent alors de simples appels de fonction. Chacun vérifie un comportement précis, y compris les cas d'erreur — un fichier absent doit lever une exception, et la traversée de chemin doit être refusée :
def test_list_notes_renvoie_les_fichiers_tries(notes): assert server.list_notes() == ["alpha.md", "beta.md"]
def test_read_note_renvoie_le_contenu(notes): assert "xylophone" in server.read_note("alpha.md")
def test_read_note_leve_une_erreur_si_absent(notes): with pytest.raises(FileNotFoundError): server.read_note("fantome.md")
def test_search_notes_trouve_le_terme(notes): assert server.search_notes("xylophone") == ["alpha.md"]
def test_search_notes_est_insensible_a_la_casse(notes): assert server.search_notes("XYLOPHONE") == ["alpha.md"]
def test_safe_path_bloque_la_traversee_de_chemin(notes): with pytest.raises(ValueError): server._safe_path("../server.py")Le dernier test mérite une mention. La traversée de chemin est la principale faille d'un serveur qui expose un système de fichiers — c'est l'attaque détaillée dans le guide Sécurité MCP. Un test qui exige que _safe_path("../server.py") lève ValueError transforme cette protection en garantie vérifiée à chaque commit : si quelqu'un affaiblit la validation, le test rougit immédiatement.
Niveau 2 — tester via le protocole, en mémoire
Section intitulée « Niveau 2 — tester via le protocole, en mémoire »Les tests unitaires ne voient pas MCP. Or une partie des bugs vit précisément là : un décorateur @mcp.tool() oublié, un type de retour que le protocole ne sait pas sérialiser, un schéma d'argument incohérent. Pour les attraper, il faut faire dialoguer un vrai client et un vrai serveur.
On pourrait simuler le protocole avec des mocks, mais ce serait fragile et peu instructif : on testerait sa propre imitation de JSON-RPC, pas le serveur. Le SDK mcp offre bien mieux — un transport en mémoire. La fonction create_connected_server_and_client_session relie un client et un serveur dans le même processus, via le vrai protocole, sans tuyau ni sous-processus. C'est rapide comme un test unitaire, mais ça exerce toute la chaîne MCP.
async def test_integration_en_memoire(): """Le client parle au serveur via le protocole MCP, sans sous-processus.
create_connected_server_and_client_session appelle initialize() lui-même : la session est prête dès l'entrée du bloc. """ async with create_connected_server_and_client_session(server.mcp) as session: tools = await session.list_tools() assert {t.name for t in tools.tools} == { "list_notes", "read_note", "search_notes", }
result = await session.call_tool("search_notes", {"query": "primitives"}) assert texts(result) == ["mcp.md"]Deux choses se vérifient ici. D'abord la découverte : list_tools doit renvoyer exactement les trois tools attendus — ni plus, ni moins. Un tool en trop signale un décorateur égaré ; un tool manquant, un décorateur oublié. Ensuite l'invocation : call_tool traverse la sérialisation, l'exécution et la désérialisation de la réponse. Le helper texts() est nécessaire parce qu'une réponse de tool est une liste de blocs de contenu, jamais une chaîne unique — un piège déjà rencontré dans le guide Construire un client MCP.
Niveau 3 — tester de bout en bout en stdio
Section intitulée « Niveau 3 — tester de bout en bout en stdio »Le test en mémoire valide le protocole, mais court-circuite une étape : le lancement réel du serveur. En production, un hôte démarre le serveur comme un sous-processus et lui parle en transport stdio. Des bugs ne se révèlent qu'à ce moment-là : un mauvais point d'entrée, une dépendance absente du venv, une écriture parasite sur stdout qui corrompt le canal JSON-RPC.
Le niveau end-to-end reproduit donc exactement le geste d'un hôte. Comme plusieurs tests ont besoin d'une session, on isole l'ouverture du serveur dans un gestionnaire de contexte asynchrone :
@asynccontextmanagerasync def stdio_session(): """Lance server.py en sous-processus et ouvre une session client stdio.""" params = StdioServerParameters(command=sys.executable, args=[SERVER]) async with stdio_client(params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() yield sessionChaque test obtient alors un serveur frais. Trois cas couvrent l'essentiel — la découverte des tools, un appel réel sur le vrai dossier notes/, et surtout le comportement en erreur :
async def test_e2e_decouverte_des_tools(): async with stdio_session() as session: tools = await session.list_tools() assert {t.name for t in tools.tools} == { "list_notes", "read_note", "search_notes", }
async def test_e2e_appel_reel_dun_tool(): async with stdio_session() as session: result = await session.call_tool("list_notes", {}) assert "mcp.md" in texts(result)
async def test_e2e_une_erreur_serveur_remonte_au_client(): """Une exception côté tool ne fait pas planter le serveur : elle revient au client sous la forme d'un résultat marqué isError.""" async with stdio_session() as session: result = await session.call_tool("read_note", {"name": "fantome.md"}) assert result.isErrorLe troisième test capture une propriété essentielle de MCP. Au niveau unitaire, read_note("fantome.md") lève une FileNotFoundError — l'exception se propage. À travers le protocole, c'est différent : le serveur ne plante pas. FastMCP intercepte l'exception et la renvoie au client comme un résultat ordinaire, simplement marqué isError. Un agent reçoit donc l'échec comme une donnée exploitable, pas comme une connexion coupée. Vérifier cette remontée, c'est s'assurer qu'un tool qui échoue dégrade le service sans l'interrompre.
Lancer la suite complète
Section intitulée « Lancer la suite complète »Les niveaux 2 et 3 sont asynchrones. Pour que pytest les exécute sans décorateur sur chaque fonction, un fichier pyproject.toml active le mode automatique de pytest-asyncio :
[tool.pytest.ini_options]asyncio_mode = "auto"La suite se lance alors d'une commande :
./.venv/bin/pytest -vLes dix tests — six unitaires, un d'intégration, trois end-to-end — s'exécutent en quelques secondes :
collected 10 items
test_blog_helper.py::test_list_notes_renvoie_les_fichiers_tries PASSED [ 10%]test_blog_helper.py::test_read_note_renvoie_le_contenu PASSED [ 20%]test_blog_helper.py::test_read_note_leve_une_erreur_si_absent PASSED [ 30%]test_blog_helper.py::test_search_notes_trouve_le_terme PASSED [ 40%]test_blog_helper.py::test_search_notes_est_insensible_a_la_casse PASSED [ 50%]test_blog_helper.py::test_safe_path_bloque_la_traversee_de_chemin PASSED [ 60%]test_blog_helper.py::test_integration_en_memoire PASSED [ 70%]test_blog_helper.py::test_e2e_decouverte_des_tools PASSED [ 80%]test_blog_helper.py::test_e2e_appel_reel_dun_tool PASSED [ 90%]test_blog_helper.py::test_e2e_une_erreur_serveur_remonte_au_client PASSED [100%]
============================== 10 passed in 2.93s ==============================Cette suite est aussi un filet d'intégration continue. Branchée dans un pipeline — sur chaque PR — elle interdit qu'une modification casse silencieusement un tool, un schéma ou la validation de sécurité. C'est le passage du serveur « jouet » au composant maintenable.
Déboguer quand un test échoue
Section intitulée « Déboguer quand un test échoue »Un test qui rougit pose toujours la même question : quelle couche ? La pyramide y répond. Si un test unitaire échoue, le bug est dans la logique du tool. Si les unitaires passent mais que l'intégration échoue, le problème est protocolaire — décorateur, type de retour, schéma. Si l'intégration passe mais que le end-to-end échoue, le serveur ne démarre pas correctement.
Pour ce dernier cas, le réflexe le plus rentable est de lancer le serveur seul :
./.venv/bin/python server.pySi le serveur plante au démarrage, la traceback s'affiche directement — alors qu'à travers un client, la même panne se manifeste par un blocage opaque sur initialize(). Une cause fréquente est une écriture sur stdout : en transport stdio, stdout est le canal JSON-RPC, et un simple print() de débogage le corrompt. Toute journalisation doit partir sur stderr, comme l'explique le guide Serveur MCP avancé.
Trois autres leviers complètent le diagnostic. L'option pytest -s laisse passer les print et les logs du serveur pendant les tests, normalement capturés. Le MCP Inspector rejoue l'appel fautif à la main et montre le JSON-RPC brut. Enfin, sur une erreur de tool, il faut lire le contenu du résultat, pas seulement le drapeau : texts(result) révèle le message d'erreur que le serveur a renvoyé.
Dépannage
Section intitulée « Dépannage »| Symptôme | Cause probable | Solution |
|---|---|---|
ModuleNotFoundError: server | pytest lancé hors du dossier du serveur | Lancer pytest depuis le dossier contenant server.py |
| Les tests async sont skipped | asyncio_mode non configuré | Ajouter asyncio_mode = "auto" dans pyproject.toml |
| Test end-to-end bloqué | Le serveur plante au démarrage | Lancer python server.py seul pour voir la traceback |
initialize() ne répond jamais | Écriture parasite sur stdout du serveur | Rediriger toute journalisation vers stderr |
list_tools renvoie une liste vide | Décorateur @mcp.tool() absent | Vérifier que chaque tool est bien décoré |
| L'Inspector ne se lance pas | Node.js absent | Installer Node.js, puis relancer la commande npx |
À retenir
Section intitulée « À retenir »- Un serveur MCP se teste sur trois niveaux : logique des tools, protocole, lancement réel.
- Les tests unitaires appellent les tools comme des fonctions Python ; une fixture isole le dossier de données.
- Le helper
create_connected_server_and_client_sessionrelie client et serveur en mémoire — le vrai protocole, sans sous-processus, sans mocks. - Les tests end-to-end lancent le serveur en sous-processus stdio, exactement comme un hôte.
- Une exception de tool revient au client en résultat
isError— le serveur ne plante pas : il faut le vérifier. - Le MCP Inspector débogue à la main et montre le JSON-RPC brut ; il prépare les tests, il ne les remplace pas.
- En transport stdio, toute journalisation va sur
stderr—stdoutest le canal du protocole.
Prochaines étapes
Section intitulée « Prochaines étapes »Pour aller plus loin
Section intitulée « Pour aller plus loin »- MCP Inspector — le dépôt officiel de l'outil de débogage.
- Documentation du SDK Python MCP — référence de
ClientSession, des transports et des helpers de test. - pytest-asyncio — la documentation du plugin pour les tests asynchrones.