Aller au contenu principal
Retour aux tutos
GratuitIntermédiaireIA & Automatisation

Créer son serveur MCP avec FastMCP — connecter un LLM à ses outils métier en Python

Guide pas-à-pas pour construire son propre serveur MCP en Python avec FastMCP : squelette minimal, 6 tools réalistes (search Notion, upsert task, list projects, résumé de page, dispatch email, get metrics), cache in-memory, transport stdio et HTTP avec authentification bearer, déploiement VPS, branchement sur Claude Desktop et Hermes Agent. Retour d'expérience terrain — Roger, l'assistant Notion interne de MAG&Cie.

9 juillet 202645 min

Vous saurez faire

  • Comprendre ce qu'est MCP et pourquoi FastMCP est le meilleur point d'entrée en Python
  • Installer FastMCP et démarrer un serveur minimal en 10 lignes
  • Écrire 6 tools réalistes avec typage strict, docstrings et validation d'entrées
  • Ajouter un cache in-memory pour absorber les rate limits des API tierces
  • Basculer du transport stdio (local) au transport HTTP avec authentification bearer
  • Déployer le serveur sur un VPS avec systemd et TLS Cloudflare
  • Connecter le serveur à Claude Desktop et à Hermes Agent
  • Sécuriser en production : rate limiting, rotation de secrets, logging structuré

Pré-requis

  • Python 3.10 ou supérieur installé
  • Notions de base d'API REST et de gestion de secrets
  • Un compte sur au moins un outil source (Notion, Linear, GitHub, Airtable…) avec un token API
  • Un client MCP pour tester : Claude Desktop, Hermes Agent, ou un client Python custom
Sommaire18

FastMCP est la meilleure porte d'entrée pour écrire un serveur MCP en Python. Ce tuto part du zéro et vous laisse avec un serveur réel : 6 tools, un cache, un transport HTTP authentifié, un déploiement VPS et une connexion vérifiée à Claude Desktop et à Hermes Agent. Le fil rouge : Roger, l'assistant interne Notion de MAG&Cie qui alimente notre agent Hermes en données produit et projets.

Ce que ce guide n'est pas

Ce guide n'est pas une introduction générale à MCP (spécifications JSON-RPC, versions du protocole, schémas). Pour ça, la documentation officielle reste la référence. Ici, on construit, on déploie et on connecte — dans cet ordre.

MCP en 90 secondes

Model Context Protocol est un standard ouvert publié par Anthropic (novembre 2024, adopté depuis par OpenAI, Google et la plupart des clients LLM sérieux) pour connecter un modèle à des sources de données et à des outils.

Trois rôles :

RôleExemple concret
ClientClaude Desktop, Hermes Agent, ChatGPT via connectors, un agent Python maison. Le LLM parle au client.
ServeurVotre code Python (via FastMCP) qui expose des tools, des resources, des prompts.
Transportstdio (client et serveur dans le même processus parent), HTTP (client et serveur séparés par le réseau).

Le protocole utilise JSON-RPC 2.0 par-dessus le transport. Le SDK officiel Anthropic gère les détails ; FastMCP les cache complètement derrière des décorateurs Python.

Installer FastMCP et poser le squelette

Créez un dossier de projet, un venv, installez la lib.

Bash
mkdir roger-mcp && cd roger-mcp
python -m venv .venv && source .venv/bin/activate  # Windows: .venv\Scripts\activate
pip install fastmcp

Créez server.py :

Python
from fastmcp import FastMCP

mcp = FastMCP("roger")

@mcp.tool()
def ping() -> str:
    """Sanity check tool: returns 'pong' if the server is alive."""
    return "pong"

if __name__ == "__main__":
    mcp.run()

Lancez le serveur en stdio :

Bash
python server.py

Le serveur attend des messages JSON-RPC sur stdin. C'est normal qu'il n'affiche rien — il ne parle qu'à un client MCP.

Ne testez pas en tapant à la main dans le terminal

JSON-RPC sur stdio n'est pas fait pour être tapé au clavier. Pour tester, utilisez soit Claude Desktop, soit le petit client Python en fin de tuto, soit fastmcp inspect server.py (outil intégré) qui liste les tools sans monter un client complet.

Tools, resources, prompts — les 3 primitives MCP à ne pas confondre

MCP expose trois types de primitives côté serveur. Les confondre est la cause la plus fréquente de serveurs mal conçus.

PrimitiveCe que ça faitContrôleExemple
ToolAction que le LLM peut exécuterLLM décideupsert_task, dispatch_email
ResourceContenu qu'un client peut charger dans le contexteClient / utilisateur décideFichier README, page Notion épinglée
PromptTemplate réutilisable côté utilisateurUtilisateur invoque« /brief-projet » qui pré-remplit un brief

