ADR 0003 — Connecteur MCP unique multi-domaines
Statut : accepté pour le MVP (Lot 0), fondation extensible
Issue : #434 —
Work Item Plane agentic-mcp-gateway-foundation
Décision : faire évoluer le MCP existant vers un Gateway multi-domaines placé devant le Cluster Operator MCP, plutôt que d'ajouter un MCP par outil.
Contexte
Un agent — ChatGPT, Claude, Codex, Mammouth AI — devait jusqu'ici choisir son
connecteur : GitHub natif, Cluster Observer, ou rien. Chaque nouvel outil
poussait vers un connecteur supplémentaire à configurer côté agent, et vers des
intégrations point-à-point (n8n -> MCP Cluster Observer, PR #430) qui créent
autant de chemins parallèles à maintenir.
L'objectif de #434 est l'inverse : un seul connecteur MCP à configurer, et toutes les capacités derrière lui.
Choix retenu
ChatGPT / Claude / Codex / n8n / Mammouth
-> Cloudflare Access (inchangé) -> cloudflared (inchangé)
-> mcp-gateway:8080/mcp
|- registry / router
|- cluster.* -> cluster-observer:8080/mcp (serveur MCP amont)
|- dagger.* -> ci-dispatcher:8080 (REST, lecture seule)
|- plane.* -> lecture API Plane / écriture branche `plane`
\- gateway.* -> in-process
- Un endpoint unique et stable. Le Gateway parle MCP
2025-06-18en Streamable HTTP : un POST JSON-RPC par requête,405sur le GET de flux serveur qu'il n'offre pas,202sur une notification. Ce transport est celui déjà consommé par ChatGPT, Claude, Codex et le client MCP natif de n8n. - Les deux contrôles obligatoires du transport sont appliqués par le Gateway.
Originest validé contre une allowlist déclarée en GitOps (protection DNS rebinding), et unMCP-Protocol-Versionhors des révisions servies reçoit un400. Ils ne sont pas délégués à Cloudflare : plusieurs portes intra-cluster appellent ce serveur sans les revérifier. Un en-tête absent reste accepté dans les deux cas — seul un navigateur envoieOrigin. - Une seule révision est annoncée, celle qui est réellement servie.
2025-03-26impose la réception de batches JSON-RPC, que ce transport refuse depuis leur retrait en 2025-06-18 ;2024-11-05suppose l'ancien transport HTTP+SSE à deux endpoints. Les annoncer promettrait des obligations non tenues, doncSUPPORTED_PROTOCOL_VERSIONSse limite à2025-06-18et la négociation échoue franchement pour le reste. La rétrocompatibilité n'est pas un besoin du P1 ; l'implémenter serait un élargissement de périmètre. - Le Cluster Observer devient un domaine, pas un concurrent. Le Gateway le
consomme comme serveur MCP amont et republie ses outils sous
cluster.*. Le serveur, son RBAC minimal et son ConfigMap de sécurité ne changent pas. - Les namespaces sont une convention de nommage, pas une extension du
protocole. MCP expose une liste plate ; un outil est publié
<domaine>.<outil>et réacheminé en coupant au premier séparateur. - Le nom est résolu de façon tolérante. Plusieurs runtimes d'agents
normalisent un nom de fonction vers
^[A-Za-z0-9_-]+$avant de l'appeler, ce qui transformecluster.pods_listencluster_pods_list. Le routeur résout d'abord le nom exact, puis un nom replié, et refuse de deviner quand deux noms publiés se replient sur la même chaîne. - Un domaine peut écrire ailleurs qu'où il lit, et ce « ailleurs » peut
changer.
plane.*lit l'API Plane ; son écriture est en cours de bascule du commit GitOps vers l'API Plane et se choisit parPLANE_WRITE_MODE(git,dualpar défaut,api). Le modedualécrit les deux et reste le seul sûr tant que.plane/sync.pyréconcilie l'état déclaré vers Plane : une écriture API non commitée serait sinon annulée au prochain push sur.plane/**. Le fait que la cible d'écriture soit une configuration, et non une réécriture du domaine, est ce que l'architecture par domaines a rendu possible. - Un domaine peut n'avoir aucun endpoint d'écriture à appeler. L'API
publique de n8n ne sait pas exécuter un workflow : le seul point d'entrée
est le nœud Webhook que son auteur a posé. Plutôt que de contourner par
l'API interne de l'éditeur,
n8n.*en fait la règle d'exposition — un workflow est déclenchable parce que son auteur l'a voulu, l'a tagué et l'a activé. La limite du backend est devenue le contrôle d'accès. - Un domaine peut déléguer à un pont plutôt que d'embarquer un client.
slack.*ne parle pas à l'API Slack : il appelleslack-mcp, un composant interne qui détient le jeton de bot. Le Gateway ne porte donc aucune credential Slack, et la surface d'un futur incident Slack reste bornée à ce composant. - Une capacité d'écriture ne doit pas ouvrir sa propre porte.
n8n.*sait créer et activer un workflow, mais aucun de ses outils n'écrit de tag. Poser le tag d'exposition reste une action humaine dans l'éditeur, donc un agent ne peut pas fabriquer puis déclencher son propre workflow. C'est une propriété de la surface publiée, pas une politique externe, et elle doit le rester. - Un domaine = une classe + une ligne d'enregistrement. Le contrat tient
dans
list_tools(),call_tool()ethealth(). Ajoutergithub.*ne touche ni le serveur JSON-RPC, ni le transport, ni la configuration des agents.
Alternatives écartées
| Alternative | Raison du rejet |
|---|---|
| Un MCP par outil (GitHub, Plane, Cluster…) | multiplie les connecteurs côté agent, exactement ce que #434 supprime |
| n8n en proxy universel synchrone | n8n reste l'orchestrateur d'events/queues ; le rendre proxy en fait un point de passage bloquant |
Étendre kubernetes-mcp-server en amont |
fork d'un composant tiers pour y greffer GitHub, Plane et Slack ; le Gateway les garde séparés du RBAC Kubernetes |
| Prolonger le pont point-à-point de la PR #430 | crée un second chemin parallèle au Gateway pour le même besoin |
Privilèges
Le Gateway est strictement moins privilégié que le serveur qu'il expose :
- aucun ServiceAccount,
automountServiceAccountToken: false, aucun token Kubernetes : il ne parle jamais à l'API Kubernetes, seulement au MCP amont ; - aucun Secret monté ni variable d'environnement issue d'un Secret dans ce lot ;
- système de fichiers racine en lecture seule, capacités Linux toutes supprimées, image épinglée au digest ;
- NetworkPolicy fermée : entrée limitée aux trois portes authentifiées du
namespace —
cloudflared,openai-tunnel-clientetmammouth-mcp-proxy— et sortie limitée à CoreDNS et aux deux backends.
L'authentification n'est pas réimplémentée : elle reste Cloudflare Access Service Auth par agent devant le tunnel, et la porte Bearer dédiée pour Mammouth AI. Le Gateway ne voit ni ne stocke aucune identité d'agent.
Modèle de menace du lot
| Menace | Barrière | Preuve |
|---|---|---|
| Prompt injection dans une réponse de domaine | instructions serveur déclarant tout contenu non fiable ; le Gateway n'interprète jamais un résultat | instructions vérifiées par test_mcp_gateway.py |
| Un domaine compromis prend tout le Gateway | une exception de domaine devient un isError, pas un 500 ; un backend injoignable ne retire que son namespace |
test_a_domain_that_raises_does_not_take_the_gateway_down, test_an_unreachable_cluster_backend_degrades_only_its_own_namespace |
| Écriture involontaire vers le CI dispatcher | l'adaptateur dagger.* n'a aucun chemin d'écriture : la classe client ne sait pas envoyer de corps |
test_the_dagger_adapter_has_no_write_path_at_all, test_reading_a_run_never_issues_anything_but_get |
| Argument hostile transformé en autre route | run_id validé avant construction du chemin |
test_a_crafted_run_id_never_reaches_the_dispatcher |
| Rejeu d'une mutation Kubernetes | tools/call n'est jamais réessayé ; seule la découverte idempotente l'est |
request(..., retry=...) dans mcp_upstream.py |
| Escalade via le Gateway | ni ServiceAccount, ni Secret, ni egress Internet | validate_render.py, mutations couvertes par GatewayContractTests |
| Déni de service | corps borné, rate limit, concurrence bornée, timeouts | TransportTests, RateLimitTests |
| DNS rebinding depuis le navigateur d'un opérateur | Origin validé contre une allowlist versionnée ; refus 403 |
TransportGuardTests, plus une mutation de l'allowlist rejetée par validate_render.py |
| Client annonçant une révision de protocole non servie | 400 nommant les révisions réellement servies, au lieu d'un comportement indéfini |
test_an_unsupported_revision_is_a_400_naming_what_is_served |
| Révision annoncée dont les obligations ne sont pas implémentées | l'ensemble annoncé est relié au comportement réel du transport | test_no_advertised_revision_promises_behaviour_the_transport_lacks |
| Bascule d'une porte d'entrée impossible faute de chemin réseau | les trois portes peuvent joindre le Gateway, et les deux portes MCP peuvent joindre les deux backends | FrontDoorPathTests, validate_front_door_paths |
| Écriture directe dans Plane créant une seconde source de vérité | le client Plane n'a aucune méthode qui envoie un corps ; toute mutation est un commit sur plane |
test_no_plane_api_write_is_ever_issued, test_the_plane_client_has_no_write_method |
| Écrasement d'une mutation concurrente sur l'état partagé | relecture du blob et sha renvoyé à chaque commit ; conflit -> relecture et réapplication, jamais de force |
ConcurrentWriteTests, ExhaustedRetryTests |
| Diff illisible sur un fichier partagé entre agents | une modification d'un champ réécrit une seule ligne, virgule finale comprise | ContractEditingTests |
| Credential exposé à tout processus enfant | les deux jetons sont montés en fichiers depuis des Secrets optionnels, jamais en variables d'environnement | GatewayContractTests, mutations rejetées par validate_render.py |
| Domaine promettant un backend qu'il ne peut pas joindre | les ingress de plane-api et du proxy d'egress GitHub sont vérifiés sur le catalogue rendu |
CrossNamespacePathTests, validate_cross_namespace_paths |
Limites assumées du MVP
Volontairement hors lot, et listées comme post-MVP dans #434 : RBAC fin par agent/domaine/outil, audit trail complet, quotas par agent, métriques de latence/erreur/coût, cache, circuit breaker, HA du Gateway, policy engine générique et catalogue dynamique de providers.
La révision MCP 2026-07-28 et son mode stateless sans handshake ne sont pas
couverts par ce lot : les SDK récents retombent sur le handshake de la
génération 2025, et l'évolution est suivie par le Work Item Plane
agentic-mcp-protocol-2026.
Deux limites méritent d'être nommées explicitement :
- le CI dispatcher n'a pas d'authentification intra-cluster ; l'ouverture réseau ajoutée ici est donc bornée par l'absence de chemin d'écriture côté Gateway, pas par un contrôle côté dispatcher. Un credential dispatcher pour les appelants intra-cluster relève du Lot 6 ;
- le Gateway ne distingue pas les agents entre eux : Cloudflare Access le fait en amont, mais cette identité n'est pas propagée jusqu'aux domaines. Le RBAC par agent est post-MVP.
Bascule et retour arrière
Le chemin réseau des trois portes d'entrée est ouvert par ce lot, de sorte que
chaque bascule est un changement d'URL et non une modification de
NetworkPolicy — la promesse est vérifiée sur le rendu par
validate_front_door_paths.
Le tunnel Cloudflare, lui, est piloté à distance par jeton ; sa table de routage
n'est pas versionnée. Après réconciliation Flux, l'opérateur repointe le public
hostname mcp.djayzone.com de cluster-observer:8080 vers
mcp-gateway.cluster-observer.svc.cluster.local:8080. La politique Access,
les Service Tokens et les identités par agent ne changent pas. Les deux autres
portes suivent ensuite par une variable d'environnement chacune
(mammouth-mcp-proxy, openai-tunnel-client).
Retour arrière : repointer le hostname vers cluster-observer:8080. Le serveur
MCP, son RBAC et les autres chemins restent intacts pendant toute l'opération ;
retirer gateway.yaml de la Kustomization supprime le Gateway sans toucher au
reste du namespace.
Décision complémentaire — contrat de résultat agent (PLAT-220)
Le Gateway porte désormais un envelope de résultat commun au-dessus des formats propres à chaque backend. Cette normalisation est faite au Gateway, pas dans ChatGPT : la même sémantique est ainsi disponible pour Claude, Codex, n8n et les autres consommateurs MCP.
Décisions :
- conserver
contentcomme canal métier historique pour les succès ; - ajouter
structuredContentaveccontract_version,ok,erroretmeta; - sérialiser aussi l'envelope dans le bloc texte lors d'une erreur afin de supporter les clients MCP qui ne lisent pas
structuredContent; - classer explicitement les erreurs quand le backend fournit un statut fiable, et utiliser
UNKNOWN_ERRORplutôt que d'inventer une sémantique ; - ne considérer comme rejouables que
RATE_LIMITED,BACKEND_UNAVAILABLEetTIMEOUT; une mutation n'est jamais automatiquement rejouée sur cette seule indication ; - borner et expurger les messages retournés ; les exceptions internes restent dans les logs serveur ;
- exposer dans
metauniquement le namespace logique, le nom de l'outil et la durée, sans URL interne ni credential.
Cette décision ne change ni les RBAC, ni les permissions des credentials, ni le modèle MCP : une erreur métier d'un outil reste isError=true dans une réponse JSON-RPC réussie.
Décision complémentaire — découverte MCP ciblée (PLAT-221)
gateway.describe accepte domain, profile et include_schemas. Sans argument, son format historique reste inchangé. Lorsqu'un sélecteur est fourni, la réponse est une projection des seuls outils réellement enregistrés dans le DomainRegistry ; les profils sont des allowlists explicites et versionnées, sans accès implicite à un domaine entier. La réponse ciblée évite les URLs des backends internes, ne retourne les schémas que sur demande et conserve la taxonomie PLAT-220 pour les sélecteurs erronés.
Ces profils guident la découverte et ne sont pas un mécanisme de contrôle d'accès. tools/list, tools/call, les droits Cloudflare et les autorisations propres aux backends restent identiques. Le routage/la publication dynamiques des outils restent hors périmètre (PLAT-225). L'ajout d'un outil dans un domaine ne l'ajoute pas automatiquement aux profils : une revue explicite de l'allowlist est nécessaire.
PLAT-223 — Corrélation des tâches entre les transports MCP
Un correlation_id valide fourni par le client est conservé ; sinon le Gateway génère un UUID. En complément, task_id et agent sont des indices d'observation uniquement. Le lease_id est traité comme secret potentiellement porteur de droits : seul un lease_ref dérivé d'une empreinte SHA-256 tronquée est transmis aux backends et apparaît dans les reçus. Ces indices ne sont jamais une autorisation ni une preuve de lease valide.
tools/call accepte les en-têtes X-Correlation-ID, X-Task-ID, X-Agent-ID, X-Lease-ID et la variante params._meta (djayzone/...). Les réponses structurées PLAT-220 comportent ces métadonnées, les appels vers Cluster MCP, Plane, GitHub et Dagger les relayent via en-têtes validés (sauf raw lease), et chaque exécution produit un reçu technique sans arguments, prompt ou secret.
Le contexte est isolé via contextvars par thread/requête ; tools/list, le routage, les ACL et les opérations de lease restent inchangés. L'observabilité reconstruite est limitée à l'ordre, l'outil, l'issue et les identifiants corrélés. Une chronologie complète des effets métiers nécessite de recouper les identifiants retournés par les backends. Aucun stockage de conversation ou dashboard n'est ajouté.
Décision complémentaire — transport de corrélation ChatGPT (PLAT-223)
Le relay connecté à ChatGPT n'autorise pas les agents à fournir des en-têtes MCP arbitraires ou params._meta ; la simple lecture du Task Runtime n'injecte donc pas de contexte partagé. Ajouter uniquement un champ facultatif _gateway_context dans les schémas tools/list et gateway.describe(include_schemas=true), puis le dépiler au point d'entrée tools/call avant le routage. L'objet ne transporte que correlation_id, task_id, agent, et un lease_ref public dérivé du lease, jamais le lease brut. Un task.acquire réussi publie ce lease_ref dans structuredContent.meta.
Le contexte est validé, borné et non persistant, sans modifier les arguments métier ; il doit être passé à chaque appel par l'agent. Aucune preuve de propriété du lease n'est déduite de ce champ, aucune restriction ni droit nouveau n'est créé. Les appels ne fournissant pas ce champ gardent leur comportement historique. Le schéma élargi nécessite une actualisation du manifeste côté ChatGPT après déploiement. Le test end-to-end de l'agent réel reste distinct des tests socket du Gateway.
La découverte doit utiliser le catalogue publié final de tools/list, incluant
les décorations du runtime. Lire uniquement les schémas métier de chaque domaine
dans gateway.describe masque le contexte _task ajouté par le garde-fou de
leases et provoque LEASE_REQUIRED avec les clients qui filtrent les arguments.
Les filtres de domaine/profil restent des règles de visibilité, sans changement
de droits. _gateway_context reste facultatif et déclaratif ; _task est requis
sur les mutations protégées et son lease est renouvelé/validé avant dispatch.
Les deux objets sont supprimés avant l’appel métier. Une empreinte fournie à
task.acquire comme indice de corrélation est remplacée dans la réponse par
l’empreinte du lease réellement acquis.