Skip to main content

Des MCP d'entreprise pour les agents : Azure MCP Server, Microsoft Learn et une couche tickets avec approbation humaine

Updated 12 min readMarkdown
Part of seriesIA agentique : vers Azure et au-delà !
Des MCP d'entreprise pour les agents : Azure MCP Server, Microsoft Learn et une couche tickets avec approbation humaine

Quatrième article de la série tirée du talk « IA agentique : vers Azure et au-delà ! » (Genève, 29 octobre 2026). Code complet dans aloizeau/agentic-ai-azure-and-beyond-geneve-2026, dossier demos/D-agent-ops.

Pour être utile, un agent d'exploitation doit lire l'état réel d'Azure, s'appuyer sur la documentation officielle et ouvrir un ticket. Si on branche directement l'Azure MCP Server et le serveur MCP GitHub générique sur l'agent, on obtient tout cela et bien plus qu'on ne veut : des dizaines d'outils, une identité large et aucune trace par équipe. La démo D prend une autre voie. L'équipe IA publie les outils d'entreprise derrière la passerelle, l'équipe de dev construit sa propre couche métier, et un humain approuve chaque écriture.

Une précision sur les versions : dans le dépôt, plusieurs champs des manifestes de cette démo portent la mention VÉRIFIER (chemin MCP de l'Azure MCP Server, rateLimit.local[].requests, apiKey.team dans une règle d'autorisation de backend, noms des domaines Azure MCP). Je les signale au fil du texte ; validez-les sur votre version d'agentgateway et de kagent.

Le scénario

L'utilisateur demande à l'agent assistant-ops : « Diagnostique le cluster AKS de production. » L'agent :

  1. lit l'état du cluster dans Azure, en lecture seule, et nomme l'abonnement, le groupe et la ressource interrogés ;
  2. cherche la sévérité dans le runbook de l'équipe (runbook-incidents.md) ;
  3. compare la version au calendrier de support AKS publié sur Microsoft Learn ;
  4. vérifie qu'aucun ticket ouvert ne couvre déjà le sujet ;
  5. propose un ticket sourcé, puis s'arrête jusqu'à ce qu'un humain le relise et l'approuve dans l'interface kagent.

Trois serveurs MCP, trois identités

Serveur MCP Publié par Identité vers le service Ce qu'il peut faire
Azure MCP Server équipe IA identité managée fédérée, Reader sur 2 groupes de ressources lire AKS, groupes, Monitor, Resource Health (--read-only)
Microsoft Learn Microsoft (distant) aucune (public) chercher et lire la documentation
Tickets (mcp/tickets.py) l'équipe de dev jeton d'application GitHub d'une heure, limité à ce dépôt et à issues:write créer, lister et commenter ses tickets

Le serveur GitHub générique expose le code, les PR et les workflows, et agit avec le jeton qu'on lui confie. La couche de l'équipe garde trois gestes avec ses règles métier, dans son propre serveur. Une équipe sait construire et déployer ce genre de serveur seule sur la plateforme, sans ticket à l'équipe IA.

Côté plateforme : l'Azure MCP Server en lecture seule

L'équipe IA déploie l'image officielle de Microsoft dans un namespace outils-entreprise, limitée à quatre domaines et en lecture seule.

Fichier : demos/D-agent-ops/plateforme/azure-mcp.yaml

containers:
  - name: azure-mcp
    image: mcr.microsoft.com/azure-sdk/azure-mcp:latest   # à épingler par digest
    args:
      - --transport
      - http
      - --outgoing-auth-strategy
      - UseHostingEnvironmentIdentity
      - --dangerously-disable-http-incoming-auth   # voir ci-dessous : NetworkPolicy obligatoire
      - --read-only
      - --mode
      - namespace
      - --namespace
      - aks
      - --namespace
      - group
      - --namespace
      - monitor
      - --namespace
      - resourcehealth                             # VÉRIFIER : azmcp --help
    env:
      - { name: AZURE_TOKEN_CREDENTIALS, value: WorkloadIdentityCredential }
      - { name: AZURE_MCP_COLLECT_TELEMETRY, value: "false" }
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities: { drop: [ALL] }

Le drapeau --dangerously-disable-http-incoming-auth demande une explication. L'authentification Entra ID intégrée du serveur attend un jeton émis pour sa propre application, alors qu'ici l'appelant est la passerelle, qui authentifie à sa place avec la clé de l'équipe. On désactive donc l'authentification entrante, à condition qu'une NetworkPolicy réserve l'accès aux proxys :

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: azure-mcp
  namespace: outils-entreprise
