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ôle | Exemple concret |
|---|---|
| Client | Claude Desktop, Hermes Agent, ChatGPT via connectors, un agent Python maison. Le LLM parle au client. |
| Serveur | Votre code Python (via FastMCP) qui expose des tools, des resources, des prompts. |
| Transport | stdio (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.
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 :
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 :
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.
| Primitive | Ce que ça fait | Contrôle | Exemple |
|---|---|---|---|
| Tool | Action que le LLM peut exécuter | LLM décide | upsert_task, dispatch_email |
| Resource | Contenu qu'un client peut charger dans le contexte | Client / utilisateur décide | Fichier README, page Notion épinglée |
| Prompt | Template réutilisable côté utilisateur | Utilisateur 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…
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.
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.
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.
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 :
| Transport | Cas d'usage | Auth |
|---|---|---|
| stdio | Local, un seul utilisateur, client Claude Desktop | Aucune (isolation par processus) |
| HTTP | Distant, multi-utilisateurs, agent 24/7 | Bearer token obligatoire |
Le même serveur peut faire les deux — il suffit de choisir au mcp.run().
# 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 :
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
# /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 :
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).
{
"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 :
{
"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 :
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 :
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
- Documentation officielle MCP
- FastMCP sur GitHub
- Notre guide compagnon : Installer et créer son agent IA avec Hermes Agent — pour brancher votre nouveau serveur MCP à un agent autonome joignable depuis Telegram.
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.