Brancher un agent self-hosted sur Claude sans lui donner les clés de la maison
Un agent qui tourne sur mon NAS, pilotable depuis Claude sur n'importe quel appareil, sans rien ouvrir sur Internet et sans droits root. Le pont MCP HTTP que j'ai écrit, et surtout les cinq murs pris en chemin.
J'héberge un agent IA chez moi. Il tourne en permanence sur mon NAS, il fait de la veille, il prépare des mails, il tient un kanban. Je voulais le piloter depuis Claude — depuis le web, depuis mon téléphone, sans être devant ma machine.
Deux contraintes, et elles tirent dans des directions opposées. Ne rien ouvrir sur Internet. Et ne donner à aucun process automatisé les droits qu'il faudrait pour faire des dégâts.
Voici comment j'ai résolu ça, et surtout les murs que j'ai pris en chemin — parce que c'est cette partie-là qui n'est écrite nulle part.
Pourquoi les solutions évidentes ne marchent pas#
L'agent expose déjà un serveur MCP en stdio. On peut le brancher sur un client local en le faisant transiter par SSH :
claude mcp add agent -- ssh mon-nas docker exec -i agent agent mcp serveÇa marche. Je l'ai fait. Puis je l'ai supprimé, pour deux raisons.
La première est une question de privilège. docker exec demande l'accès au socket Docker, qui est root-équivalent. Le faire à la main dans un terminal est une chose ; l'accrocher à un client qui spawne le process tout seul, quand il veut, en est une autre. La commande ne change pas, le modèle de risque si.
La seconde est structurelle : stdio suppose que le client peut lancer le process. Un client web ne le peut pas. Cette voie est fermée pour claude.ai par construction, pas par configuration. C'est d'ailleurs la vraie ligne de partage entre les deux transports du protocole, que je détaillais dans mon guide de création d'un serveur MCP : stdio pour un client qui vit sur la même machine, HTTP pour tout le reste.
Exposer le dashboard de l'agent ? Il stocke les clés API des fournisseurs. Non.
Ouvrir un port, monter un VPN ? Ça déplace le problème sans traiter l'authentification du client web, et ça ajoute de la surface.
Un pont, et ce qu'il refuse de faire#
J'ai écrit un pont HTTP : un petit serveur MCP (Streamable HTTP, SDK Python) qui parle à l'agent par l'API REST de son dashboard. Pas par le socket Docker, pas par ses bases SQLite.
Ce choix paie trois fois. Le pont n'a besoin d'aucun privilège particulier. Il ne dépend d'aucun détail interne, donc il survit aux mises à jour de l'agent. Et sa surface est exactement celle d'une API déjà pensée pour être appelée.
Sept outils : lire le kanban, lire une carte, créer une carte, commenter, lister et lire les fichiers produits par l'agent, lui poser une question.
Il en manque un, délibérément : approuver une carte.
Mon agent a le droit de préparer un mail de démarchage. Il n'a pas le droit de l'envoyer. Entre les deux, il y a une colonne du kanban : la carte s'arrête en attente de validation, et c'est moi qui la fais passer en « fait ». Ce geste est le garde-fou.
Exposer cette transition dans le pont l'aurait vidée de son sens : un agent capable d'approuver son propre travail n'est plus sous supervision, il a juste une étape de plus à franchir. La bonne décision d'ingénierie, ici, était de ne pas implémenter une fonction évidente.
Même logique pour la création : une carte déposée par le pont naît dans la colonne d'attente, sans assignation. L'agent distant peut proposer du travail, pas en lancer.
Le périmètre d'un serveur MCP n'est pas la liste de ce que l'API sait faire, c'est la liste de ce qu'on accepte de déléguer. Chaque outil exposé est une autorisation permanente accordée à un modèle.
L'authentification, ou le vrai sujet#
Devant le pont : Cloudflare Access, via un tunnel. Aucun port ouvert sur ma box, aucune IP publique à protéger.
Access sait authentifier deux populations. Les machines, par jeton de service. Les humains, par un fournisseur d'identité. Il fallait les deux, et c'est là que ça se complique.
Piège n°1 — Claude ne sait pas envoyer d'en-têtes#
L'interface des connecteurs Claude ne propose que de l'OAuth. Pas de bearer, pas d'en-tête personnalisé.
Or un jeton de service Cloudflare est une paire d'en-têtes. Toute ma configuration machine, celle qui marchait très bien pour mes scripts, était donc structurellement inutilisable par un connecteur web. Il fallait de l'OAuth, point.
Piège n°2 — les deux mondes ne se mélangent pas#
Une politique Access dont l'action est « Autorisation de service » n'évalue que du non-identitaire : jetons, mTLS, IP. Y ajouter une règle sur une adresse e-mail ne produit aucune erreur — la règle est simplement ignorée.
La réciproque est vraie et m'avait déjà coûté du temps : une politique « Autoriser » avec un sélecteur de jeton ne valide pas le jeton, elle redirige vers une page de login.
Deux populations, deux politiques distinctes sur la même application. Access évalue les politiques de service d'abord, les autres ensuite.
Piège n°3 — mon pont ne validait rien#
Cloudflare propose un « OAuth géré » qui fait d'Access le fournisseur OAuth de l'application. C'est la pièce qui rend un serveur MCP joignable par un client web. Sa documentation pose une condition :
N'activez OAuth géré que pour les serveurs MCP qui valident le JWT d'Access.
J'ai vérifié. Un curl depuis le NAS, sans le moindre en-tête, obtenait une session MCP complète. Mon pont ne validait rien du tout.
Je m'étais raconté que le filtrage en amont suffisait. C'était faux, et pas qu'un peu : le conteneur écoutait sur 0.0.0.0 et partageait un réseau Docker avec d'autres services. N'importe quel conteneur voisin pouvait piloter mon kanban sans authentification, sans jamais passer par Cloudflare.
Access injecte dans chaque requête un JWT signé, dans l'en-tête Cf-Access-Jwt-Assertion. Le valider correctement demande une vingtaine de lignes :
import jwt
from jwt import PyJWKClient
TEAM_DOMAIN = "https://mon-equipe.cloudflareaccess.com"
AUD = "…" # le tag d'audience de CETTE application Access
jwks = PyJWKClient(f"{TEAM_DOMAIN}/cdn-cgi/access/certs")
def verifier(token: str) -> dict:
cle = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
cle,
algorithms=["RS256"],
audience=AUD, # spécifique à l'application
issuer=TEAM_DOMAIN, # le compte Cloudflare
options={"require": ["exp", "iat", "aud", "iss"]},
)L'audience est le point à ne pas rater : sans elle, un jeton émis pour une autre application du même compte passerait la validation. Le reste — signature, émetteur, expiration — est le minimum syndical.
C'est de la défense en profondeur : le bord filtre déjà, mais l'origine ne fait plus confiance à sa position dans le réseau.
Piège n°4 — l'enregistrement dynamique a besoin d'une adresse de retour#
Connecteur activé, premier essai : « Impossible de s'inscrire auprès du service de connexion. »
Claude s'enregistre dynamiquement comme client OAuth, et déclare l'URL vers laquelle l'autorisation doit revenir. Le champ « URI de redirection autorisées » de Cloudflare était vide — donc aucune URI acceptée, donc enregistrement refusé.
La valeur à déclarer, pour toutes les surfaces hébergées de Claude :
https://claude.ai/api/mcp/auth_callback
Piège n°5 — 421 Invalid Host header#
Entre-temps, un rejet que je n'attendais pas, et qui ne venait pas de Cloudflare : le SDK MCP Python embarque une protection anti-DNS-rebinding. Son constructeur prend un paramètre host valant 127.0.0.1 par défaut, et si on ne renseigne pas explicitement la politique, le SDK en dérive une restreinte à la loopback. Tout nom d'hôte public est alors rejeté en 421, avec pour seule trace une ligne de log côté serveur — le client, lui, ne voit qu'une erreur de transport générique.
La configuration explicite règle le cas :
from mcp.server.transport_security import TransportSecuritySettings
securite = TransportSecuritySettings(
allowed_hosts=["agent.example.com", "agent.example.com:443"],
allowed_origins=["https://claude.ai"],
)Une protection qui s'auto-active dans sa version la plus stricte quand on ne la configure pas : c'est un bon défaut, mais il faut savoir qu'il existe. Derrière un reverse proxy qui contrôle déjà l'en-tête Host, la désactiver explicitement (enable_dns_rebinding_protection=False) est plus honnête que de bricoler la liste — au moins la décision est écrite.
La méthode, qui compte plus que les correctifs#
Deux réflexes ont fait gagner beaucoup de temps.
Isoler une variable à la fois. Avant de brancher le connecteur, j'ai testé la chaîne avec un simple jeton de service. Ça fait émettre un vrai JWT par Cloudflare sans impliquer OAuth. C'est ce test qui a révélé le 421 du SDK.
Si j'étais allé directement au connecteur, j'aurais eu une erreur de connexion et j'aurais naturellement accusé l'OAuth — qui n'y était pour rien. J'aurais cherché du mauvais côté longtemps.
Tester le cas passant, pas seulement le cas refusé. Après avoir ajouté la validation JWT, j'avais des preuves rassurantes : sans jeton → 401, avec un jeton forgé → 401. Ça ne prouve rien. Un validateur qui refuse tout ressemble trait pour trait à un validateur correct. La seule preuve qui compte est qu'un jeton légitime passe.
Elle est arrivée par un chemin détourné : l'appel authentifié a été rejeté par le contrôle de Host — donc après la couche JWT, qui l'avait laissé passer. Un test croisé l'a confirmé.
Ce que ça donne#
Depuis Claude, sur n'importe quel appareil, je lis l'état de mon kanban, je consulte les rapports produits pendant la nuit, je dépose une tâche, je pose une question à mon agent.
Rien n'est ouvert sur Internet. Aucun process automatisé n'a de droits root sur ma machine. Et la seule action qui engage vraiment quelque chose vers l'extérieur — valider un envoi — n'est exposée nulle part.
Le code du pont est publié : mickadoua/hermes-mcp-bridge, sous licence MIT, avec la procédure Cloudflare et les cinq symptômes de cet article en dépannage. Il est petit, et l'essentiel de sa valeur tient dans les quelques décisions racontées ici : passer par l'API publique plutôt que par les entrailles, valider le jeton même quand on est déjà protégé, et refuser d'implémenter la fonction de trop.
C'est le même arbitrage que pour un assistant IA personnel auto-hébergé ou pour la mémoire que je donne à Claude Code : ce qu'on décide de ne pas brancher compte autant que ce qu'on branche.
Les noms d'hôtes, identifiants d'application et adresses de cet article sont des placeholders.
Vous voulez exposer vos outils internes à un agent sans ouvrir votre infrastructure ? C'est un des chantiers types de mon accompagnement IA. Prenons 30 minutes pour en parler.
Articles similaires
Créer son propre serveur MCP en TypeScript : le guide complet
J'ai construit plusieurs serveurs MCP en production pour des clients. Voici le guide que j'aurais aimé avoir au départ - de zéro jusqu'au déploiement.
Donner une mémoire à Claude Code : l'architecture que j'utilise en mission
Une session Claude Code est excellente sur la tâche du jour et amnésique sur tout le reste. Voici le montage qui lui donne une mémoire : un vault Obsidian, des notes de session, et trois étages qu'il ne faut jamais confondre.
Relier ses notes de mission : ce qui a marché, ce qui est passé à la trappe
Mon vault Obsidian sert de mémoire à Claude Code. Restait à le relier aux tickets Jira et aux pages Confluence de la mission. Ce que j'ai mis en place, ce que j'ai jeté, et les chiffres des deux.