Serveurs MCP sur un VPS pour agents IA
Hébergez des serveurs MCP sur votre VPS pour donner de vrais outils aux agents IA : transports stdio et HTTP distant, systemd, TLS, authentification et pannes.
Ce que vous allez construire
Deux configurations MCP fonctionnelles sur un seul VPS. D'abord un serveur stdio : un outil de système de fichiers ou de base de données que Claude Code lance comme processus enfant et avec lequel il dialogue via un tube (pipe). Ensuite un serveur HTTP distant qui tourne comme un service réseau permanent derrière systemd et un proxy inverse nginx avec TLS, joignable par n'importe quel client MCP que vous pointez vers lui. L'installation de l'un comme de l'autre est légère. L'essentiel de ce guide porte sur les deux points qui posent réellement problème : garder le flux JSON-RPC propre, et ne jamais exposer un point d'accès outil sans authentification sur l'internet public.
Ce qu'est réellement MCP
Le Model Context Protocol (MCP) est une manière standard, pour un client IA (Claude Code, Claude Desktop, le Gemini CLI sur un VPS ou votre propre script), d'appeler des outils externes et de lire des ressources externes. Le modèle lui-même n'exécute rien. Il demande au client, le client parle le JSON-RPC 2.0 à un serveur MCP, le serveur exécute l'outil et renvoie le résultat. Un seul protocole : un serveur que vous écrivez une fois fonctionne avec tous les clients qui parlent MCP.
Il existe deux transports, et tout le reste de ce guide se divise selon eux :
- stdio. Le client démarre le serveur comme processus enfant et échange des messages JSON-RPC délimités par des sauts de ligne via son entrée standard et sa sortie standard. Pas de réseau, pas de port, pas d'authentification : la limite de confiance est le processus lui-même. Presque tous les outils locaux sont livrés ainsi.
- Streamable HTTP (et son cousin plus ancien, HTTP+SSE). Le serveur est un service web permanent. Le client se connecte en HTTP et le serveur peut renvoyer les réponses en flux sous forme de Server-Sent Events (SSE). C'est ainsi que vous partagez un serveur entre plusieurs clients, ou que vous faites tourner un outil qui doit vivre en permanence sur la machine.
Choisissez stdio quand l'outil appartient à une seule machine et un seul utilisateur. Choisissez HTTP quand c'est un service partagé.
Prérequis et les pièges honnêtes
Partez d'un VPS KVM Ubuntu 24.04 tout neuf avec root ou sudo. Au-delà de ça :
- Un environnement d'exécution dans lequel le serveur est écrit. La plupart des serveurs de référence sont en Node ou Python. Ubuntu 24.04 est livré avec Node 18, et plusieurs paquets MCP actuels réclament Node 20 ou plus récent, donc installez une version LTS actuelle depuis NodeSource ou nvm plutôt que de faire confiance à
apt. Python 3.12 est déjà présent. - Un domaine et un enregistrement DNS de type A, mais seulement pour le serveur HTTP distant : le TLS a besoin d'un nom qui résout vers ce VPS. L'exemple stdio n'a besoin d'aucun DNS.
- 512 Mo de RAM suffisent largement. Les serveurs MCP sont de fins processus JSON-RPC ; le coût mémoire est celui de ce que votre outil manipule (un pilote de base de données, un cache de fichiers), pas celui du protocole.
- La spécification est jeune et évolue. La révision 2025-03-26 a remplacé HTTP+SSE par Streamable HTTP et a marqué SSE comme obsolète. SSE fonctionne toujours et de nombreux serveurs le parlent encore, donc traitez tout choix de transport figé comme une chose à revérifier dans les notes de version du serveur plutôt que comme une vérité absolue.
Étape 1 : brancher un serveur stdio dans Claude Code
Commencez par le serveur de système de fichiers : il est officiel, activement maintenu, et n'a besoin que de Node. L'unique commande ci-dessous l'enregistre auprès de Claude Code et le limite au projet courant, de sorte qu'il atterrit dans un fichier versionnable :
cd /home/matt/projects/api
claude mcp add --scope project --transport stdio filesystem \
-- npx -y @modelcontextprotocol/server-filesystem /home/matt/projects/apiLe séparateur -- a son importance : tout ce qui le suit est la commande que Claude Code exécutera, et non une option pour Claude Code. Cela écrit un .mcp.json à la racine du projet :
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/home/matt/projects/api"
]
}
}
}Rien ne tourne encore. Au prochain démarrage de Claude Code dans ce répertoire, l'agent lit .mcp.json, démarre npx -y @modelcontextprotocol/server-filesystem ... comme processus enfant, et effectue la poignée de main MCP via le stdin/stdout de ce processus. Vérifiez que cela a bien pris :
claude mcp listUn serveur en bonne santé affiche sa commande et une coche verte : filesystem: npx -y @modelcontextprotocol/server-filesystem ... - ✓ Connected. Dans la session, la commande slash /mcp liste les outils que le serveur expose (read_file, write_file, list_directory), et l'agent peut désormais les appeler sur les chemins que vous avez autorisés. Un outil de base de données a la même forme (remplacez le paquet et passez une chaîne de connexion comme dernier argument), mais vérifiez le nom actuel du paquet dans le dépôt du serveur, car le serveur Postgres de référence a changé de mains plus d'une fois.
C'est tout l'intérêt de faire tourner l'agent sur la machine : la session Claude Code vit sur le VPS dans tmux, et ses serveurs stdio tournent juste à côté avec un accès direct aux fichiers du projet et aux services locaux, sans aller-retour réseau.
Étape 2 : construire un serveur HTTP distant
Un serveur stdio meurt avec son parent. Quand vous voulez un outil qui reste disponible pour tous les clients (un outil d'exploitation partagé, une passerelle de base de données, quelque chose que votre ordinateur portable et votre CI appellent tous les deux), il vous faut le transport HTTP et un vrai service. Voici un serveur Python minimal utilisant le SDK officiel, exposant un seul outil :
# /opt/mcp-ops/server.py
from mcp.server.fastmcp import FastMCP
import subprocess
mcp = FastMCP("ops-tools", host="127.0.0.1", port=8000)
@mcp.tool()
def disk_free() -> str:
"""Return `df -h` for the server."""
out = subprocess.run(["df", "-h"], capture_output=True, text=True)
return out.stdout
if __name__ == "__main__":
# Serves Streamable HTTP at /mcp on 127.0.0.1:8000
mcp.run(transport="streamable-http")Notez host="127.0.0.1". Le serveur se lie uniquement à localhost : rien à l'extérieur de la machine ne peut l'atteindre directement, ce qui est exactement ce que vous voulez tant que l'authentification n'existe pas. Installez-le dans son propre environnement virtuel pour que systemd dispose d'un chemin d'interpréteur stable :
sudo useradd --system --home /opt/mcp-ops --shell /usr/sbin/nologin mcp
sudo install -d -o mcp -g mcp /opt/mcp-ops
sudo -H -u mcp python3 -m venv /opt/mcp-ops/.venv
sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install "mcp[cli]"Étape 3 : le garder en vie avec systemd
Un outil hors service au moment où l'agent le sollicite est pire que pas d'outil du tout. Écrivez /etc/systemd/system/mcp-ops.service :
[Unit]
Description=MCP ops-tools server
After=network.target
[Service]
Type=simple
User=mcp
WorkingDirectory=/opt/mcp-ops
ExecStart=/opt/mcp-ops/.venv/bin/python /opt/mcp-ops/server.py
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
[Install]
WantedBy=multi-user.targetLe chemin absolu vers le Python du venv dans ExecStart n'est pas optionnel : pointez-le vers /usr/bin/python3 et le processus démarre avec ModuleNotFoundError: No module named 'mcp', parce que l'interpréteur système n'a jamais vu votre pip install. Activez et vérifiez :
sudo systemctl daemon-reload
sudo systemctl enable --now mcp-ops
sudo systemctl status mcp-ops
curl -si -H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-X POST http://127.0.0.1:8000/mcpstatus doit afficher active (running). Le curl revient avec HTTP/1.1 400 Bad Request et une erreur JSON-RPC dans le corps (la requête ne portait ni session ni charge JSON valide), et c'est exactement ce que vous voulez : cela prouve que le port répond et parle le protocole. Connection refused ou une réponse vide signifie que le processus n'est pas lié là où vous le croyez ; lisez journalctl -u mcp-ops -n 50.
Étape 4 : placer TLS et un proxy inverse devant
Le serveur écoute sur localhost. Pour l'atteindre depuis n'importe où, vous terminez le TLS au niveau de nginx et vous relayez vers l'intérieur. Installez nginx, obtenez un certificat avec Certbot et Let's Encrypt sur nginx, puis écrivez le bloc location. Le point critique est de désactiver la mise en tampon, car le comportement par défaut de nginx retient une réponse jusqu'à ce qu'elle soit complète, ce qui bloque un flux SSE indéfiniment :
server {
listen 443 ssl;
server_name mcp.example.com;
# ssl_certificate lines managed by Certbot
location /mcp {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# The four lines that make SSE work through nginx:
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding off;
}
}Rechargez avec sudo nginx -t && sudo systemctl reload nginx. Si vous faites déjà tourner une flotte de conteneurs, le même travail est fait pour vous par un proxy inverse Traefik avec TLS automatique : il émet le certificat et route par nom d'hôte, et vous n'avez qu'à ajouter des labels au conteneur MCP. Dans tous les cas, le proxy inverse est désormais la seule chose sur un port public, et il pointe vers un service que vous n'avez pas encore sécurisé. Corrigez cela avant d'enregistrer l'URL où que ce soit.
Étape 5 : la règle de sécurité qui domine ce sujet
N'exposez jamais un point d'accès MCP sans authentification. Un serveur MCP n'est pas une API en lecture seule. Il accorde l'accès à des outils : à vos fichiers, votre base de données, parfois un shell. Un /mcp ouvert sur l'internet public, c'est un inconnu doté de la même portée que votre agent IA : il liste vos outils, puis les appelle. Traitez-le exactement comme un socket d'administration sans authentification, car c'est ce qu'il est.
Trois défenses, par ordre de préférence :
- Ne le publiez pas. Gardez le serveur sur
127.0.0.1et atteignez-le depuis votre ordinateur portable avec un tunnel SSH :ssh -L 8000:127.0.0.1:8000 matt@vps, puis pointez le client vershttp://127.0.0.1:8000/mcp. Rien n'est jamais exposé. - Placez-le sur un réseau privé. Liez-le à l'adresse de tunnel d'un VPN WireGuard auto-hébergé et laissez seuls les pairs du VPN l'atteindre. L'internet public voit un port fermé.
- S'il doit être public, exigez un jeton. La bonne réponse est le flux OAuth MCP que le transport HTTP prend en charge nativement. Le minimum pragmatique est un jeton bearer partagé vérifié au niveau du proxy : peu coûteux, et il arrête complètement l'attaque de passage :
location /mcp {
if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM") {
return 401;
}
proxy_pass http://127.0.0.1:8000;
# ...buffering-off block from above...
}Générez le jeton avec openssl rand -hex 32, et ne liez jamais le serveur lui-même à 0.0.0.0 sans l'un de ces dispositifs devant. Le client envoie alors le jeton dans un en-tête. Dans Claude Code :
claude mcp add --scope project --transport http ops-tools https://mcp.example.com/mcp \
--header 'Authorization: Bearer ${MCP_TOKEN}'Définissez MCP_TOKEN dans votre shell pour que le secret n'atterrisse jamais en clair dans .mcp.json : Claude Code développe ${MCP_TOKEN} depuis l'environnement au moment de la lecture.
Étape 6 : déboguer avec le MCP Inspector
Quand un serveur se comporte mal, ne devinez pas depuis l'intérieur de l'agent : pilotez-le directement avec l'Inspector, le client de test officiel basé sur le web. Pour un serveur stdio, passez-lui la même commande que l'agent exécute :
npx @modelcontextprotocol/inspector \
npx -y @modelcontextprotocol/server-filesystem /tmpIl démarre une interface sur http://localhost:6274 (les versions récentes affichent une URL avec un paramètre de requête MCP_PROXY_AUTH_TOKEN : utilisez ce lien exact ou l'interface vous rejette) et un proxy sur 6277. Cliquez sur Connect, puis List Tools, puis Call Tool avec de vrais arguments. Si cela fonctionne dans l'Inspector mais échoue dans l'agent, le bogue est dans la configuration de votre client, pas dans le serveur. Pour le serveur HTTP distant, choisissez le transport Streamable HTTP, saisissez https://mcp.example.com/mcp, ajoutez l'en-tête Authorization, et connectez-vous : c'est le moyen le plus rapide de prouver que l'authentification et le proxy sont corrects avant d'impliquer le moindre agent.
Maintenir les serveurs à jour
MCP évolue vite, donc appliquez les correctifs régulièrement. Les serveurs Node lancés avec npx -y récupèrent la dernière version à chaque démarrage, ce qui est pratique mais non reproductible ; figez la version exacte que vous avez testée (lisez-la avec npm view @modelcontextprotocol/server-filesystem version et ajoutez-la au nom du paquet dans .mcp.json, soit @modelcontextprotocol/server-filesystem@<version>) dès qu'un serveur devient important, et faites-la évoluer délibérément. Les serveurs Python sous systemd se mettent à jour avec sudo -H -u mcp /opt/mcp-ops/.venv/bin/pip install -U "mcp[cli]" suivi de sudo systemctl restart mcp-ops. Surveillez la révision de la spécification que vise votre SDK quand vous mettez à jour : un saut par-dessus la frontière SSE vers Streamable HTTP peut changer le transport que vos clients doivent demander.
Modes de défaillance, avec les chaînes que vous verrez
L'agent indique que le serveur a échoué. claude mcp list affiche ✗ Failed to connect, et le TUI signale MCP server 'filesystem' failed to start. Lancez claude --debug et vous verrez généralement Error: spawn npx ENOENT : la commande n'est pas dans le PATH de l'agent. L'environnement d'exécution est manquant ou n'est pas là où l'agent le cherche : Node non installé, npx absent, ou un Python de venv référencé par son simple nom. Corrigez la commande en chemin absolu ou installez l'environnement, puis reconnectez.
Un serveur stdio se connecte, puis se coupe aussitôt. Le client journalise une erreur d'analyse JSON, quelque chose comme Unexpected token 'S', "Server sta"... is not valid JSON ou Failed to parse message. La cause est toujours la même : le serveur a écrit une ligne de journal sur stdout. En stdio, stdout est le canal JSON-RPC, donc tout texte parasite corrompt le flux et la poignée de main meurt. En Node, console.log va vers stdout : utilisez console.error. En Python, un simple print() va vers stdout : écrivez les journaux avec logging configuré vers sys.stderr, ou passez file=sys.stderr. La règle est absolue : en stdio, uniquement du JSON-RPC sur stdout, tout ce qui est humain sur stderr.
Un serveur distant expire ou se ferme au milieu de la poignée de main. Le client échoue avec MCP error -32000: Connection closed, ou l'Inspector reste bloqué sur Connect et ne liste jamais les outils. Derrière nginx, c'est la mise en tampon : le proxy retient le flux SSE au lieu de le vider, donc le client attend une réponse qui n'arrive jamais. Ajoutez proxy_buffering off; (et le reste du bloc de l'étape 4) au location. Confirmez avec curl -N sur l'URL publique : vous devriez voir les données d'événements arriver progressivement, et non toutes d'un coup à la fin.
L'authentification est rejetée. Le client signale Error POSTing to endpoint (HTTP 401) ou simplement 401 Unauthorized. Soit l'en-tête est manquant, soit le jeton est faux, soit la variable de shell était vide quand le client a lu la configuration, un piège courant puisque ${MCP_TOKEN} se développe en rien si la variable n'est pas définie, et nginx voit alors Bearer sans valeur. Affichez la variable, rajoutez l'en-tête, et vérifiez que les octets exacts correspondent au jeton dans le if de nginx.
Le service ne démarre pas sous systemd. journalctl -u mcp-ops affiche ModuleNotFoundError: No module named 'mcp' : ExecStart pointe vers le Python système au lieu de l'interpréteur du venv. Ou Address already in use : un autre processus occupe le 8000 ; trouvez-le avec sudo ss -ltnp | grep 8000.
FAQ
Qu'est-ce qu'un serveur MCP, exactement ?
C'est un programme qui expose des outils et des ressources à un client IA via le Model Context Protocol, en utilisant JSON-RPC 2.0. Le modèle IA n'exécute jamais l'outil lui-même : il demande à son client, le client appelle le serveur MCP, et le serveur exécute et renvoie un résultat. Comme le protocole est standard, un seul serveur fonctionne avec n'importe quel client conforme, que ce soit Claude Code, Claude Desktop ou le Gemini CLI.
Quelle est la différence entre le transport stdio et le transport HTTP ?
Un serveur stdio est démarré par le client comme processus enfant et communique via stdin/stdout, donc il vit et meurt avec un seul client sur une seule machine et n'a besoin ni de réseau ni d'authentification. Un serveur HTTP est un service réseau permanent que de nombreux clients peuvent atteindre en même temps, ce qui explique qu'il exige TLS et authentification. Utilisez stdio pour les outils locaux mono-utilisateur ; utilisez HTTP (Streamable HTTP sur les serveurs actuels) pour tout ce qui est partagé ou persistant.
Comment sécuriser un serveur MCP distant ?
Partez du principe qu'il accorde l'accès à vos fichiers, votre base de données ou votre shell, et ne l'exposez jamais sans authentification. Le mieux est de le garder lié à localhost et de l'atteindre par un tunnel SSH ou un VPN privé ; s'il doit être public, placez-le derrière un proxy inverse qui impose un jeton bearer ou le flux OAuth MCP. Générez le jeton avec openssl rand -hex 32 et ne liez jamais le serveur à 0.0.0.0 sans l'un de ces dispositifs devant.
Comment déboguer un serveur qui ne démarre pas ?
Vérifiez d'abord claude mcp list : ✗ Failed to connect avec spawn ... ENOENT signifie que la commande ou l'environnement d'exécution est manquant, donc corrigez le chemin ou installez-le. S'il se connecte puis se coupe avec une erreur d'analyse JSON, le serveur journalise sur stdout et corrompt le flux JSON-RPC ; déplacez toute la journalisation vers stderr. Pour tout le reste, lancez la commande exacte sous le MCP Inspector, qui pilote le serveur de manière isolée pour que vous puissiez distinguer un bogue du serveur d'un problème de configuration du client.