Aller au contenu principal

Architecture d'un Serveur MCP Custom

Mis à jour le 29 juillet 2026

Concevoir un serveur MCP de production

Un prototype MCP tient dans un fichier de quarante lignes et cela suffit à convaincre une équipe. Le passage à l’usage réel change la donne : plusieurs personnes appellent le serveur, les tools se multiplient, la configuration varie d’un environnement à l’autre, et le fichier unique devient impossible à maintenir. Cette leçon pose les décisions d’architecture qui évitent ce mur — choix du framework, découpage du projet, gestion de la configuration et des erreurs, et arbitrage entre serveur avec ou sans état.

Choisir son framework

En Python, FastMCP est la bibliothèque de référence. Elle expose une API à base de décorateurs et prend en charge le protocole MCP à votre place, si bien qu’un serveur naît en trois lignes :

from mcp.server.fastmcp import FastMCP

app = FastMCP(
    "Mon Serveur",
    instructions="Serveur MCP pour la gestion de projets",
)

Les équipes JavaScript et TypeScript disposent du SDK officiel MCP publié par Anthropic, dont la logique est comparable même si l’écriture diffère :

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({
  name: "Mon Serveur",
  version: "1.0.0",
});

Le choix se joue moins sur les capacités que sur l’écosystème dans lequel votre serveur ira chercher ses données. Un serveur qui interroge une base via SQLAlchemy ou qui réutilise vos scripts de traitement existants sera naturellement en Python ; un serveur adossé à une application Express existante restera en TypeScript. Le tableau ci-dessous résume les différences pratiques.

CritèreFastMCP (Python)MCP SDK (TypeScript)
MaturitéTrès matureMature
Décorateurs@app.tool()Méthode server.tool()
Transport HTTPIntégré (FastAPI)Intégré (Express)
CommunautéLargeLarge

Une structure de projet qui tient dans le temps

L’arborescence recommandée sépare quatre responsabilités : ce que le modèle voit (tools/), ce qui accède aux données (services/), ce qui contrôle l’accès (auth/) et ce qui décrit l’environnement (config.py, .env).

mon-serveur-mcp/
├── server.py              # Point d'entrée FastAPI + montage MCP
├── tools/
│   ├── __init__.py
│   ├── project_tools.py   # Tools de gestion de projet
│   ├── file_tools.py      # Tools de gestion de fichiers
│   └── search_tools.py    # Tools de recherche
├── services/
│   ├── __init__.py
│   ├── database.py        # Accès base de données
│   └── external_api.py    # Appels API externes
├── auth/
│   ├── __init__.py
│   └── middleware.py       # Middleware d'authentification
├── config.py              # Configuration (env vars)
├── Dockerfile             # Conteneurisation
├── requirements.txt       # Dépendances Python
├── .env                   # Variables d'environnement (non commité)
└── tests/
    ├── test_tools.py
    └── test_integration.py

Cette séparation a une conséquence directe et très concrète : vos tools ne contiennent plus de SQL ni d’appels HTTP, seulement de la validation et une délégation vers un service. Vous pouvez alors tester la logique métier sans passer par MCP, et changer de base de données sans toucher à une seule docstring vue par le modèle.

Le point d’entrée : server.py

Le fichier principal ne fait rien d’autre qu’assembler : il crée l’application FastAPI, empile les middlewares et monte chaque serveur MCP sur sa propre route.

# server.py
"""Point d'entrée du serveur MCP."""

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from tools.project_tools import project_mcp
from tools.search_tools import search_mcp
from auth.middleware import AuthMiddleware

# Chaque serveur MCP doit démarrer son gestionnaire de sessions
@asynccontextmanager
async def lifespan(app: FastAPI):
    async with project_mcp.session_manager.run(), search_mcp.session_manager.run():
        yield