Règle pratique : action → tool ; contenu → resource ; workflow guidé → prompt. Ce tuto se concentre sur les tools (95 % des serveurs MCP utiles). FastMCP expose @mcp.resource() et @mcp.prompt() avec la même ergonomie si vous en avez besoin.

Écrire 6 tools réalistes

Un tool MCP = une fonction Python typée avec un décorateur @mcp.tool(). FastMCP génère automatiquement le JSON Schema à partir des annotations de type et de la docstring — que le LLM lit pour comprendre quand utiliser le tool.

Voici 6 tools inspirés de Roger. Ils utilisent Notion comme source, mais le pattern est identique pour Linear, GitHub, Airtable, votre CRM maison…

Python
from fastmcp import FastMCP
from pydantic import BaseModel, Field
from typing import Literal
import httpx, os

NOTION_TOKEN = os.environ["NOTION_TOKEN"]
NOTION_DB_TASKS = os.environ["NOTION_DB_TASKS"]

mcp = FastMCP("roger")
client = httpx.Client(
    base_url="https://api.notion.com/v1",
    headers={
        "Authorization": f"Bearer {NOTION_TOKEN}",
        "Notion-Version": "2022-06-28",
    },
    timeout=15.0,
)

class TaskInput(BaseModel):
    title: str = Field(..., min_length=1, max_length=200)
    status: Literal["todo", "doing", "done"] = "todo"
    project: str | None = None
    due: str | None = Field(default=None, description="ISO 8601 date (YYYY-MM-DD)")

@mcp.tool()
def search_notion(query: str, limit: int = 10) -> list[dict]:
    """Search across all Notion pages the integration has access to. Use for open-ended lookups where you don't know the database in advance."""
    r = client.post("/search", json={"query": query, "page_size": limit})
    r.raise_for_status()
    return [{"id": p["id"], "title": _title(p), "url": p["url"]} for p in r.json()["results"]]

@mcp.tool()
def upsert_task(task: TaskInput) -> dict:
    """Create or update a task in the internal task database. Use when the user asks to add, plan or reschedule work."""
    payload = {"parent": {"database_id": NOTION_DB_TASKS}, "properties": _task_to_props(task)}
    r = client.post("/pages", json=payload)
    r.raise_for_status()
    return {"id": r.json()["id"], "url": r.json()["url"], "status": task.status}

@mcp.tool()
def list_projects(status: Literal["active", "archived", "all"] = "active") -> list[dict]:
    """List all internal projects with their current status. Use to discover which project a new task belongs to."""
    r = client.post(f"/databases/{os.environ['NOTION_DB_PROJECTS']}/query")
    r.raise_for_status()
    return [{"id": p["id"], "name": _title(p), "status": _select(p, "Status")} for p in r.json()["results"]
            if status == "all" or _select(p, "Status") == status]

@mcp.tool()
def summarize_page(page_id: str) -> str:
    """Fetch the raw markdown of a Notion page. Use when the user asks about the content of a specific page you already found via search_notion."""
    r = client.get(f"/blocks/{page_id}/children")
    r.raise_for_status()
    return _blocks_to_markdown(r.json()["results"])

@mcp.tool()
def dispatch_email(to: str, subject: str, body_markdown: str) -> dict:
    """Send an email via the internal transactional mailer. Use only when the user explicitly asks to email someone — never proactively."""
    # Real implementation: Postmark, Resend, or a local SMTP relay. Here: stubbed.
    return {"queued": True, "to": to, "subject": subject, "size": len(body_markdown)}

@mcp.tool()
def get_metrics(period: Literal["day", "week", "month"] = "week") -> dict:
    """Return internal KPIs (MRR, active clients, open tasks) for the given period. Read-only. Cached 60 s."""
    return {"period": period, "mrr_eur": 0, "active_clients": 0, "open_tasks": 0}

# Helpers _title, _select, _task_to_props, _blocks_to_markdown volontairement omis pour la lisibilité.

if __name__ == "__main__":
    mcp.run()

Les docstrings comptent — pour le LLM

Le LLM ne voit pas votre code, il voit uniquement le JSON Schema généré à partir de vos annotations de type et de vos docstrings. Une docstring vague (« Search Notion ») produit un tool médiocrement utilisé. Une docstring précise (« Search across all Notion pages the integration has access to. Use for open-ended lookups… ») pousse le LLM à choisir le bon tool au bon moment.

Ajouter un cache in-memory (retour Roger)

