Aller au contenu

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-18 en Streamable HTTP : un POST JSON-RPC par requête, 405 sur le GET de flux serveur qu'il n'offre pas, 202 sur 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. Origin est validé contre une allowlist déclarée en GitOps (protection DNS rebinding), et un MCP-Protocol-Version hors des révisions servies reçoit un 400. 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 envoie Origin.
  • Une seule révision est annoncée, celle qui est réellement servie. 2025-03-26 impose la réception de batches JSON-RPC, que ce transport refuse depuis leur retrait en 2025-06-18 ; 2024-11-05 suppose l'ancien transport HTTP+SSE à deux endpoints. Les annoncer promettrait des obligations non tenues, donc SUPPORTED_PROTOCOL_VERSIONS se limite à 2025-06-18 et 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 transforme cluster.pods_list en cluster_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 par PLANE_WRITE_MODE (git, dual par défaut, api). Le mode dual écrit les deux et reste le seul sûr tant que .plane/sync.py ré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 appelle slack-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() et health(). Ajouter github.* 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-client et mammouth-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 content comme canal métier historique pour les succès ;
  • ajouter structuredContent avec contract_version, ok, error et meta ;
  • 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_ERROR plutôt que d'inventer une sémantique ;
  • ne considérer comme rejouables que RATE_LIMITED, BACKEND_UNAVAILABLE et TIMEOUT ; 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 meta uniquement 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.