spec:
  podSelector: { matchLabels: { app: azure-mcp } }
  policyTypes: [Ingress, Egress]
  ingress:                                  # les proxys de la passerelle, rien d'autre
    - from:
        - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: agentgateway-system } }
          podSelector: { matchLabels: { gateway.networking.k8s.io/gateway-name: agentgateway-proxy } }
      ports: [{ port: 8080 }]
  egress:                                   # DNS et HTTPS public (ARM, Entra ID), jamais le réseau interne
    - to:
        - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } }
      ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }]
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except: [10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16]
      ports: [{ protocol: TCP, port: 443 }]

Les deux sélecteurs sont dans le même élément from, ce qui exige à la fois le namespace et le pod. Écrits comme deux éléments séparés, ils formeraient un OU, et n'importe quel pod d'agentgateway-system pourrait appeler le serveur. En sortie, l'exclusion de 169.254.0.0/16 bloque aussi le service de métadonnées de l'hôte. Le dépôt vérifie sans cluster, dans tests/test_manifests.py, que l'entrée est réservée aux proxys et que le serveur reste en lecture seule ; l'exclusion des plages privées n'est pas testée, relisez-la à chaque modification.

Une seule porte pour les outils d'entreprise

La passerelle fédère l'Azure MCP Server et Microsoft Learn derrière une seule route, /agents/mcp-entreprise, et préfixe les outils par leur cible : azure_* et learn_*.

Fichier : demos/D-agent-ops/plateforme/passerelle-agents.yaml

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
  name: mcp-entreprise-agents
spec:
  mcp:
    prefixMode: Always
    failureMode: FailOpen     # Learn injoignable (réseau du lieu) : Azure reste servi
    targets:
      - name: azure
        static:
          host: azure-mcp.outils-entreprise.svc.cluster.local
          port: 8080
          path: /             # VÉRIFIER : chemin MCP du serveur Azure en HTTP
          protocol: StreamableHTTP
      - name: learn
        static:
          host: learn.microsoft.com
          port: 443
          path: /api/mcp
          protocol: StreamableHTTP
          policies:
            tls:
              sni: learn.microsoft.com

Deux politiques encadrent cette route. La première exige la clé virtuelle d'une équipe et plafonne les appels d'outils en requêtes, puisque pour un outil le coût est l'appel. La seconde est une liste blanche d'outils par équipe, écrite en CEL :

apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayPolicy
metadata:
  name: outils-entreprise-autorises
spec:
  targetRefs:
    - { group: agentgateway.dev, kind: AgentgatewayBackend, name: mcp-entreprise-agents }
  backend:
    mcp:
      authorization:
        action: Allow
        policy:
          matchExpressions:          # règles en OU
            - 'mcp.tool.name in ["learn_microsoft_docs_search", "learn_microsoft_docs_fetch"]'
            - 'mcp.tool.name.startsWith("azure_") && apiKey.team in ["equipe-ops"]'

Un outil non autorisé disparaît du tools/list au lieu de renvoyer une erreur. Le modèle ne le voit jamais et n'essaie donc jamais de l'appeler. Ici, microsoft_code_sample_search n'est ouvert à personne, et seules les équipes listées voient les outils azure_*.

Côté équipe : passer par la passerelle

L'agent de l'équipe déclare les outils d'entreprise comme un RemoteMCPServer qui pointe vers la passerelle, avec la clé de l'équipe en en-tête :

Fichier : demos/D-agent-ops/kit-equipe/deploy/outils-entreprise.yaml

apiVersion: kagent.dev/v1alpha2
kind: RemoteMCPServer
metadata:
  name: outils-entreprise
spec:
  url: http://agentgateway-proxy.agentgateway-system.svc.cluster.local/agents/mcp-entreprise
  protocol: STREAMABLE_HTTP
  timeout: 60s
  headersFrom:
    - name: Authorization
      valueFrom:
        type: Secret
        name: gateway-key       # créé par Agent Ready
        key: authorization      # « Bearer sk-equipe-ops-… »

headersFrom reprend la valeur du Secret telle quelle. Agent Ready (terraform/teams/main.tf) écrit donc dans gateway-key une seconde clé, authorization, qui contient déjà le préfixe Bearer . Le namespace de l'équipe ne contient ni identité Azure ni URL externe ; tout passe par la porte qui authentifie, filtre et compte.

La couche tickets : un client GitHub avec des règles

Le serveur MCP de l'équipe ajoute trois outils. L'appel à l'API GitHub est la partie facile ; ce qui compte, ce sont les règles qui l'entourent.