Le problème réel. Roger appelle list_projects et search_notion en boucle pendant qu'un utilisateur discute avec lui. Notion applique un rate limit à ~3 requêtes par seconde et par intégration. Sans cache, Roger arrache le tapis dès la deuxième session en parallèle.

La solution simple. Un décorateur @cached à TTL courte (60 s) sur les tools read-only. Pas besoin de Redis pour ça — un dictionnaire in-memory suffit tant que vous tournez en mono-process.

Python
import time
from functools import wraps

_cache: dict[tuple, tuple[float, object]] = {}

def cached(ttl: int = 60):
    def deco(fn):
        @wraps(fn)
        def wrapper(*args, **kwargs):
            key = (fn.__name__, args, tuple(sorted(kwargs.items())))
            hit = _cache.get(key)
            if hit and time.time() - hit[0] < ttl:
                return hit[1]
            result = fn(*args, **kwargs)
            _cache[key] = (time.time(), result)
            return result
        return wrapper
    return deco

@mcp.tool()
@cached(ttl=60)
def list_projects(status: Literal["active", "archived", "all"] = "active") -> list[dict]:
    ...

@mcp.tool()
@cached(ttl=60)
def search_notion(query: str, limit: int = 10) -> list[dict]:
    ...

Résultat mesuré côté Roger : 400 appels Notion/heure sans cache → ~80 après. Zéro régression fonctionnelle — la TTL de 60 s reste très en dessous du seuil de fraîcheur attendu (« combien de projets actifs ? » n'a pas besoin d'être précis à la seconde près).

Ne cachez pas les tools write

upsert_task, dispatch_email ne doivent JAMAIS être décorés @cached — vous risqueriez de renvoyer un ancien résultat à la place d'exécuter réellement l'action. Cache = read-only uniquement.

Gestion d'erreurs — backoff exponentiel + circuit breaker light

Deux patterns à intégrer avant de mettre en prod :

1. Backoff exponentiel avec jitter sur les tools qui écrivent (upsert_task, dispatch_email) — pour absorber les pics de rate limit sans faire échouer le tool.

Python
import random, time
import httpx

def with_backoff(fn, max_retries: int = 3):
    for attempt in range(max_retries):
        try:
            return fn()
        except httpx.HTTPStatusError as e:
            if e.response.status_code not in (429, 502, 503, 504):
                raise
            if attempt == max_retries - 1:
                raise
            sleep_s = (2 ** attempt) + random.random()  # 1-2s, 2-3s, 4-5s
            time.sleep(sleep_s)

2. Circuit breaker minimaliste — si Notion tombe, on arrête d'insister pendant 60 secondes plutôt que d'inonder le LLM d'erreurs.

Python
class Circuit:
    def __init__(self, cool_down: int = 60):
        self.failures = 0
        self.opened_at: float | None = None
        self.cool_down = cool_down

    def check(self):
        if self.opened_at and time.time() - self.opened_at < self.cool_down:
            raise RuntimeError("Notion currently degraded — try again shortly.")
        if self.opened_at and time.time() - self.opened_at >= self.cool_down:
            self.opened_at = None
            self.failures = 0

    def record_failure(self):
        self.failures += 1
        if self.failures >= 5:
            self.opened_at = time.time()

notion_circuit = Circuit()

À ré-utiliser dans chaque tool qui parle à Notion. La couche est volontairement simple : pour la majorité des cas TPE/PME, une lib comme tenacity ou pybreaker est plus lourde que le besoin.

Passer du transport stdio au transport HTTP

Deux transports au choix, selon l'usage :

TransportCas d'usageAuth
stdioLocal, un seul utilisateur, client Claude DesktopAucune (isolation par processus)
HTTPDistant, multi-utilisateurs, agent 24/7Bearer token obligatoire

Le même serveur peut faire les deux — il suffit de choisir au mcp.run().

Python
# stdio (par défaut)
mcp.run()

# HTTP sur le port 8000
mcp.run(transport="http", host="0.0.0.0", port=8000)

Pour l'auth bearer, FastMCP prend en charge un middleware simple :

Python
from fastmcp.server.auth import BearerAuth

expected_token = os.environ["MCP_BEARER_TOKEN"]

mcp = FastMCP("roger", auth=BearerAuth(token=expected_token))

Tout appel HTTP sans header Authorization: Bearer <token> est refusé avec un 401 avant même d'atteindre un tool.

Déployer sur un VPS avec systemd

Réponse directe. Un VPS à 5 €/mois (Hetzner CX22, OVH Kimsufi, Scaleway Stardust) tient largement — le serveur MCP n'est pas gourmand tant que le LLM tourne côté client.

Unit systemd hardened

