
Votre premier serveur MCP exposait des fichiers locaux. Un serveur réel appelle des API externes, ne bloque pas sous la charge, journalise ce qu'il fait et se distribue proprement. Ce guide franchit ce pas avec un cas concret : github-readonly, un serveur MCP qui interroge l'API GitHub en lecture seule. Vous y verrez les tools asynchrones, le piège de la journalisation en transport stdio, la gestion d'erreur et le packaging. Tout le code a été exécuté et validé en lab. Public visé : développeur ayant déjà écrit un serveur MCP simple.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire des tools asynchrones qui appellent une API externe sans bloquer.
- Journaliser côté serveur sans casser le protocole.
- Gérer les erreurs pour qu'elles remontent proprement au client.
- Packager un serveur MCP en paquet installable.
Prérequis
Section intitulée « Prérequis »Ce guide prolonge Créer son premier serveur MCP. Vous avez besoin de Python 3.10+, du SDK mcp, de httpx (déjà installé comme dépendance du SDK) et d'une aise avec async/await.
Un cas réel : interroger une API externe
Section intitulée « Un cas réel : interroger une API externe »Le serveur d'exemple, github-readonly, expose deux tools : repo_info renvoie les informations d'un dépôt GitHub, latest_releases liste ses dernières versions. Rien n'est écrit — c'est un serveur strictement en lecture, ce qui en fait un bon premier cas réel : utile, mais sans risque d'effet de bord.
Le serveur s'appuie sur l'API publique de GitHub. Une fonction interne centralise les appels HTTP — c'est elle qui portera l'asynchrone et la gestion d'erreur.
API = "https://api.github.com"
async def _get(path: str) -> dict | list: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(f"{API}{path}", headers={"Accept": "application/vnd.github+json"}) resp.raise_for_status() return resp.json()Des tools asynchrones
Section intitulée « Des tools asynchrones »Un tool MCP qui fait un appel réseau ne doit pas bloquer. S'il était synchrone, le serveur resterait figé pendant toute la durée de la requête HTTP — incapable de traiter quoi que ce soit d'autre. FastMCP accepte directement les fonctions async def : il suffit de les déclarer asynchrones.
@mcp.tool()async def repo_info(owner: str, name: str) -> dict: """Renvoie les informations clés d'un dépôt GitHub public.""" data = await _get(f"/repos/{owner}/{name}") return { "full_name": data["full_name"], "description": data.get("description"), "stars": data["stargazers_count"], "language": data.get("language"), "open_issues": data["open_issues_count"], }L'appel renvoie un dictionnaire structuré, que FastMCP sérialise pour le client :
{ "full_name": "ggml-org/llama.cpp", "description": "LLM inference in C/C++", "stars": 111665, "language": "C++", "open_issues": 1706}Le décorateur reste @mcp.tool(), identique au cas synchrone. La seule différence est le mot-clé async et le await sur l'appel réseau. Pour un serveur qui interroge des API, tous les tools devraient être asynchrones.
Journaliser côté serveur : le piège stdout
Section intitulée « Journaliser côté serveur : le piège stdout »Voici l'erreur qui piège tout le monde. En transport stdio, le serveur communique avec le client par sa sortie standard (stdout). Cette sortie est réservée aux messages JSON-RPC. Si votre serveur écrit autre chose sur stdout — un print(), un log mal configuré — il injecte du bruit dans le protocole et la session casse.
La règle est absolue : toute journalisation part sur stderr, jamais sur stdout.
import loggingimport sys
logging.basicConfig( level=logging.INFO, stream=sys.stderr, # impératif en stdio format="%(asctime)s [github-readonly] %(levelname)s %(message)s",)log = logging.getLogger("github-readonly")Une fois la configuration en place, journalisez sans crainte : stderr est libre, et l'hôte l'affiche séparément ou le redirige vers un fichier.
async def _get(path: str) -> dict | list: async with httpx.AsyncClient(timeout=10.0) as client: log.info("GET %s", path) ...Gérer les erreurs proprement
Section intitulée « Gérer les erreurs proprement »Un appel d'API échoue : dépôt inexistant, quota dépassé, réseau coupé. Le serveur doit transformer ces échecs en réponses claires, pas en plantage.
La bonne nouvelle : avec FastMCP, il suffit de lever une exception. FastMCP l'attrape et la renvoie au client sous forme de réponse avec isError: true. Le client n'a pas à deviner — il sait que l'appel a échoué.
async def _get(path: str) -> dict | list: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(...) if resp.status_code == 404: raise ValueError(f"Ressource introuvable : {path}") if resp.status_code == 403: raise RuntimeError("Quota API GitHub dépassé (utilisez un token).") resp.raise_for_status() return resp.json()Distinguer les cas a un intérêt pédagogique pour le modèle : un 404 (le dépôt n'existe pas) appelle une autre réaction qu'un 403 (quota dépassé, réessayer plus tard). Côté client, un appel sur un dépôt fantôme renvoie bien isError = True, sans interrompre la session.
Packager le serveur pour le distribuer
Section intitulée « Packager le serveur pour le distribuer »Tant que le serveur est un simple server.py, il n'est ni versionné, ni installable, ni partageable. Le packaging corrige cela. Un fichier pyproject.toml déclare le paquet, ses dépendances et un script console.
-
Déclarer le paquet et ses dépendances.
[project]name = "mcp-github-readonly"version = "0.1.0"description = "Serveur MCP en lecture seule pour l'API GitHub publique"requires-python = ">=3.10"dependencies = ["mcp>=1.20", "httpx>=0.27"] -
Exposer un script console.
La section
[project.scripts]crée une commande qui pointe vers la fonctionmain()du serveur — d'où l'intérêt d'avoir extraitmain()plutôt que de tout laisser dansif __name__.[project.scripts]mcp-github-readonly = "server:main"[build-system]requires = ["hatchling"]build-backend = "hatchling.build" -
Installer et vérifier.
Fenêtre de terminal pip install --only-binary=:all: -e .La commande
mcp-github-readonlyest désormais disponible. Un hôte peut la déclarer directement, sans connaître le chemin du fichier source.
À partir de là, deux voies de distribution s'ouvrent. Publier le paquet sur PyPI le rend installable d'un pip install. L'emballer dans une image OCI (un conteneur) le rend déployable partout — c'est la voie privilégiée pour un serveur en transport HTTP, abordée dans le guide de mise en production.
À retenir
Section intitulée « À retenir »- Un serveur MCP réel appelle des API externes — privilégiez des tools en lecture seule pour un premier cas.
- Les tools qui font de l'I/O réseau doivent être
async defpour ne pas bloquer le serveur. - En stdio,
stdoutest réservé au protocole : journalisez surstderr, jamais deprint(). - Lever une exception dans un tool suffit : FastMCP la transforme en réponse
isError. - Le packaging (
pyproject.toml+ script console) rend le serveur versionné, installable et reproductible. - Distribution : PyPI pour l'installation, image OCI pour le déploiement.