L'application GitHub de la plateforme est installée sur tous les dépôts d'équipe, mais le jeton d'installation demandé ne vaut que pour un dépôt et une permission :

Fichier : demos/D-agent-ops/kit-equipe/mcp/tickets.py

def _jeton(self) -> str:
    with self._lock:
        if self._token and self.now() < self._token_exp - 300:
            return self._token
        app = {"Authorization": f"Bearer {self._jwt_app()}", "Accept": "application/vnd.github+json"}
        r = self.http.get(f"/repos/{self.repository}/installation", headers=app)
        r.raise_for_status()
        # Le jeton ne vaut que pour CE dépôt et ces permissions, même si
        # l'application est installée sur toute l'organisation.
        r = self.http.post(
            f"/app/installations/{r.json()['id']}/access_tokens",
            headers=app,
            json={"repositories": [self.repo], "permissions": {"issues": "write", "metadata": "read"}},
        )
        r.raise_for_status()
        self._token = r.json()["token"]
        self._token_exp = self.now() + 3600
        return self._token

Agent Ready pose la clé privée de l'application dans un Secret github-app du namespace de l'équipe. Le pipeline de l'équipe ne peut pas la lire, car sa ClusterRole n'inclut pas les Secrets ; seul le pod la monte.

Avant tout appel, le serveur vérifie les règles métier : longueur du titre, sévérité du runbook, identifiant Azure complet, sources limitées à Microsoft Learn, absence de doublon et plafond de créations.

def creer(self, titre, description, severite, ressource="", sources=None) -> dict:
    if not 8 <= len(titre) <= 120:
        raise TicketRefuse("titre : 8 à 120 caractères")
    if severite not in SEVERITES:
        raise TicketRefuse(f"sévérité : {', '.join(SEVERITES)}")
    if ressource and not ressource.startswith("/subscriptions/"):
        raise TicketRefuse("ressource : identifiant Azure complet (/subscriptions/…), ou vide")
    for s in sources:
        if not re.match(r"^https://learn\.microsoft\.com/", s):
            raise TicketRefuse(f"source non autorisée : {s} (Microsoft Learn uniquement)")

    # Pas de doublon : même titre, ticket ouvert → on le renvoie.
    for i in self._tickets_agent("open"):
        if i["title"].strip().lower() == titre.lower():
            return {"numero": i["number"], "url": i["html_url"], "doublon": True}

    with self._lock:                         # fenêtre glissante : 5 tickets en 10 min
        t = self.now()
        while self._creations and t - self._creations[0] > self.fenetre_s:
            self._creations.popleft()
        if len(self._creations) >= self.max_creations:
            raise TicketRefuse(f"plafond atteint : {self.max_creations} tickets en {self.fenetre_s // 60} min")
        self._creations.append(t)
    ...

Les outils MCP attrapent TicketRefuse et renvoient {"refuse": "<règle>"} comme une réponse normale. Le prompt système demande à l'agent d'expliquer la règle à l'utilisateur sans chercher à la contourner. Une exception lui aurait donné un message technique opaque, et l'envie de réessayer autrement.

@mcp.tool()
def creer_ticket(titre: str, description: str, severite: str,
                 ressource_azure: str = "", sources_learn: list[str] | None = None) -> dict:
    """Crée un ticket (issue GitHub) dans le dépôt de l'équipe. ..."""
    try:
        return tickets.creer(titre, description, severite, ressource_azure, sources_learn)
    except TicketRefuse as e:
        return {"refuse": str(e)}

L'agent ne peut commenter qu'un ticket qui porte l'étiquette agent, et ne réécrit donc jamais la discussion d'un humain. Ces règles sont couvertes par tests/test_tickets.py, avec un GitHub simulé.

L'approbation humaine, déclarée dans l'agent

Les deux outils qui écrivent sont soumis à approbation dans kagent. L'agent s'arrête, l'interface affiche l'appel et ses arguments, et rien ne part sans un clic.

Fichier : demos/D-agent-ops/kit-equipe/deploy/assistant-ops.yaml

tools:
  - type: McpServer
    mcpServer:
      apiGroup: kagent.dev
      kind: RemoteMCPServer
      name: outils-entreprise          # aucun nom d'outil : la passerelle décide
  - type: McpServer
    mcpServer:
      apiGroup: kagent.dev
      kind: RemoteMCPServer
      name: docs-mcp
      toolNames: [rechercher, lire_document, lister_tickets, creer_ticket, commenter_ticket]
      requireApproval: [creer_ticket, commenter_ticket]   # l'agent propose, un humain valide