# Application FastAPI
api = FastAPI(
    title="Mon Serveur MCP",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS pour les clients web
api.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

# Middleware d'authentification
api.add_middleware(AuthMiddleware)

# Monter les serveurs MCP sur des routes distinctes
api.mount("/projects", project_mcp.streamable_http_app())
api.mount("/search", search_mcp.streamable_http_app())

# Page d'accueil (optionnel mais utile)
@api.get("/")
async def root():
    return {
        "service": "Mon Serveur MCP",
        "version": "1.0.0",
        "endpoints": {
            "projects": "/projects/mcp",
            "search": "/search/mcp",
        },
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(api, host="0.0.0.0", port=8000)

Le montage sur des routes distinctes est ce qui permet à un utilisateur de n’activer que le domaine dont il a besoin : quelqu’un qui cherche de la documentation connecte /search/mcp sans se voir proposer les tools de gestion de projet. Quant à la page d’accueil, elle paraît anecdotique jusqu’au jour où un collègue vous demande l’URL exacte de l’endpoint ; elle lui répond à votre place et sert accessoirement de health check.

Un module de tools par domaine

Chaque fichier du dossier tools/ déclare son propre objet FastMCP et regroupe les outils d’un même domaine fonctionnel. Voici à quoi ressemble le module de gestion de projets, importé plus haut par server.py :

# tools/project_tools.py
"""Tools MCP pour la gestion de projets."""

from mcp.server.fastmcp import FastMCP
from services.database import db

project_mcp = FastMCP(
    "Project Manager",
    instructions="Outils de gestion de projets et de tâches",
)

@project_mcp.tool()
async def create_project(name: str, description: str = "") -> str:
    """Crée un nouveau projet.

    Args:
        name: Nom du projet (2-100 caractères)
        description: Description du projet (optionnel)

    Returns:
        JSON avec les détails du projet créé
    """
    project = await db.projects.create(name=name, description=description)
    return project.to_json()

@project_mcp.tool()
async def list_projects(status: str = "active") -> str:
    """Liste les projets par statut.

    Args:
        status: Filtrer par statut : active, archived, all (défaut: active)

    Returns:
        JSON avec la liste des projets
    """
    projects = await db.projects.list(status=status)
    return projects.to_json()

Remarquez la brièveté des corps de fonction : deux lignes chacun, parce que l’accès aux données vit dans services/database.py. Le tool se contente de décrire son contrat au modèle et de déléguer.

Configuration centralisée

Toute valeur qui change entre votre machine et le serveur de production doit venir de l’environnement, jamais du code. Un unique fichier config.py rassemble ces variables avec leurs valeurs par défaut :

# config.py
"""Configuration du serveur MCP."""

import os
from dataclasses import dataclass

@dataclass
class Config:
    api_token: str = os.getenv("MCP_API_TOKEN", "")
    database_url: str = os.getenv("DATABASE_URL", "sqlite:///data.db")
    debug: bool = os.getenv("DEBUG", "false").lower() == "true"
    port: int = int(os.getenv("PORT", "8000"))
    max_results: int = int(os.getenv("MAX_RESULTS", "50"))

config = Config()

Le paramètre max_results illustre bien l’intérêt de la démarche : le jour où un tool renvoie trop de résultats et sature le contexte du modèle, vous ajustez une variable d’environnement et redémarrez, sans redéploiement de code.

Des erreurs que le modèle sait lire

Une exception Python qui remonte brutalement ne dit rien au modèle : il constate un échec sans comprendre pourquoi, et retente souvent à l’identique. Standardisez plutôt un format de réponse d’erreur explicite, avec un code et un message.

import json

def error_response(message: str, code: str = "error") -> str:
    """Formate un message d'erreur standardisé."""
    return json.dumps({
        "success": False,
        "error": {"code": code, "message": message}
    }, ensure_ascii=False)

@project_mcp.tool()
async def get_project(project_id: str) -> str:
    """Récupère les détails d'un projet par son identifiant."""
    try:
        project = await db.projects.get(project_id)
        if not project:
            return error_response(f"Projet {project_id} introuvable", "not_found")
        return project.to_json()
    except Exception as e:
        return error_response(f"Erreur serveur : {str(e)}", "server_error")

La différence est décisive en conversation. Face à not_found, le modèle comprend qu’il doit d’abord lister les projets pour retrouver le bon identifiant ; face à une exception muette, il abandonne ou boucle.

Avec ou sans état

Un serveur stateful conserve de l’information entre les appels : base de données en mémoire, sessions utilisateur, contexte de travail. C’est le cas courant des serveurs de gestion, comme le gestionnaire de tâches de la leçon précédente. Un serveur stateless traite chaque requête indépendamment, sans rien retenir — configuration recommandée pour du calcul ou de la transformation pure, et qui autorise la mise à l’échelle horizontale puisque n’importe quelle instance peut répondre à n’importe quelle requête. FastMCP l’active avec un seul paramètre :

app = FastMCP("Stateless Server", stateless_http=True)

Tranchez cette question avant d’écrire vos tools, pas après : passer d’un serveur stateful à un serveur stateless implique de sortir tout l’état vers une base externe, ce qui réécrit une bonne partie du code.

Points clés à retenir

  • Séparez vos tools en modules thématiques dans un dossier tools/
  • Utilisez FastAPI comme base HTTP, montez les serveurs MCP sur des routes dédiées et transmettez leur lifespan
  • Centralisez la configuration via des variables d’environnement
  • Standardisez le format des réponses d’erreur pour que le modèle les exploite
  • Choisissez entre stateful (gestion de données) et stateless (calculs purs)
  • Ajoutez une page d’accueil / pour documenter vos endpoints MCP