Runtime MCP (ToolHive Operator)
Version anglaise : en/mcp-runtime.md.
Le Runtime MCP exécute dans le cluster les serveurs MCP (Model Context Protocol) des agents et des utilisateurs. Il repose sur le ToolHive Kubernetes Operator de Stacklok (Apache-2.0, auto-hébergé, aucun service externe), version 0.59.0. Chaque instance MCP est éphémère et isolée par tenant, projet et utilisateur : le contrôleur de cycle de vie du Control Plane la crée à la demande et la détruit dès qu'elle n'est plus utilisée.
1. Architecture : Gateway et Runtime
| Brique | Rôle | Où |
|---|---|---|
| MCP Gateway | Point d'entrée unique des appels MCP des agents et des utilisateurs : authentification (Keycloak), autorisation par utilisateur et par agent (OpenFGA, OPA), journal d'audit du Control Plane, routage vers l'instance. | Control Plane, namespace enterprise-ai |
| MCP Runtime | Exécution des serveurs MCP : un namespace par projet, une instance par utilisateur et modèle de serveur, proxy ToolHive authentifiant devant chaque serveur. | ToolHive Operator (toolhive-system) et namespaces mcp-<12 hex> |
Flux d'un appel :
- L'agent ou l'utilisateur appelle la Gateway du Control Plane.
- Le contrôleur de cycle de vie crée au besoin le namespace du projet puis l'instance (MCPServer et objets associés).
- L'opérateur ToolHive réconcilie le MCPServer : un proxy (
eai.io/mcp-role: proxy, port 8080) et le serveur MCP (eai.io/mcp-role: server). - La Gateway appelle le proxy avec un JWT court signé par le Control Plane. Le proxy vérifie la signature avec le JWKS public du Control Plane (route
/v1/mcp/runtime/jwks,EAI_MCP_RUNTIME_JWKS_URL), l'émetteur (EAI_MCP_RUNTIME_ISSUER) et l'audience, puis applique sa politique Cedar. - À la fin de l'usage, le contrôleur supprime l'instance et révoque ses identifiants temporaires. Si un MCPServer disparaît côté cluster, le contrôleur clôt l'instance de la même façon.
Objets créés par le contrôleur (contrat partagé avec services/control-plane) :
| Portée | Objets |
|---|---|
| Namespace de projet | Namespace mcp-<12 hex de sha256(tenant_id/project_id)> avec les étiquettes eai.io/mcp-runtime: "true", eai.io/tenant, eai.io/project, app.kubernetes.io/managed-by: eai-mcp-runtime et pod-security.kubernetes.io/enforce, audit, warn à restricted ; NetworkPolicy default-deny (entrée et sortie), ResourceQuota mcp-quota, LimitRange mcp-limits, MCPOIDCConfig eai-mcp-runtime |
| Instance | MCPServer inst-<10 hex>, MCPToolConfig du même nom, Secret inst-<10 hex>-env, CiliumNetworkPolicy inst-<10 hex> ; étiquettes eai.io/mcp-instance (UUID), eai.io/mcp-template, eai.io/owner (16 hex de sha256 de l'identifiant utilisateur, jamais l'identité), eai.io/tenant, eai.io/project, app.kubernetes.io/managed-by: eai-mcp-runtime |
| Pods | eai.io/mcp-instance et eai.io/mcp-role (proxy ou server) |
2. Installation
Les fichiers :
| Fichier | Contenu |
|---|---|
deploy/toolhive/crds-values.yaml | Valeurs du chart toolhive-operator-crds 0.59.0 (crds.keep: true) |
deploy/toolhive/operator-values.yaml | Valeurs du chart toolhive-operator 0.59.0 : 2 réplicas avec élection de leader, operator.rbac.scope: cluster, ressources, contextes de sécurité, images épinglées par digest |
deploy/toolhive/k8s/namespace.yaml | Namespace toolhive-system (Pod Security restricted, étiquettes tenant: platform, owner: platform-team) |
deploy/toolhive/k8s/operator.yaml | PodDisruptionBudget, Service de métriques et ServiceMonitor de l'opérateur (le chart ne les fournit pas) |
deploy/argocd/repository-toolhive.yaml | Déclaration du registre OCI ghcr.io/stacklok/toolhive dans Argo CD (enableOCI) |
deploy/policies/kyverno/mcp-runtime.yaml | ClusterPolicy eai-mcp-runtime (Enforce) |
deploy/cilium/mcp-runtime.yaml | Politiques réseau de l'opérateur, des proxies et du Control Plane |
deploy/helm/enterprise-ai/templates/control-plane/mcp-runtime-rbac.yaml | ClusterRole et ClusterRoleBinding eai-mcp-runtime-controller |
Composants Argo CD (deploy/argocd/applicationset.yaml) :
| Composant | Couche | Source |
|---|---|---|
toolhive-operator-crds | infra, avant kyverno-policies (la politique cible le type MCPServer) | chart OCI ghcr.io/stacklok/toolhive/toolhive-operator-crds 0.59.0 |
toolhive-operator | platform | chart OCI ghcr.io/stacklok/toolhive/toolhive-operator 0.59.0 |
toolhive-runtime | platform | deploy/toolhive/k8s |
Argo CD ne tire un chart Helm d'un registre OCI que si ce registre est déclaré avec enableOCI, et l'adresse s'écrit sans le préfixe oci:// : appliquez deploy/argocd/repository-toolhive.yaml avec l'AppProject et l'ApplicationSet (installation.md). L'AppProject autorise ghcr.io/stacklok/toolhive. Le namespace toolhive-system est créé par le module deploy/terraform/modules/platform-bootstrap, comme les autres namespaces de la plateforme.
Images (miroir Harbor, digest de l'index OCI lu sur ghcr.io pour le tag v0.59.0 ; copiez avec skopeo copy --all pour garder le même digest, puis signez avec la clé interne comme les autres images du miroir) :
| Image amont | Digest |
|---|---|
ghcr.io/stacklok/toolhive/operator:v0.59.0 | sha256:b094d9220a687ef975a62f9c073fa5faf433e88c3f355f8ad29f50cd082e430c |
ghcr.io/stacklok/toolhive/proxyrunner:v0.59.0 | sha256:f0e970f749ef10004d150721311fa8512ba4c349768ca7b1915ff77452750250 |
ghcr.io/stacklok/toolhive/vmcp:v0.59.0 | sha256:e9077bc25803740423a04333d8400a02b6f5f44d5ba7c6b5edf65437ce1fe0dd |
ghcr.io/stacklok/thv-registry-api:v1.5.3 | sha256:323652f7bd195a0a6a84815e749f52a809633a07d911580abb2d58945abc78f3 |
Artefacts des charts : toolhive-operator 0.59.0 sha256:0920e45a4d371a6a9096c0019afea688e3aecbd1167182f5dacfe86b37605449, toolhive-operator-crds 0.59.0 sha256:b7694eb741a04043818978c34b0136cf8cef30329a95ae16de518bd02daaf620.
Activation côté Control Plane, dans les valeurs du chart deploy/helm/enterprise-ai :
mcpRuntime:
enabled: true
issuer: "" # vide : http://<release>-control-plane.<namespace>.svc.cluster.local:8080/v1/mcp/runtime
jwksUrl: "" # vide : <issuer>/jwks
gatewayNamespace: "" # vide : namespace de la release
La valeur ajoute la ClusterRole eai-mcp-runtime-controller (namespaces : get, list, create, patch, delete ; resourcequotas, limitranges, secrets, networkpolicies, ciliumnetworkpolicies, mcpservers, mcpoidcconfigs, mcptoolconfigs : get, list, watch, create, patch, update, delete), sa liaison au compte de service du Control Plane et les variables EAI_MCP_RUNTIME_ENABLED, EAI_MCP_RUNTIME_ISSUER, EAI_MCP_RUNTIME_JWKS_URL, EAI_MCP_RUNTIME_GATEWAY_NAMESPACE du Deployment du Control Plane. Par défaut elle vaut false : rien n'est rendu.
3. Durcissement
Authentification obligatoire. ToolHive n'active pas l'authentification par défaut : un MCPServer sans configuration OIDC expose un proxy anonyme. Chaque MCPServer référence donc la MCPOIDCConfig eai-mcp-runtime du namespace (spec.oidcConfigRef avec nom et audience propre à l'instance). Le proxy n'accepte que les JWT émis par le Control Plane et vérifiés avec son JWKS public. La règle Kyverno mcpserver-oidc refuse tout MCPServer sans oidcConfigRef.
Autorisation Cedar. Chaque instance porte sa politique Cedar inline (spec.authzConfig) ou une référence (spec.authzConfigRef) : seul le propriétaire de l'instance (empreinte eai.io/owner) appelle ses outils, et le MCPToolConfig restreint la liste des outils exposés. Règle Kyverno mcpserver-authz.
Audit. spec.audit.enabled: true sur chaque MCPServer (règle mcpserver-audit) : le proxy écrit un événement d'audit par appel, envoyé dans l'index séparé décrit plus bas.
Isolation réseau.
- Namespace de projet : NetworkPolicy
default-denycréée par le contrôleur et maintenue par la règle Kyvernogenerate-default-deny(synchronize: true, recréée si elle est modifiée ou supprimée). - Proxies (
deploy/cilium/mcp-runtime.yaml,eai-mcp-runtime-proxy) : entrée depuis le Control Plane seul, sur 8080 ; sortie vers le Control Plane pour le seul chemin JWKS (règle HTTP L7), vers l'API Kubernetes (attache au serveur stdio) et le DNS du cluster. - Serveurs MCP : aucune sortie hors de la CiliumNetworkPolicy
inst-<10 hex>de l'instance (domaines exacts du modèle de serveur). - Opérateur :
default-denydanstoolhive-system, sortie vers l'API Kubernetes et le DNS seulement, entrée des métriques depuis Prometheus (observability). - Control Plane :
eai-mcp-runtime-control-planeouvre la sortie vers les proxies et l'entrée JWKS depuis les proxies.
Pod Security. Les namespaces de projet sont en restricted (enforce, audit, warn), exigé par la règle namespace-name-and-labels ; toolhive-system aussi. Le conteneur mcp du podTemplateSpec : readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities.drop: [ALL], runAsNonRoot: true ; au niveau du pod : seccompProfile: RuntimeDefault, runAsNonRoot: true, automountServiceAccountToken: false (règles mcpserver-mcp-container-*). Les politiques générales de deploy/policies/kyverno/pod-network-security.yaml et cluster-policies.yaml s'appliquent en plus à tous les pods créés.
Quotas. ResourceQuota mcp-quota et LimitRange mcp-limits dans chaque namespace de projet, créés par le contrôleur : un projet ne peut pas épuiser le cluster.
Images épinglées. Opérateur, proxy et images annexes par digest (tableau ci-dessus) ; l'image de chaque MCPServer doit porter @sha256: (règle mcpserver-image-digest).
Kyverno, politique eai-mcp-runtime (Enforce).
| Règle | Contrôle |
|---|---|
namespace-writers | Seul system:serviceaccount:enterprise-ai:enterprise-ai-control-plane crée, modifie ou supprime un namespace eai.io/mcp-runtime=true |
runtime-object-writers | Même restriction pour les MCPServer, MCPToolConfig, MCPOIDCConfig, Secrets et CiliumNetworkPolicy de ces namespaces ; l'opérateur ne peut que mettre à jour ses propres ressources (finaliseurs), le contrôleur de namespaces et le ramasse-miettes que supprimer |
namespace-name-and-labels, namespace-name-format, mcp-name-requires-label | Nom mcp-<12 hex>, étiquettes Pod Security, tenant, projet ; un namespace nommé ainsi porte forcément l'étiquette |
mcpserver-oidc, mcpserver-authz, mcpserver-audit, mcpserver-image-digest | OIDC, Cedar, audit, digest |
mcpserver-mcp-container-present, mcpserver-mcp-container-hardened, mcpserver-mcp-container-drops-all | Conteneur mcp présent et durci, pas de jeton de compte de service |
generate-default-deny | NetworkPolicy default-deny synchronisée |
Cas de test : deploy/policies/kyverno/tests/mcp-runtime (ressources conformes et non conformes, requêtes du Control Plane et d'un administrateur du cluster).
Périmètre RBAC. L'opérateur tourne en operator.rbac.scope: cluster car les namespaces de projet sont créés dynamiquement ; les garde-fous sont Kyverno et Cilium. La ClusterRole du Control Plane donne l'accès aux Secrets de tout le cluster en lecture : Kyverno borne les écritures, pas les lectures. Gardez le journal d'audit de l'API Kubernetes actif sur les Secrets et surveillez les lectures du compte enterprise-ai-control-plane hors des namespaces mcp-*.
4. Journaux d'audit séparés
- Alloy (
deploy/observability/alloy/values.yaml) retire les pods des namespacesmcp-<12 hex>du flux des journaux de la plateforme et les lit dans un flux à part (pods étiquetéseai.io/mcp-instance), étiquetéeai_stream=mcp-runtime-auditavec l'instance, le rôle, le tenant et le projet. Kyverno réserve ce nom aux namespaces étiquetéseai.io/mcp-runtime=true, la sélection par nom est donc équivalente à la sélection par étiquette. - Le collecteur OpenTelemetry (
deploy/observability/otel-collector) écarte ces enregistrements du pipelinelogs(indexeai-logs-*et ClickHouse) et les écrit par le pipelinelogs/mcp-runtime-auditdans l'index quotidieneai-mcp-runtime-audit-yyyy.MM.dd, après les mêmes filtres de données personnelles. - OpenSearch (
deploy/observability/opensearch) : modèle d'indexeai-mcp-runtime-audit, politique ISMeai-mcp-runtime-audit-retention(lecture seule après 2 jours, suppression après 400 jours), installés parbootstrap.py. Lecture réservée au rôleeai_mcp_runtime_audit_reader(rôle d'arrière-plan OpenSearcheai-security-officer, comme pour Security Analytics) ; OpenSearch Dashboards et les autres lecteurs de journaux n'y ont pas accès. - Le journal d'audit du Control Plane (chaîne de hachage PostgreSQL, exports WORM) reste la preuve de référence ; cet index sert aux investigations sur les instances.
5. Exploitation
- Pilotage : ToolHive UI est une application de bureau locale, destinée au poste d'un développeur ; il n'y a pas d'interface web ToolHive côté cluster et l'ancienne Cloud UI n'est plus proposée. Le pilotage se fait dans le Navin Manager (état de l'opérateur, instances par tenant et projet, destruction) et dans le portail (instances de l'utilisateur).
- État de l'opérateur : 2 réplicas, un seul actif (bail
toolhive-operator-leader-electiondu namespacetoolhive-system), sondes/healthzet/readyzsur 8081, métriques sur 8080 (ServiceMonitortoolhive-operator). - Instances : MCPServer étiquetés
app.kubernetes.io/managed-by=eai-mcp-runtimedans tous les namespaces, phasePending,Ready,Failed,TerminatingouStopped. - Destruction : depuis le Navin Manager ou le portail. Supprimer un MCPServer est sûr : le contrôleur révoque les identifiants temporaires et met l'instance en veille ; l'appel suivant de son propriétaire la recrée. Le Navin Manager redémarre aussi une instance (annotation
mcpserver.toolhive.stacklok.dev/restarted-at) ; ses identités sont listées dans la ConfigMapkyverno/eai-mcp-runtime-operators(deploy/policies/kyverno/mcp-runtime-operators.yaml), seules autorisées à supprimer et à redémarrer, jamais à modifier la spécification. - Intervention d'urgence : Kyverno refuse les écritures d'un administrateur dans les namespaces
mcp-*. Passez par le Control Plane ; sinon, une modification revue de la politique dans Git (Argo CD), jamais une désactivation à la main. - Mise à niveau : CRD d'abord (
toolhive-operator-crds), puis l'opérateur, avec les nouveaux digests ; les CRD sont conservées à la désinstallation (crds.keep). - Panne de l'opérateur : les instances en cours continuent de servir ; aucune nouvelle instance n'est réconciliée tant qu'aucun réplica ne détient le bail.
6. Aucun MCP en ligne, catalogue de l'entreprise dans SeaweedFS
- Les utilisateurs n'utilisent jamais un serveur MCP en ligne. La passerelle n'atteint que des adresses internes : serveurs hébergés par le Navin Manager (
EAI_EGRESS_PRIVATE_ALLOWLIST), instances du Runtime MCP, hôtes internes où l'entreprise héberge les serveurs qu'elle développe (EAI_MCP_INTERNAL_HOSTS: hôte exact, hôte:port,*.suffixeou CIDR). Un hôte public est refusé à l'enregistrement et à chaque appel (403mcp_online_forbidden, auditsecurity.egress.denied, métriqueeai_mcp_online_refused_total) ; un nom déclaré qui se résout vers une adresse publique est refusé à la connexion. Un préréglage en ligne de la Bibliothèque Navin (hosting: online) ne s'installe qu'avec l'adresse interne où l'entreprise l'héberge. Le registre MCP du Navin Manager applique la même règle et le service mcp-host lance ses serveurs hors ligne (npm, uv et pip sans téléchargement, sans proxy). - Les modèles du Runtime MCP viennent du catalogue de l'entreprise dans SeaweedFS : objet
EAI_MCP_CATALOG_KEY(défautmcp-runtime/templates.yaml) du profil de stockage plateformeEAI_MCP_CATALOG_STORAGE_PROFILE, relu toutes lesEAI_MCP_CATALOG_REFRESH_SECONDS(300). Une image doit venir d'un registre interne (EAI_MCP_RUNTIME_IMAGE_REGISTRIES, défaut<harbor-host>) et être épinglée par digest. Une version illisible ou invalide ne retire jamais les modèles en service : la dernière version valide continue de servir, marquée périmée (eai_mcp_catalog_stale, alerteEaiMcpCatalogStale). En production, sans profil, la création répond 503mcp_catalog_not_configured. Publication par l'équipe plateforme :
cd services/control-plane
.venv/bin/python -m eai.domains.mcp_runtime.catalog_cli validate templates.yaml
.venv/bin/python -m eai.domains.mcp_runtime.catalog_cli publish templates.yaml
7. Cycle de vie : destruction dès la panne, réveil à l'appel
La boucle du contrôleur tourne dans les pods de l'API du Control Plane, seuls autorisés à appeler l'API Kubernetes, une réplique à la fois (verrou consultatif PostgreSQL), toutes les EAI_MCP_RUNTIME_REAPER_SECONDS (60).
| Situation | Effet |
|---|---|
Inactivité au-delà de la durée de la politique (900 s par défaut), fin de la durée de vie d'une génération, arrêt par le propriétaire (POST .../stop) | Veille : identifiants révoqués, ressources ToolHive supprimées, entrée de passerelle gardée |
| Appel d'une instance en veille par son propriétaire ou un de ses agents | Recréation : modèle relu dans le catalogue, politique vérifiée de nouveau, nouvelle génération et nouveaux identifiants ; l'appel attend jusqu'à EAI_MCP_RUNTIME_WAKE_TIMEOUT_SECONDS (90) |
MCPServer Failed, condition *Validated à False, pods en CrashLoopBackOff, image introuvable, conteneur qui ne démarre pas, MCPServer jamais pris en charge par l'opérateur, délai de démarrage dépassé, deux sondes /health du proxy en échec, proxy qui ne répond pas à un appel | Destruction immédiate puis veille ; l'appel suivant la recrée. Un appel dont la requête n'a pas atteint le serveur, ou sans effet de bord, est rejoué une fois sur l'instance recréée |
EAI_MCP_RUNTIME_FAILURE_LIMIT pannes (3) en EAI_MCP_RUNTIME_FAILURE_WINDOW_SECONDS (900), configuration refusée par ToolHive, modèle retiré du catalogue, politique du projet | Échec : appels refusés (mcp_instance_failed) jusqu'au redémarrage explicite par le propriétaire (POST .../start) |
8. Traçabilité par défaut
- Chaque espace de noms de projet reçoit le MCPTelemetryConfig
eai-mcp-telemetry: traces et métriques des proxys ToolHive en OTLP/HTTP avec TLS vers le collecteur de la plateforme (EAI_MCP_RUNTIME_OTEL_ENDPOINT, échantillonnageEAI_MCP_RUNTIME_OTEL_SAMPLING_RATE, 1.0 par défaut), la CA interne copiée depuisEAI_MCP_RUNTIME_OTEL_CA_FILE(Secreteai-internal-cadu namespace de la release, monté par le chart). Kyverno refuse un MCPServer sanstelemetryConfigRef. - La passerelle propage le contexte W3C (
traceparent) au proxy : la trace d'un appel continue dans ToolHive. Chaque appel est compté (eai_mcp_calls_totalpar cible, méthode et résultat) et porté par le spaneai.mcp.proxy. - Le journal de cycle de vie
eai.mcp_runtime_eventsporte letrace_idde la requête et la génération de l'instance ; les métriqueseai_mcp_runtime_instances,eai_mcp_runtime_lifecycle_total,eai_mcp_runtime_failures_totaleteai_mcp_runtime_wake_secondssuivent les instances ; les journaux des pods vont dans l'index séparéeai-mcp-runtime-audit. - Alertes (groupe
eai-mcp-runtime) :EaiMcpRuntimeFailures,EaiMcpRuntimeInstancesFailed,EaiMcpCatalogStale,EaiMcpOnlineServerRefused,EaiMcpCallErrorRatio.
9. Ce qui est repris du guide ToolHive
| Section du guide run-mcp-k8s | Dans la plateforme |
|---|---|
| Créer un serveur MCP, transports stdio et Streamable HTTP | MCPServer écrit par le contrôleur depuis le modèle du catalogue (transport, mcpPort) |
| Gestion automatique du RBAC, ServiceAccount existant | Compte de service propre à chaque proxy, jamais de jeton monté dans le conteneur MCP |
| Personnaliser le pod, serveur avec secrets, volume | podTemplateSpec durci (non root, racine en lecture seule, aucune capacité, seccomp), Secret propre à l'instance, /tmp borné |
| État d'un serveur, dépannage, référence des conditions | Phase, conditions *Validated, pods et sondes lus par le contrôleur ; raison et message affichés dans le Manager et le portail |
| Redémarrer un serveur | Action Redémarrer du Navin Manager (annotation restarted-at) |
| Groupes et agrégation, mise à l'échelle, affinité et routage de session, vidange | Non repris : une instance par utilisateur et par projet, une réplique, sans agrégation entre utilisateurs (isolation) |
10. Vérifications hors ligne
helm lint deploy/helm/enterprise-ai --set mcpRuntime.enabled=true
/tmp/toolchains/kyverno test deploy/policies/kyverno/tests
cd services/control-plane && .venv/bin/python -m pytest -q -p no:cacheprovider tests/test_platform_mcp_runtime.py
tests/test_platform_mcp_runtime.py lit les fichiers, rend les deux charts ToolHive (helm template, réseau vers ghcr.io) et le chart de la plateforme avec et sans mcpRuntime.enabled, puis lance kyverno test et kyverno apply ; il échoue si helm ou kyverno manque.