Aller au contenu
Développement medium

Serveur MCP avancé : async, logging et packaging

9 min de lecture

logo python

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.

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

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.

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()

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.

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 logging
import 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)
...

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.

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.

  1. 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"]
  2. Exposer un script console.

    La section [project.scripts] crée une commande qui pointe vers la fonction main() du serveur — d'où l'intérêt d'avoir extrait main() plutôt que de tout laisser dans if __name__.

    [project.scripts]
    mcp-github-readonly = "server:main"
    [build-system]
    requires = ["hatchling"]
    build-backend = "hatchling.build"
  3. Installer et vérifier.

    Fenêtre de terminal
    pip install --only-binary=:all: -e .

    La commande mcp-github-readonly est 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.

  • 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 def pour ne pas bloquer le serveur.
  • En stdio, stdout est réservé au protocole : journalisez sur stderr, jamais de print().
  • 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.

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