INI
# /etc/systemd/system/roger-mcp.service
[Unit]
Description=Roger MCP server
After=network.target

[Service]
Type=simple
User=roger
WorkingDirectory=/opt/roger-mcp
EnvironmentFile=/etc/roger-mcp.env  # NOTION_TOKEN, MCP_BEARER_TOKEN, etc.
ExecStart=/opt/roger-mcp/.venv/bin/python server.py
Restart=on-failure
RestartSec=5

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/roger-mcp/data
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target

Activation :

Bash
sudo systemctl enable --now roger-mcp
sudo systemctl status roger-mcp

TLS via Caddy (2 lignes)

mcp.mag-cie.com {
  reverse_proxy 127.0.0.1:8000
}

Caddy demande automatiquement un certificat Let's Encrypt et le renouvelle. Ajoutez le tunnel Cloudflare si vous ne voulez pas exposer d'IP publique.

Connecter à Claude Desktop et à Hermes Agent

Claude Desktop (stdio)

Éditez claude_desktop_config.json (sur macOS : ~/Library/Application Support/Claude/claude_desktop_config.json ; sur Windows : %APPDATA%\Claude\claude_desktop_config.json).

JSON
{
  "mcpServers": {
    "roger": {
      "command": "/opt/roger-mcp/.venv/bin/python",
      "args": ["/opt/roger-mcp/server.py"],
      "env": {
        "NOTION_TOKEN": "secret_...",
        "NOTION_DB_TASKS": "..."
      }
    }
  }
}

Redémarrez Claude Desktop. Vérifiez l'icône MCP en bas à droite — les 6 tools doivent apparaître.

Claude Desktop distant (via mcp-proxy)

Claude Desktop ne parle nativement qu'en stdio. Pour taper un serveur HTTP distant, utilisez mcp-proxy qui fait pont stdio ↔ HTTP :

JSON
{
  "mcpServers": {
    "roger-remote": {
      "command": "mcp-proxy",
      "args": [
        "https://mcp.mag-cie.com/",
        "--headers", "Authorization=Bearer eyJhbGc..."
      ]
    }
  }
}

Hermes Agent (HTTP natif)

Hermes gère MCP HTTP nativement :

Bash
hermes mcp add roger \
  --url https://mcp.mag-cie.com/ \
  --header "Authorization: Bearer eyJhbGc..."

hermes mcp list   # doit lister roger et ses 6 tools

Durcir en production

Rate limiting par consumer

En HTTP multi-utilisateurs, ajoutez un rate limit par bearer token (1 requête/seconde par défaut). Un LLM en boucle peut tirer 50 requêtes/seconde sans broncher — c'est vous qui payez la facture Notion.

Rotation manuelle des secrets

Automatiser la rotation ajoute de la complexité sans grand gain. En revanche, un rappel calendaire trimestriel pour ré-émettre MCP_BEARER_TOKEN et le NOTION_TOKEN de l'intégration est un compromis raisonnable.

Logs structurés avec corrélation

Un tool qui remonte une erreur Notion sans ID de corrélation vous laisse aveugle. Ajoutez un request_id (uuid4) en début de chaque appel, propagez-le dans les logs et dans le message d'erreur remonté au LLM.

Tests d'intégration automatisés

Un petit client Python (~30 lignes) suffit pour valider les 6 tools en CI :

Python
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
import asyncio

async def main():
    params = StdioServerParameters(command="python", args=["server.py"])
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            tools = await session.list_tools()
            assert {t.name for t in tools.tools} >= {
                "ping", "search_notion", "upsert_task",
                "list_projects", "summarize_page", "get_metrics",
            }
            r = await session.call_tool("ping", {})
            assert r.content[0].text == "pong"
            print("OK — MCP server ready")

asyncio.run(main())

Ajoutez ça à votre pipeline CI et vous avez un garde-fou permanent contre les régressions.

Récapitulatif

  • Serveur MCP fonctionnel avec 6 tools réels
  • Cache in-memory qui absorbe les rate limits Notion
  • Deux transports : stdio (local) et HTTP + bearer (distant)
  • Déploiement VPS avec systemd hardening et TLS Caddy
  • Branchement vérifié à Claude Desktop (stdio + mcp-proxy) et à Hermes Agent (HTTP natif)
  • Sécurité prod : rate limit, rotation trimestrielle, logs corrélés, tests CI

Aller plus loin

Besoin d'un MCP sur mesure pour votre stack ?

MAG&Cie conçoit des serveurs MCP dédiés à vos outils métier (Notion, Airtable, HubSpot, PostgreSQL, votre API interne). Voir notre offre MCP sur mesure — première demi-heure de cadrage offerte.