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 :
- lit l'état du cluster dans Azure, en lecture seule, et nomme l'abonnement, le groupe et la ressource interrogés ;
- cherche la sévérité dans le runbook de l'équipe (
runbook-incidents.md) ; - compare la version au calendrier de support AKS publié sur Microsoft Learn ;
- vérifie qu'aucun ticket ouvert ne couvre déjà le sujet ;
- 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
demos/D-agent-ops/README.md: déroulé de la démo, application GitHub et points à validerdemos/D-agent-ops/plateforme/: Azure MCP Server, route et politiques publiés par l'équipe IAdemos/D-agent-ops/kit-equipe/: la couche tickets, l'agent et le runbook de l'équipedemos/D-agent-ops/tests/: 22 tests, tickets, serveur et manifestes
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
Comments
Join the discussionNo comments yet. Be the first to comment.
IA agentique : vers Azure et au-delà !
Série d'article tirée du talk « IA agentique : vers Azure et au-delà ! » (Chalet Azure Romandie, Genève, 29 octobre 2026). Tout le code est dans le dépôt aloizeau/agentic-ai-azure-and-beyond-geneve-2026.





