Le Model Context Protocol standardise la façon dont un agent découvre et appelle des outils. Avant lui, chaque intégration était spécifique à un client ; avec lui, un serveur écrit une fois est utilisable par n'importe quel client compatible. C'est une prise normalisée entre vos systèmes et les agents qui doivent s'en servir.
Deux transports existent. En stdio, le client lance le serveur comme un sous-processus : simple, mais limité à la machine locale. En HTTP, le serveur écoute sur le réseau et devient joignable par plusieurs clients, depuis n'importe où, c'est le cas qui justifie un VPS, et celui que couvre ce guide.
1. Écrire le serveur
Le SDK Python fournit FastMCP, qui dérive le schéma de chaque outil
depuis la signature et les annotations de type de la fonction.
sudo useradd --system --home /srv/mcp --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp -m 750 /srv/mcp
sudo -u mcp python3 -m venv /srv/mcp/.venv
sudo -u mcp /srv/mcp/.venv/bin/pip install "mcp[cli]"
# /srv/mcp/server.py
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ffxf-tools", host="127.0.0.1", port=8000)
@mcp.tool()
def check_dns(domain: str, record: str = "A") -> str:
"""Résout un enregistrement DNS pour un domaine.
À utiliser quand il faut vérifier la configuration DNS d'un domaine, par exemple avant d'émettre un certificat ou de diagnostiquer une panne.
Args:
domain: Le nom de domaine à résoudre, par exemple "exemple.com".
record: Le type d'enregistrement : A, AAAA, MX, TXT, NS ou CNAME.
"""
import subprocess
out = subprocess.run(
["dig", "+short", domain, record],
capture_output=True, text=True, timeout=10,
)
return out.stdout.strip() or f"aucun enregistrement {record} pour {domain}"
if __name__ == "__main__":
mcp.run(transport="streamable-http")
La docstring n'est pas de la documentation : elle est transmise au modèle et c'est sur elle qu'il décide d'appeler l'outil. Écrivez-la pour lui, dites dans quelles situations l'outil s'applique, pas seulement ce qu'il calcule.
Le serveur écoute sur 127.0.0.1, délibérément.
Un serveur MCP expose des capacités d'exécution : la spécification prévoit
OAuth 2.1 pour le transport HTTP, mais tant que vous ne l'avez pas mis en
place, rien n'authentifie les appels. Laissez le proxy s'en charger et gardez
le processus inaccessible directement.
2. En faire un service
sudo tee /etc/systemd/system/mcp.service <<'EOF'
[Unit]
Description=Serveur MCP
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=mcp
Group=mcp
WorkingDirectory=/srv/mcp
ExecStart=/srv/mcp/.venv/bin/python -u server.py
Restart=on-failure
RestartSec=5s
StartLimitBurst=5
StartLimitIntervalSec=300
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
SyslogIdentifier=mcp
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now mcp
journalctl -u mcp -n 20 --no-pager
3. TLS et jeton d'accès
Nginx termine le TLS, vérifie un jeton porteur et transmet au serveur local. Le transport HTTP de MCP utilise des réponses en flux : la mise en tampon doit être désactivée, sinon le client attend indéfiniment.
map $http_authorization $mcp_ok {
default 0;
"Bearer VOTRE_JETON_LONG" 1;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mcp.exemple.com;
location /mcp {
if ($mcp_ok = 0) { return 401; }
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off; # le transport MCP diffuse en flux
proxy_read_timeout 600s; # un appel d'outil peut être long
}
}
openssl rand -hex 32 # pour générer le jeton
sudo nginx -t && sudo systemctl reload nginx
sudo ufw allow 'Nginx Full'
Le jeton en dur dans la configuration convient pour un serveur à un seul consommateur. Dès qu'il y en a plusieurs, ou qu'il faut révoquer un accès sans couper les autres, passez à l'authentification OAuth prévue par la spécification, la comparaison de chaîne ne se révoque pas finement.
4. Brancher un agent
Côté API, un serveur MCP distant se déclare en deux morceaux indissociables : le
serveur dans mcp_servers, et un mcp_toolset qui le
référence par son nom. Déclarer le premier sans le second est refusé.
import anthropic
client = anthropic.Anthropic()
response = client.beta.messages.create(
model="claude-opus-5",
max_tokens=4000,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{
"type": "url",
"name": "ffxf-tools",
"url": "https://mcp.exemple.com/mcp",
"authorization_token": "VOTRE_JETON_LONG",
}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "ffxf-tools"}],
messages=[{"role": "user", "content": "Quels sont les AAAA de ffxf.net ?"}],
)
for block in response.content:
if block.type == "text":
print(block.text)
Le nom passé à mcp_server_name doit correspondre exactement au
name déclaré plus haut : c'est l'erreur de configuration la plus
fréquente, et elle se manifeste par un rejet de la requête plutôt que par un
outil silencieusement absent.
5. Tester sans agent
Avant de brancher quoi que ce soit, l'inspecteur fourni avec le SDK liste les outils exposés et permet de les appeler à la main. C'est le moyen le plus rapide de séparer un problème de serveur d'un problème de client.
npx @modelcontextprotocol/inspector
Un contrôle direct au curl confirme au moins que le proxy et le jeton fonctionnent :
# sans jeton : doit répondre 401
curl -s -o /dev/null -w "%{http_code}\n" https://mcp.exemple.com/mcp
# avec jeton : ne doit plus répondre 401
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer VOTRE_JETON_LONG" \
https://mcp.exemple.com/mcp
6. Concevoir les outils
La qualité d'un serveur MCP tient plus à la conception des outils qu'au code. Quelques règles qui font la différence à l'usage :
- Peu d'outils, aux frontières nettes. Deux outils qui se recouvrent produisent des appels hésitants. S'ils se ressemblent, dites explicitement dans chaque description quand utiliser l'autre.
- Des réponses denses. Tout ce que l'outil retourne entre dans le contexte et se paie. Retournez ce qui sert à décider, pas le dump complet de l'API sous-jacente.
-
Des paramètres expressifs. Une énumération nommée
(
record: "A" | "AAAA" | "MX") transmet l'intention mieux qu'une chaîne libre, et supprime une classe entière d'erreurs. - Des erreurs utiles. Renvoyez un message qui dit quoi faire (« domaine introuvable, vérifiez l'orthographe ») plutôt qu'une trace d'exception : le modèle sait s'adapter à la première, pas à la seconde.
-
Des délais bornés. Tout appel externe porte un
timeout. Sans lui, un outil bloqué immobilise la session entière.
Checklist
- Serveur lié à
127.0.0.1, jamais exposé directement. - Service systemd durci, avec compte dédié et relance bornée.
- Nginx en TLS, jeton vérifié, mise en tampon désactivée, délai allongé.
- 401 confirmé sans jeton depuis l'extérieur.
- Outils validés à l'inspecteur avant tout branchement d'agent.
- Descriptions écrites pour le modèle, délais bornés sur les appels externes.