Le premier mcpServer ne liste aucun outil, parce que la liste est celle que la passerelle autorise pour l'équipe. Deux listes à tenir à jour finiraient par diverger.

Les personnes voient aussi les outils des équipes dans LibreChat, via /mcp-equipes. Une politique les y limite à la lecture, pour que créer ou commenter un ticket reste un geste de l'agent, approuvé, et pas un bouton ouvert à tout le tenant.

backend:
  mcp:
    authorization:
      action: Allow
      policy:
        matchExpressions:
          - '!mcp.tool.name.endsWith("_creer_ticket") && !mcp.tool.name.endsWith("_commenter_ticket")'

Tester la liste blanche depuis PowerShell

Le helper Invoke-Mcp du dépôt (demos/lib.ps1) enchaîne initialize, notifications/initialized et la requête, et lit une réponse JSON ou SSE. Comparez ce que voient deux clés :

. ./demos/lib.ps1
Start-Gateway

function Get-TeamKey([string]$Ns) {
  $b64 = kubectl -n $Ns get secret gateway-key -o jsonpath='{.data.key}'
  [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b64))
}

foreach ($ns in 'equipe-ops', 'kagent') {          # kagent porte la clé « platform »
  $r = Invoke-Mcp -Path '/agents/mcp-entreprise' -Token (Get-TeamKey $ns) -Method 'tools/list'
  "{0} : HTTP {1}" -f $ns, $r.Code
  $r.Result.result.tools.name | Sort-Object | ForEach-Object { "   $_" }
}
# attendu : equipe-ops voit azure_* et learn_* ; platform ne voit que les deux outils learn_*

Sans clé, la même requête renvoie 401 dès initialize. Je garde ce script comme test de non-régression : si une modification de la liste blanche ouvre Azure à une équipe non listée, on le voit tout de suite.

Qui protège quoi

Couche Protection
Passerelle clé virtuelle obligatoire (401), liste blanche d'outils par équipe, plafond d'appels, comptage par équipe, serveur et outil
kagent requireApproval sur les deux outils qui écrivent
Serveur de l'équipe un dépôt, trois gestes, sources Learn seulement, dédoublonnage, 5 créations par 10 min, commentaires sur ses seuls tickets
Azure --read-only, rôle Reader, serveur joignable par les seuls proxys

Chaque couche rattrape une défaillance de la précédente. Si l'approbation est contournée, le serveur refuse les doublons et plafonne les créations. Si le serveur a un bug, le jeton GitHub ne vaut que pour un dépôt. Et un agent manipulé par une injection ne voit que les outils de lecture Azure.

Les limites que j'assume

Tous les agents autorisés lisent Azure en Reader avec la même identité, car l'Azure MCP Server ne sait pas qui est derrière l'agent. L'étape suivante documentée par Microsoft est --outgoing-auth-strategy UseOnBehalfOf, qui propage l'identité de l'utilisateur ; c'était le sujet de la slide « Où l'identité se perd ».

Le plafond de créations est tenu en mémoire, par réplica : avec deux réplicas, l'agent peut ouvrir dix tickets en dix minutes. Un compteur partagé, dans Redis ou via l'API GitHub, le rendrait global.

Microsoft Learn dépend du réseau, d'où failureMode: FailOpen sur ce backend. Sans Learn, l'agent diagnostique quand même, mais sans sources.

Le code

Le dernier article mesure ce que tout cela coûte, par personne, par équipe et par modèle.

About the author

  • Ayant eu de multiples expériences dans le monde du conseil, j'ai pu acquérir une expertise dans la conception et la construction de services de collaboration d'entreprise. Je suis passionné par les technologies Microsoft et surtout Azure. Aujourd'hui je possède une solide expérience en méthodologie de développement et j'ai mené des équipes de développement technique au succès. Je possède également une solide connaissance de l'infrastructure qui fait de moi une ressource efficace pour mettre en œuvre la transformation numérique vers le cloud Microsoft. Je suis un professionnel efficace et honnête qui aime relever les challenges. Aimant partager mes connaissances; je suis à l'aise en tant que Technical Leader et en tant que membre d'une équipe. Mes compétences techniques sont les suivantes: Azure, DevOps, Architecture Applicative, Développement de solution Cloud Native, écosystème Microsoft... et bien d'autres. Pour voir mes certifications Microsoft : https://www.youracclaim.com/users/antoine-loizeau

No comments yet. Be the first to comment.