0002-mcp-gateway-Use-official-standalone-Helm-chart.patch
Rama master · réplica de lectura del repositorio original.
From c2516181fc1c73ea617661d77f4e0a91fdf0a490 Mon Sep 17 00:00:00 2001
From: Alexander Gil <pando855@gmail.com>
Date: Mon, 9 Nov 2026 07:14:10 +0100
Subject: [PATCH 2/2] mcp-gateway: Use official standalone Helm chart
Migrate from app-template wrapper to official
agentgateway-standalone chart (oci, digest 8c3fcfb7f).
- Chart.yaml: dependency to agentgateway-standalone:1.7.0
- values.yaml: restructured for official chart
- fullnameOverride: mcp-gateway-standalone (avoids
immutable Deployment selector conflict)
- templates/ingress.yaml: new, points to new service
- templates/configmap.yaml: deleted (chart renders it)
- templates/networkpolicy.yaml: deleted (no NP in
foundation baseline)
- foundation-checks.py: adapted (51 tests pass)
Verified: nodeSelector prusik, routes empty, no
NetworkPolicy rendered, adminAddr off, SESSION_KEY
from existing immutable ESO secret, image v1.7.0,
TLS on nginx-external.
---
apps/mcp-gateway/Chart.yaml | 6 +-
apps/mcp-gateway/templates/configmap.yaml | 168 ------
apps/mcp-gateway/templates/ingress.yaml | 38 ++
apps/mcp-gateway/templates/networkpolicy.yaml | 146 -----
apps/mcp-gateway/values.yaml | 547 ++++++------------
test/mcp-gateway/foundation-checks.py | 96 ++-
6 files changed, 256 insertions(+), 745 deletions(-)
delete mode 100644 apps/mcp-gateway/templates/configmap.yaml
create mode 100644 apps/mcp-gateway/templates/ingress.yaml
delete mode 100644 apps/mcp-gateway/templates/networkpolicy.yaml
diff --git a/apps/mcp-gateway/Chart.yaml b/apps/mcp-gateway/Chart.yaml
index b79fbcd30..0d6ac5471 100644
--- a/apps/mcp-gateway/Chart.yaml
+++ b/apps/mcp-gateway/Chart.yaml
@@ -2,6 +2,6 @@ apiVersion: v2
name: mcp-gateway
version: 0.0.0
dependencies:
- - name: app-template
- version: 5.3.0
- repository: https://bjw-s-labs.github.io/helm-charts/
+ - name: agentgateway-standalone
+ version: 1.7.0
+ repository: oci://cr.agentgateway.dev/charts
diff --git a/apps/mcp-gateway/templates/configmap.yaml b/apps/mcp-gateway/templates/configmap.yaml
deleted file mode 100644
index 9069aa6f2..000000000
--- a/apps/mcp-gateway/templates/configmap.yaml
+++ /dev/null
@@ -1,168 +0,0 @@
-apiVersion: v1
-kind: ConfigMap
-metadata:
- name: {{ .Release.Name }}-config
- # Same two labels the NetworkPolicy selects the gateway pods by, so the config
- # and its workload are findable together. The repo's auxiliary templates carry
- # no helper-generated labels (see apps/antdroid/templates/configmap.yaml).
- labels:
- app.kubernetes.io/name: {{ .Release.Name }}
- app.kubernetes.io/instance: {{ .Release.Name }}
-data:
- config.yaml: |
- config:
- adminAddr: "off"
- logging:
- format: json
- level: {{ .Values.logLevel }}
-
- gateways:
- default:
- port: {{ .Values.gateway.port }}
-
- {{- $enabled := list }}
- {{- range $name, $backend := .Values.backends }}
- {{- if $backend.enabled }}
- {{- $enabled = append $enabled (dict "name" $name "backend" $backend) }}
- {{- end }}
- {{- end }}
- # Fail closed: no routes until a backend is enabled. A route with no enabled
- # backend would still fetch a JWKS, and routes are only useful together with the
- # Kanidm OAuth2 client and a scopeMap group, so the gateway boots with `routes: []`
- # and answers 404 for everything.
- {{- if not $enabled }}
- routes: []
- {{- else }}
- routes:
- {{- range $entry := $enabled }}
- {{- $b := $entry.backend }}
- - name: {{ $entry.name }}
- matches:
- - path:
- exact: {{ $b.path }}
- # RFC 9728 protected-resource metadata has to be reachable on its own path.
- # Without this match the gateway answers 404 for the very URL its 401
- # challenge points clients at, so an MCP client cannot discover the
- # authorization server (measured against the pinned binary).
- - path:
- exact: /.well-known/oauth-protected-resource{{ $b.path }}
- policies:
- # No CORS policy on purpose: callers here are native clients (OpenCode,
- # Hermes), not browser pages, so a wildcard origin would only widen the
- # attack surface. If a browser-based MCP client is ever approved, add an
- # explicit allowOrigins list for that origin.
- mcpAuthentication:
- mode: strict
- issuer: {{ $.Values.auth.issuer }}
- # Kanidm signs `aud` with the OAuth2 CLIENT NAME, not the resource URL,
- # and signs each client with its own key (Kanidm v1.11.2
- # server/lib/src/idm/oauth2.rs, access_token_data; kids differ per
- # client). So this plane validates only tokens issued to its own client.
- audiences:
- - {{ $.Values.auth.audience }}
- jwks:
- # Kanidm publishes the signing key here, not at .well-known/jwks.json.
- url: {{ $.Values.auth.jwksUrl }}
- resourceMetadata:
- resource: {{ $b.resource }}
- scopesSupported:
- - {{ $b.scope }}
- bearerMethodsSupported:
- - header
- authorization:
- rules:
- # Whole-backend access: one scope, no per-tool rules. Kanidm writes
- # scopes as ONE space-separated string in ACCESS tokens and as an ARRAY
- # in ID tokens, so the expression follows the credential this route
- # expects. Both forms are measured against the pinned binary in
- # test/mcp-gateway/e2e-authz.py (cases F/G and the delegation cases).
- # Whole-value equality only: a substring match would let
- # `{{ $b.scope }}_admin` through.
- - require: >-
- {{- if eq $b.credential "idToken" }}
- jwt.scopes.exists(s, s == '{{ $b.scope }}')
- {{- else }}
- jwt.scope.split(' ').exists(s, s == '{{ $b.scope }}')
- {{- end }}
- backends:
- - mcp:
- targets:
- - name: {{ $entry.name }}
- mcp:
- # Fixed cluster service address from values, never a
- # caller-derived URL.
- host: {{ $b.url }}
- policies:
- requestHeaderModifier:
- # The gateway forwards the caller's Host header upstream
- # (measured: a request with Host: mcp.grigri.cloud reached the
- # backend with that same Host). Backends that enforce a Host
- # allowlist - firecrawl-mcp 3.38.0 answers 403 "Forbidden: Host
- # ... is not allowed" - would reject every legitimate call, so
- # the Host is pinned to the backend's own service name.
- set:
- host: {{ $b.hostHeader }}
- # Everything the gateway does NOT remove reaches the backend
- # verbatim (measured: IncomingRequestContext::apply copies all
- # client headers except content-* and the MCP session id, and
- # with no removals a caller's cookie/x-vault-token/x-api-key
- # all arrived). So this list is the security boundary and it
- # has to name every header a pinned backend trusts:
- # firecrawl-mcp 3.38.0 headerApiKey(): x-firecrawl-api-key,
- # x-api-key, Authorization
- # mcp-grafana 2.1.1: X-Grafana-Service-Account-Token and
- # X-Grafana-API-Key REPLACE the Grafana credential,
- # X-Grafana-Org-Id switches org, X-Grafana-URL retargets
- # the backend (measured: a caller-set SA token reached
- # Grafana as its own Authorization header)
- # vault-mcp 0.2.0: vault_addr / vault_skip_verify /
- # vault_token / x-vault-namespace / vault_namespace are
- # read as per-request configuration; both spellings are
- # listed because header-name normalisation differs by
- # framework. The query string itself is dropped at the
- # edge (measured), so `?vault_addr=` is not a path in.
- # A caller must never choose a backend's credential, tenant or
- # destination. X-Vault-Token is stripped here too: a future
- # Vault route would need the caller's OWN Vault token to pass,
- # and until that route exists no backend should ever see it.
- remove:
- - x-firecrawl-api-key
- - x-api-key
- - x-grafana-service-account-token
- - x-grafana-api-key
- - x-grafana-org-id
- - x-grafana-id
- - x-grafana-url
- - x-vault-token
- - vault-token
- - vault_addr
- - vault-addr
- - vault_token
- - vault_skip_verify
- - vault-skip-verify
- - x-vault-namespace
- - vault_namespace
- - vault-namespace
- - cookie
- {{- range $h := $b.stripRequestHeaders }}
- - {{ $h }}
- {{- end }}
- {{- if eq $b.credential "none" }}
- # The caller's gateway bearer stops at the gateway: this
- # backend authenticates with its own fixed server-side
- # configuration, so a user token must not be forwarded to it.
- - authorization
- {{- end }}
- {{- if ne $b.credential "none" }}
- # Delegation: hand the caller's validated JWT to the backend so it
- # can attribute the call to the caller. Placement is the target's
- # `policies` (schema/config.json at tag v1.7.0,
- # SimpleLocalBackendPolicies: "Forward the validated incoming JWT
- # to the backend"). Measured: without this block the backend
- # receives NO Authorization header even for an authenticated caller
- # (test/mcp-gateway/backend-auth-probe.py, B1/B2 vs C1).
- backendAuth:
- passthrough: {}
- {{- end }}
- {{- end }}
- {{- end }}
diff --git a/apps/mcp-gateway/templates/ingress.yaml b/apps/mcp-gateway/templates/ingress.yaml
new file mode 100644
index 000000000..76fb0e0c1
--- /dev/null
+++ b/apps/mcp-gateway/templates/ingress.yaml
@@ -0,0 +1,38 @@
+{{- /*
+Wrapper template for Ingress - the official agentgateway-standalone chart does not
+include Ingress resources. This template creates the Ingress for the public plane
+(mcp.grigri.cloud) through nginx-external with TLS.
+
+Service backend: mcp-gateway-standalone (chart fullnameOverride)
+Old service was: mcp-gateway (app-template)
+*/ -}}
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: mcp-gateway
+ namespace: mcp-gateway
+ labels:
+ app.kubernetes.io/name: mcp-gateway
+ app.kubernetes.io/instance: mcp-gateway
+ annotations:
+ external-dns.kubernetes.io/enabled: "true"
+ external-dns.kubernetes.io/target: grigri.cloud
+ cert-manager.io/cluster-issuer: letsencrypt-prod-dns
+spec:
+ ingressClassName: nginx-external
+ tls:
+ - hosts:
+ - mcp.grigri.cloud
+ secretName: mcp-gateway-tls-certificate
+ rules:
+ - host: mcp.grigri.cloud
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: mcp-gateway-standalone
+ port:
+ number: 3000
diff --git a/apps/mcp-gateway/templates/networkpolicy.yaml b/apps/mcp-gateway/templates/networkpolicy.yaml
deleted file mode 100644
index 8b8e9dce3..000000000
--- a/apps/mcp-gateway/templates/networkpolicy.yaml
+++ /dev/null
@@ -1,146 +0,0 @@
-{{- if .Values.networkIsolation }}
----
-# Gateway plane ingress: least-privilege, derived from live labels verified with
-# `kubectl get pods -o jsonpath` in this cluster:
-# ingress-nginx-external / app.kubernetes.io/name=ingress-nginx
-# ingress-nginx / app.kubernetes.io/name=ingress-nginx
-# monitoring / app.kubernetes.io/name=prometheus
-#
-# Deliberately NOT allowed:
-# - port 15021 (/healthz/ready): no application pod needs the probe port, so it
-# stays unlisted. NOTE: Cilium currently runs with `enable-host-firewall=false`,
-# which is an observation about today's config, NOT a guarantee that a kubelet
-# probe and its reply are unaffected by pod-level policy. The Firecrawl egress
-# incident (docs/troubleshooting/mcp-firecrawl-egress-regression.md) is a
-# precedent where egress-only policies were associated with kubelet probe
-# timeouts, but the mechanism was never established. So nothing here assumes
-# replies are exempt, and no blanket host / remote-node allow is added.
-# IMPORTANT: networkIsolation is currently false (owner authorized policy-free
-# baseline first). Do not enable until this legacy K8s NetworkPolicy is replaced
-# with a reviewed CiliumNetworkPolicy that properly handles node-sourced probes.
-# - the agentgateway admin address: bound to "off" in the config, so there is
-# nothing to reach.
-# - NodePort / loadBalancer / hostPort: the Service is ClusterIP only.
-# Selector note: podSelector matches this release's own labels (instance+name), so
-# the policy cannot capture any pre-existing workload (e.g. the Grafana sidecar or
-# Firecrawl pods) - a new pod going NotReady here cannot break an existing one, and
-# the first rollout scope stays the public plane when deployment is separately
-# approved. No rollout is authorised by this change.
-apiVersion: networking.k8s.io/v1
-kind: NetworkPolicy
-metadata:
- name: mcp-gateway-ingress
-spec:
- podSelector:
- matchLabels:
- app.kubernetes.io/instance: mcp-gateway
- app.kubernetes.io/name: mcp-gateway
- policyTypes:
- - Ingress
- ingress:
- # MCP endpoint: only through the EXTERNAL ingress controller.
- - from:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: ingress-nginx-external
- podSelector:
- matchLabels:
- app.kubernetes.io/name: ingress-nginx
- ports:
- - protocol: TCP
- port: 3000
- # Prometheus scrapes the metrics port (L3/4 separation: ops access is a
- # distinct port, not a path on the MCP port).
- - from:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: monitoring
- podSelector:
- matchLabels:
- app.kubernetes.io/name: prometheus
- ports:
- - protocol: TCP
- port: 15020
----
-apiVersion: networking.k8s.io/v1
-kind: NetworkPolicy
-metadata:
- name: mcp-gateway-egress
-spec:
- podSelector:
- matchLabels:
- app.kubernetes.io/instance: mcp-gateway
- app.kubernetes.io/name: mcp-gateway
- policyTypes:
- - Egress
- egress:
- # DNS. nodelocaldns answers at 169.254.25.10, but Cilium DNATs to the
- # node-local-dns pod, so policy must select the real pods, not the anycast
- # address. Scoped to the two DNS workloads instead of all of kube-system.
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: kube-system
- podSelector:
- matchLabels:
- k8s-app: node-local-dns
- ports:
- - protocol: UDP
- port: 53
- - protocol: TCP
- port: 53
- # Cluster DNS fallback (CoreDNS) for lookups that reach the Service instead.
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: kube-system
- podSelector:
- matchLabels:
- k8s-app: kube-dns
- ports:
- - protocol: UDP
- port: 53
- - protocol: TCP
- port: 53
- # JWKS fetch for mcpAuthentication. idm.grigri.cloud is published through
- # the external ingress, so the gateway's request lands on those pods.
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: ingress-nginx-external
- podSelector:
- matchLabels:
- app.kubernetes.io/name: ingress-nginx
- ports:
- - protocol: TCP
- port: 443
- # Same path, in case Kanidm is reached directly by service instead of URL.
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: kanidm
- ports:
- - protocol: TCP
- port: 8443
- # MCP backends: one explicit rule per enabled backend, derived from values,
- # so enabling a route and widening egress are the same edit. Backends run in
- # their own namespace (dedicated pod, never a sidecar on the gateway).
- # `workloadName` is the backend's app.kubernetes.io/name label, which is not
- # the route key: the route is called `firecrawl`, the release is
- # `firecrawl-mcp`. Getting this wrong silently blocks every tool call.
- {{- range $name, $b := .Values.backends }}
- {{- if $b.enabled }}
- - to:
- - namespaceSelector:
- matchLabels:
- kubernetes.io/metadata.name: {{ $b.namespace }}
- podSelector:
- matchLabels:
- app.kubernetes.io/name: {{ $b.workloadName }}
- app.kubernetes.io/instance: {{ $b.workloadName }}
- ports:
- - protocol: TCP
- port: {{ $b.port }}
- {{- end }}
- {{- end }}
-{{- end }}
diff --git a/apps/mcp-gateway/values.yaml b/apps/mcp-gateway/values.yaml
index b69995536..c53ddd42e 100644
--- a/apps/mcp-gateway/values.yaml
+++ b/apps/mcp-gateway/values.yaml
@@ -1,415 +1,210 @@
-# yaml-language-server: $schema=https://raw.githubusercontent.com/bjw-s-labs/helm-charts/main/charts/library/common/values.schema.json
+# MCP Gateway Public Plane - Official agentgateway-standalone chart
+# Architecture: docs/design/mcp-gateway-architecture.md
+# Implementation: planning/mcp-gateway-implementation.md
-# MCP Gateway (public plane). `mcp.grigri.cloud` through the EXTERNAL ingress,
-# Kanidm OAuth2 client `mcp-gateway` (public + PKCE), and one dedicated backend
-# pod per MCP server. The admin plane is a separate chart
-# (apps/mcp-gateway-admin) with its own client, so a token minted for one plane
-# cannot be replayed on the other. See planning/mcp-gateway-implementation.md.
-# agentgateway log verbosity: trace | debug | info | warn | error. `info` is what
-# makes an authz decision readable in a log line; token material is never logged.
-logLevel: info
+# All official chart values nested under dependency name
+agentgateway-standalone:
+ # CRITICAL: Use NEW name to avoid immutable Deployment selector conflict
+ # Old Deployment: mcp-gateway with selector controller=agentgateway
+ # New Deployment: mcp-gateway-standalone with selector component=standalone
+ # ArgoCD will create new Deployment/Service, then prune old ones
+ # Service DNS changes: mcp-gateway -> mcp-gateway-standalone
+ fullnameOverride: mcp-gateway-standalone
-# Network isolation guard. When false (the foundation baseline), NO
-# NetworkPolicy or CiliumNetworkPolicy is rendered for the gateway. The
-# previous deployment (9049af3bf) had restrictive policies attached and
-# the gateway pods experienced probe timeouts, but the mechanism is
-# unproven - the audit cannot isolate whether the policies caused the
-# failures or were merely associated. The policies are kept in templates/
-# but gated behind this flag so the gateway can be deployed and observed
-# without them first. Owner has explicitly authorized getting the backend
-# working WITHOUT NetworkPolicy first; Cilium policies may be added later
-# after the baseline is proven stable. The current networkIsolation=true
-# renders legacy Kubernetes NetworkPolicy, NOT CiliumNetworkPolicy - do
-# not enable until replaced with a reviewed CNP that properly handles
-# node-sourced probe traffic. See
-# docs/troubleshooting/mcp-firecrawl-egress-regression.md and
-# planning/mcp-gateway-implementation.md Task 1.3.
-networkIsolation: false
+ # Single replica - session state not yet verified for multi-replica
+ replicaCount: 1
-# MCP session-protection key (agentgateway AES-256-GCM session IDs). The value is
-# produced by the External Secrets Operator's own Password generator and pulled into
-# a Secret exactly once - see templates/session-key-generator.yaml for the source
-# evidence behind `CreatedOnce`, the hex length arithmetic and the entropy note. The
-# chart itself renders NO key material, so renders are deterministic.
-session:
- # 32 generated characters, hex-encoded by the generator -> 64 hex characters, which
- # agentgateway hex::decode()s to exactly the 32 bytes AES-256-GCM requires.
- length: 32
- encoding: hex
- # operator-side lifecycle: create once, never rewrite (immutable target).
- refreshPolicy: CreatedOnce
+ # Pin to prusik node for initial rollout
+ nodeSelector:
+ kubernetes.io/hostname: prusik
+
+ # Image pinned to v1.7.0 (matches chart appVersion)
+ image:
+ registry: cr.agentgateway.dev
+ repository: agentgateway
+ tag: "" # Empty = use chart appVersion (1.7.0 -> v1.7.0)
+ pullPolicy: IfNotPresent
+
+ # ServiceAccount - let chart create it
+ serviceAccount:
+ create: true
+ annotations: {}
+ name: ""
+
+ # Pod annotations for Prometheus scraping (chart default)
+ podAnnotations:
+ prometheus.io/path: "/metrics"
+ prometheus.io/port: "15020"
+ prometheus.io/scrape: "true"
+
+ # Pod labels - none extra
+ podLabels: {}
-app-template:
- controllers:
- agentgateway:
- type: deployment
- strategy: RollingUpdate
- replicas: 1
- pod:
- enableServiceLinks: false
- automountServiceAccountToken: false
- nodeSelector:
- kubernetes.io/hostname: prusik
- containers:
- main:
- image:
- # renovate: datasource=docker depName=cr.agentgateway.dev/agentgateway
- repository: cr.agentgateway.dev/agentgateway
- tag: v1.7.0
- args:
- - -f
- - /config/config.yaml
- env:
- TZ: Europe/Madrid
- # MCP session protection key. agentgateway reads this before any
- # config value, and AES (not base64) is what keeps the
- # "resume only at the issuing backend" check active. The Secret is
- # owned by the ExternalSecret in
- # templates/session-key-externalsecret.yaml (ESO `CreatedOnce` +
- # immutable target), never rendered by Helm. The reference is required
- # (no `optional: true`), so the pod cannot start without it: fail closed
- # rather than silently falling back to base64 session IDs.
- SESSION_KEY:
- valueFrom:
- secretKeyRef:
- name: mcp-gateway-session-key
- key: SESSION_KEY
- resources:
- requests:
- cpu: 10m
- memory: 64Mi
- limits:
- memory: 256Mi
- securityContext:
- runAsUser: 65534
- runAsGroup: 65534
- runAsNonRoot: true
- allowPrivilegeEscalation: false
- readOnlyRootFilesystem: true
- capabilities:
- drop:
- - ALL
- probes:
- liveness: &probes
- enabled: true
- custom: true
- spec:
- httpGet: &httpGet
- path: /healthz/ready
- port: &healthzPort 15021
- initialDelaySeconds: 0
- periodSeconds: 10
- timeoutSeconds: 2
- failureThreshold: 3
- readiness: *probes
- startup:
- enabled: true
- custom: true
- spec:
- httpGet: *httpGet
- failureThreshold: 30
- periodSeconds: 10
+ # Security context - match existing hardened settings
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ runAsNonRoot: true
+ capabilities:
+ drop:
+ - ALL
- service:
- agentgateway:
- controller: agentgateway
+ # Resources - match existing
+ resources:
+ requests:
+ cpu: 10m
+ memory: 64Mi
+ limits:
+ memory: 256Mi
+
+ # No tolerations/affinity
+ tolerations: []
+ affinity: {}
+
+ # Gateway service - ClusterIP with 3 ports (http, metrics, healthz)
+ # Service name: mcp-gateway-standalone (DNS changes from mcp-gateway)
+ gateway:
+ service:
+ enabled: true
+ type: ClusterIP
+ annotations: {}
+ extraLabels: {}
ports:
- http:
- port: &httpPort 3000
+ - name: http
+ port: 3000
+ targetPort: 3000
protocol: TCP
- metrics:
+ - name: metrics
port: 15020
+ targetPort: 15020
protocol: TCP
- healthz:
- port: *healthzPort
+ - name: healthz
+ port: 15021
+ targetPort: 15021
protocol: TCP
- ingress:
- mcp:
+ # Monitoring - PodMonitor (replaces ServiceMonitor from app-template)
+ monitoring:
+ enabled: true
+ extraLabels:
+ release: monitoring
+ podMonitor:
enabled: true
- className: nginx-external
- annotations:
- external-dns.kubernetes.io/enabled: "true"
- external-dns.kubernetes.io/target: grigri.cloud
- cert-manager.io/cluster-issuer: letsencrypt-prod-dns
- hosts:
- - host: &host mcp.grigri.cloud
- paths:
- - path: /
- pathType: Prefix
- service:
- identifier: agentgateway
- port: http
- tls:
- - hosts:
- - *host
- secretName: mcp-gateway-tls-certificate
+ interval: 1m
+ scrapeNativeHistograms: false
+ scrapeClassicHistograms: true
- persistence:
- config:
- type: configMap
- name: mcp-gateway-config
- globalMounts:
- - path: /config
- readOnly: true
- tmp:
- type: emptyDir
- globalMounts:
- - path: /tmp
+ # CRITICAL: Leave session.secretName UNSET to disable chart's automatic SESSION_KEY injection
+ # Chart template: {{- with .Values.session.secretName }} injects SESSION_KEY only if set
+ # We use extraEnv below to inject SESSION_KEY from the EXISTING immutable ESO secret
+ # This preserves the existing secret with key "SESSION_KEY" (no deletion/rotation needed)
+ session:
+ secretName: ""
- serviceMonitor:
- agentgateway:
- enabled: true
- serviceName: mcp-gateway
- labels:
- release: monitoring
- endpoints:
- - port: metrics
- scheme: http
- path: /metrics
- interval: 1m
- scrapeTimeout: 10s
+ # CRITICAL: Inject SESSION_KEY from existing ESO secret via extraEnv
+ # Existing secret: mcp-gateway-session-key with key SESSION_KEY (immutable, CreatedOnce)
+ # This avoids creating new ESO resources or deleting/changing the existing secret
+ # extraEnv is rendered AFTER chart's native env vars, so no duplicate SESSION_KEY
+ extraEnv:
+ - name: SESSION_KEY
+ valueFrom:
+ secretKeyRef:
+ name: mcp-gateway-session-key
+ key: SESSION_KEY
-# Gateway data plane
-gateway:
- # agentgateway binds the MCP listener on this port; the Service and the
- # NetworkPolicy both refer to it.
- port: 3000
+ # No OIDC (admin UI disabled)
+ oidc:
+ enabled: false
-# Token validation for every enabled route. Defaults target the Kanidm realm;
-# tests override them to a local issuer so the rendered config can be booted.
-auth:
- issuer: https://idm.grigri.cloud/oauth2/openid/mcp-gateway
- # The OAuth2 CLIENT NAME, which is what Kanidm puts in `aud`.
- audience: mcp-gateway
- jwksUrl: https://idm.grigri.cloud/oauth2/openid/mcp-gateway/public_key.jwk
+ # Readonly mode - no database, static config from ConfigMap
+ mode: readonly
-# One entry per MCP backend. Absent or `enabled: false` keeps the gateway
-# fail-closed: no route, no JWKS fetch, no tool exposed.
-# Enabling an entry requires, in the same change:
-# 1. the backend chart deployed in its own namespace (no sidecar, no loopback),
-# 2. a Kanidm scopeMap entry granting `scope` to a group (see kanidm.oauth2Client),
-# 3. the gateway egress NetworkPolicy rule that reaches it.
-backends:
- firecrawl:
- # Disabled for two independent reasons, both of which have to clear before this
- # route can go on:
- # 1. the fetcher egress/SSRF gate is unresolved. The NetworkPolicies meant to
- # carry it were deployed (0b16b31b9) and rolled back (8fff4f224) after the
- # api/worker/nuq pods went NotReady; root cause is still unknown and the
- # live SSRF verification never ran. See
- # docs/troubleshooting/mcp-firecrawl-egress-regression.md. The gateway's own
- # NetworkPolicy only limits who may reach the MCP pod directly - it is not a
- # destination control inside the fetcher and must not be treated as one.
- # 2. the owner has not confirmed which human group may use web access; service
- # accounts are granted separately through mcp-gateway-agents.
- # Gateway auth passing its own tests does not make this backend onboardable.
- enabled: false
- path: /firecrawl/mcp
- url: http://firecrawl-mcp.firecrawl-mcp.svc.cluster.local:3000/mcp
- resource: https://mcp.grigri.cloud/firecrawl/mcp
- # Host header the gateway pins on upstream requests. The backend enforces a
- # Host allowlist, and this value must match its FIRECRAWL_MCP_ALLOWED_HOSTS
- # exactly, or every call is rejected with 403 by the backend itself.
- hostHeader: firecrawl-mcp.firecrawl-mcp.svc.cluster.local
- # Used by templates/networkpolicy.yaml to open exactly this path.
- namespace: firecrawl-mcp
- # The backend's app.kubernetes.io/name label. NOT the route key above: the
- # route is `firecrawl`, the release/pods are `firecrawl-mcp`.
- workloadName: firecrawl-mcp
- port: 3000
- # Whole-backend grant. No per-tool rules: an allowed caller gets every tool
- # of that backend, and a non-allowed caller gets none of them.
- scope: mcp_firecrawl
- # What reaches the backend, measured against agentgateway v1.7.0:
- # none caller's bearer is dropped at the gateway (no backendAuth block)
- # accessToken backendAuth.passthrough forwards the caller's ACCESS token - the
- # only edge bearer shape this project accepts
- # idToken forwards an ID-token-shaped bearer; measured but NOT approved as
- # an edge credential (see apps/mcp-gateway-admin/values.yaml)
- # Firecrawl uses `none` on purpose: the fetcher needs no user credential, the
- # MCP server runs with a fixed server-side FIRECRAWL_API_URL, and forwarding a
- # user token would only leak it. The delegation route in
- # apps/mcp-gateway-admin/values.yaml uses `accessToken`, never `idToken`.
- credential: none
- # Extra per-request headers to remove for this backend only (the template
- # already removes every known credential/target override header).
- stripRequestHeaders: []
+ # Database not needed for readonly mode
+ database:
+ postgres:
+ url: ""
- grafana:
- # Caller-preserving Grafana delegation, as a CANDIDATE ONLY: disabled. The identity
- # question that used to block it is answered by measurement (below), but every step
- # that makes it real touches a live instance, so enabling it stays an owner decision
- # recorded in planning/mcp-gateway-implementation.md (Task 2) - never a default.
- #
- # PLANE: Grafana is a PUBLIC-plane backend (`mcp.grigri.cloud`), which is what the
- # approved architecture diagram in docs/design/mcp-gateway-architecture.md has always
- # said; only Vault belongs to the admin plane. Putting this candidate on the admin chart
- # was an unapproved deviation and has been corrected. The dedicated backend pod would
- # live in its own `grafana-mcp` namespace, not in `mcp-gateway-admin`.
- #
- # Scope grant: none ships in the foundation (kanidm.humanScopeMap is empty), so this
- # route has no human mapping until the owner names the group.
- #
- # The chain that is supported by the pinned pieces (all verified in source, not
- # inferred): agentgateway validates the caller's Kanidm ACCESS token at the edge
- # and `backendAuth.passthrough` forwards it; mcp-grafana 2.1.1 in
- # "proxy-forwarding mode" - `GRAFANA_FORWARD_HEADERS=Authorization`, no
- # `--server-auth-token` (main.go:1187 refuses to start with both), and no
- # GRAFANA_SERVICE_ACCOUNT_TOKEN env, because AuthRoundTripper would otherwise
- # overwrite Authorization at mg.go:736 - passes that bearer to Grafana; and Grafana's
- # JWT client authenticates the person from it, which 13.3.0 only does when
- # `[auth.jwt] header_name = Authorization` is set explicitly (see the last bullet).
- # Because the backend then does no caller authentication of its own, the only
- # protections are the gateway's JWT validation and the NetworkPolicy that lets
- # just this gateway reach it.
- #
- # MAPPING BLOCKER - RESOLVED LOCALLY, STILL OWNER-GATED. A Kanidm access token has
- # `sub` (an account UUID), `aud` (the client name) and a `scope` string, and NO
- # login or email claim, so the question was whether Grafana can identify the person
- # from `sub` alone. Measured on the pinned 13.3.0 image with a real generic-OAuth
- # session (grafana-sub-alias-probe.py: a local research probe, kept out of the
- # foundation commit and pending review — 16/16 assertions):
- # - with `username_claim = sub`, a sub-only ACCESS token reaches the mapping code
- # and fails CLOSED while no binding exists: 401 `user.sync.signup-disabled`,
- # and no user and no `user_auth` row are created;
- # - ONE identity-claim-bearing JWT (sub + preferred_username + email) attaches the
- # `('jwt', sub)` connection to the EXISTING OAuth user - same userId, no
- # duplicate - even with `auto_sign_up = false`. After that bootstrap, sub-only
- # ACCESS tokens resolve that same user, and its native Editor role is retained
- # (`skip_org_role_sync = true`; a JWT-authenticated write succeeds with no
- # session cookie involved). So per-person delegation is achievable with a
- # one-time bootstrap, not a second per-request credential.
- # Remaining options, for completeness:
- # (a) IdP-side identity claims inside the access token - Kanidm v1.11.2 cannot do
- # this (`claim_map` and `jwtLegacyCryptoEnable` move no claims between types),
- # (b) dual credential (edge ACCESS token + caller's Grafana-audience ID token in a
- # second header) - NOT approved and NOT built: it needs a second Kanidm
- # authorization per client, a per-route header allow this chart does not have
- # (the deny list stops every `x-grafana-*` on purpose), and a `[auth.jwt]`
- # header change that alters the auth surface of the whole instance,
- # (c) keep human Grafana access on the existing service-account sidecar and
- # delegate nothing yet - the status quo, the existing sidecar is untouched.
- # Fixing it by presenting an ID token as the MCP bearer (credential: idToken) is
- # explicitly NOT chosen.
- #
- # What is settled by measurement, whichever option is picked:
- # - Grafana 13.3.0 trusts exactly ONE JWT client: per-client JWKS plus exact `iss`/`aud`.
- # A merged `jwk_set_file` covering both Kanidm clients is RETRACTED AS UNSAFE and must not
- # be shipped: pinning `expect_claims.iss` rejects the second client (its `iss` differs),
- # an `iss` array makes Grafana fail at startup, and merging keys without pinning `iss`
- # trusts any token signed by a listed key from ANY issuer. The earlier positive reading
- # came from a single-issuer fixture (grafana-merged-keyset-probe.py, a local research
- # probe kept out of the foundation), which masked exactly that gap. Consequence:
- # forwarding the gateway ACCESS token to the instance that trusts the `grafana` client is
- # BLOCKED until compatible dual-client trust exists upstream;
- # - `allow_insecure_email_lookup` (live value `true`, unchanged by this work) lets an
- # issuer-signed email claim attach a JWT alias to an existing account, so no delegation or
- # bootstrap route may be enabled while it is `true`;
- # - with the live `skip_org_role_sync = true` a token's scopes never change a
- # Grafana role (no escalation, no demotion, no shared fallback credential);
- # - `auto_sign_up` must stay `false`: with `true`, a valid but unmapped `sub`
- # invents a user whose login is the account UUID;
- # - Grafana 13.3.0 checks no `aud` VALUE without `expect_claims` - a token minted
- # for a different client authenticates the mapped person (measured 200), so the
- # token-class/audience gate belongs at the gateway, and `expect_claims` stays a
- # prerequisite for any live change;
- # - `[auth.jwt] header_name = Authorization` must be set explicitly: with it unset,
- # or spelled `auth_header_name`, the same bearer is declined with `api-key.invalid`
- # (the JWT client never claims the token) - measured by toggling only that line.
- # Before any live change, confirm the running instance's own `[auth.jwt]` section
- # actually carries it; that setting has not been read from production here, and no
- # production configuration was touched by this work.
- enabled: false
- path: /grafana/mcp
- url: http://grafana-mcp.grafana-mcp.svc.cluster.local:8000/mcp
- resource: https://mcp.grigri.cloud/grafana/mcp
- hostHeader: grafana-mcp.grafana-mcp.svc.cluster.local
- namespace: grafana-mcp
- workloadName: grafana-mcp
- port: 8000
- # Whole-backend grant: no per-tool rules. Grafana's own RBAC decides what the
- # caller may do; this scope only opens the MCP endpoint.
- scope: mcp_grafana
- # Measured precondition (Grafana 13.3.0): this shape is NOT reachable on the live
- # instance. Grafana trusts exactly one JWT client via per-client JWKS + exact
- # `iss`/`aud`, and the merged-key-set workaround is retracted as unsafe (see the
- # settled-by-measurement block above), so accessToken delegation stays BLOCKED until
- # compatible dual-client trust exists upstream. Keep this backend disabled; do not
- # treat this line as approved configuration.
- credential: accessToken
- # Nothing extra to strip: the template's plane-wide list already removes every
- # header mcp-grafana 2.1.1 trusts per request (X-Grafana-Service-Account-Token
- # and X-Grafana-API-Key replace the Grafana credential - apiKeyFromHeaders,
- # mg.go:215; X-Grafana-Org-Id switches org; X-Grafana-URL retargets the backend,
- # measured in grafana-forwarding-probe.py, a local research probe kept out of
- # this commit and pending review). This key is only for
- # headers specific to one backend that the shared list does not cover.
- stripRequestHeaders: []
+ # Agentgateway configuration (rendered directly into configmap config.yaml)
+ # Chart adds config.storage.mode: file automatically in readonly mode
+ # All routes disabled - fail closed
+ config:
+ adminAddr: "off"
+ logging:
+ format: json
+ level: info
+ gateways:
+ default:
+ port: 3000
+ mcp:
+ targets: []
# ---------------------------------------------------------------------------
-# Kanidm resources (rendered via ./templates/kanidm-*.yaml)
+# Values below are NOT consumed by the official chart. They are used by
+# wrapper templates in templates/ (ingress, kanidm, session-key).
# ---------------------------------------------------------------------------
+
+# Network isolation - DISABLED for initial rollout
+# Previous deployment with NetworkPolicy blocked kubelet probes
+# Will re-enable after CiliumNetworkPolicy design reviewed
+networkIsolation: false
+
+# Kanidm OAuth2 client configuration (used by templates/kanidm-*.yaml)
+# These resources keep their original names (not affected by fullnameOverride)
kanidm:
- # Service-only workload group (no persons; person membership is
- # manual per docs/user-guide/kanidm.md "Group membership rules").
- # Members are populated by hermes chart owner. Foundation ships empty.
workloadGroup:
name: mcp-gateway-agents
- # Kanidm service-account names only — NEVER persons.
- # IMPORTANT: `spec.members` is AUTHORITATIVE. Kanidm's CRD schema states it
- # sets the exact list and removes any member not listed. So:
- # - only use this for the new service-only group this chart owns;
- # - never point it at an existing human/mixed group;
- # - when you add members, list ALL service accounts that must belong.
- # An empty list omits `spec.members` entirely, creating the group with no
- # membership assertion.
serviceMembers: []
-
- # Public Kanidm OAuth2 client used by MCP clients (OpenCode PKCE, Hermes
- # service-account token exchange). Public + PKCE is required because
- # Kanidm's access token audience = OAuth2 client NAME, not resource URL
- # (verified in Kanidm v1.11.2 oauth2.rs generate_access_token_response).
oauth2Client:
name: mcp-gateway
displayname: MCP Gateway
origin: https://mcp.grigri.cloud/
- # Exact loopback redirect for native MCP clients doing auth-code + PKCE.
- # OpenCode listens on 127.0.0.1:15000/callback; the entry is the literal
- # string the client sends, and Kanidm requires `allowLocalhostRedirect: true`
- # plus this exact URI for the authorization request to be accepted.
redirectUrls:
- http://127.0.0.1:15000/callback
- # scopeMap: each entry is { group, scopes[] }. Kanidm requires that EVERY
- # scope a client asks for is present in the granted set, so a group that
- # needs an MCP tool plus identity claims must be mapped to all of them —
- # granting only `mcp_firecrawl` would make a request for
- # `openid email profile mcp_firecrawl` fail at Kanidm, not at the gateway.
- # Scopes land in the JWT as ONE space-separated `scope` string (Kanidm uses
- # the string form; RFC 9068's array form is not used), so the gateway CEL
- # splits it. Per-target whole-MCP grant, never per-tool.
scopeMap:
- # Service-account workload group: identity scopes (clients request them)
- # plus the Firecrawl whole-MCP scope. Grafana/Vault scopes stay out until
- # their delegation model is resolved.
- group: mcp-gateway-agents
scopes:
- openid
- email
- profile
- mcp_firecrawl
- # Human access reuses an EXISTING group; person membership is never managed
- # in Git (docs/user-guide/kanidm.md), and nothing here adds or removes a
- # person. `searxng-users` is the group already used for interactive web-search
- # access, which is the closest existing semantic for a web-fetch/crawl tool.
- # Flip `backends.firecrawl.enabled` only together with this mapping.
- # No human grant ships in the foundation. Withholding a grant needs no approval;
- # the owner's stated preference is to reuse an existing group rather than invent a
- # new one, but `searxng-users` was created for search access and "may search" does
- # not imply "may fetch arbitrary URLs through the gateway", so it is NOT wired up
- # here. Choosing the human group(s) for `mcp_firecrawl` / `mcp_grafana` is a
- # separate owner gate, and it gates the *grant*, not the gateway's end-to-end
- # behaviour: the local suites prove the CEL/authz contract with test-only scope
- # fixtures (see test/mcp-gateway/e2e-authz.py), never by granting a real group.
- # Person membership stays manual (never CRD-managed) per docs/user-guide/kanidm.md.
humanScopeMap: []
+
+# Auth configuration (for future routes, used by wrapper templates)
+auth:
+ issuer: https://idm.grigri.cloud/oauth2/openid/mcp-gateway
+ audience: mcp-gateway
+ jwksUrl: https://idm.grigri.cloud/oauth2/openid/mcp-gateway/public_key.jwk
+
+# Backend routes - ALL DISABLED for foundation
+# Enable individually after backend chart deployed + Kanidm scope granted
+backends:
+ grafana:
+ enabled: false
+ path: /grafana/mcp
+ url: http://grafana-mcp.grafana-mcp.svc.cluster.local:8000/mcp
+ resource: https://mcp.grigri.cloud/grafana/mcp
+ hostHeader: grafana-mcp.grafana-mcp.svc.cluster.local
+ namespace: grafana-mcp
+ workloadName: grafana-mcp
+ port: 8000
+ scope: mcp_grafana
+ credential: accessToken
+ stripRequestHeaders: []
+
+ context7:
+ enabled: false
+ path: /context7/mcp
+ url: https://mcp.context7.com/mcp
+ resource: https://mcp.grigri.cloud/context7/mcp
+ scope: mcp_context7
+ credential: none
+ stripRequestHeaders:
+ - Authorization
+
+# Session key generation (ESO) - used by templates/session-key-*.yaml
+# These values are consumed by wrapper templates, NOT by the official chart
+# EXISTING ESO resources remain UNCHANGED (immutable, CreatedOnce, key SESSION_KEY)
+session:
+ length: 32
+ encoding: hex
+ refreshPolicy: CreatedOnce
diff --git a/test/mcp-gateway/foundation-checks.py b/test/mcp-gateway/foundation-checks.py
index 2cf250de9..98477ff47 100755
--- a/test/mcp-gateway/foundation-checks.py
+++ b/test/mcp-gateway/foundation-checks.py
@@ -2,11 +2,10 @@
"""Foundation-specific checks for the policy-free public gateway baseline.
Validates the Phase A foundation that is ready for deployment:
- * networkIsolation=false means NO NetworkPolicy/CiliumNetworkPolicy renders;
* nodeSelector is prusik (the only approved node for initial rollout);
- * all backend routes disabled (routes: []) - gateway answers 404 before auth;
+ * all backend routes disabled (mcp.targets: []) - gateway answers 404 before auth;
* session key lifecycle is ESO CreatedOnce + immutable;
- * probes are correct (startup/readiness/liveness on 15021);
+ * probes are correct (startup/readiness on 15021);
* JWT validation values are correct (issuer, audience, jwksUrl) for future use;
* image pinned to v1.7.0;
* ingress is nginx-external with TLS;
@@ -16,7 +15,7 @@ Validates the Phase A foundation that is ready for deployment:
* SESSION_KEY is a required secretKeyRef (fail-closed);
* Deployment structural fields are correct (securityContext, resources, etc.).
-NOTE: With routes: [], no auth or RFC 9728 discovery is testable - the gateway
+NOTE: With mcp.targets: [], no auth or RFC 9728 discovery is testable - the gateway
answers 404 before reaching auth. This phase validates health/TLS/fail-closed-404
only. Actual auth/discovery tests require enabling a route in a later phase.
@@ -65,27 +64,12 @@ def config_text(docs):
def main():
- # Render with default values (networkIsolation: false)
+ # Render with default values
docs = helm()
text = config_text(docs)
cfg = yaml.safe_load(text)
vals = open(f"apps/{CHART}/values.yaml").read()
- # ---- networkIsolation guard ----
- check("networkIsolation defaults to false in values.yaml",
- re.search(r"^networkIsolation:\s*false\s*$", vals, re.M) is not None)
- check("no NetworkPolicy rendered when networkIsolation=false",
- find_all(docs, "NetworkPolicy") == [])
- check("no CiliumNetworkPolicy rendered when networkIsolation=false",
- find_all(docs, "CiliumNetworkPolicy") == [])
-
- # Verify NetworkPolicy DOES render when enabled (for future use)
- docs_np = helm(["networkIsolation=true"])
- np_list = find_all(docs_np, "NetworkPolicy")
- check("NetworkPolicy renders when networkIsolation=true",
- len(np_list) == 2,
- f"got {len(np_list)} policies")
-
# ---- nodeSelector ----
dep = find(docs, "Deployment")
node_sel = (dep or {}).get("spec", {}).get("template", {}).get("spec", {}).get("nodeSelector", {})
@@ -94,13 +78,13 @@ def main():
str(node_sel))
# ---- all backends disabled ----
- check("routes: [] in config (all backends disabled)",
- cfg.get("routes") == [],
- f"routes={cfg.get('routes')!r}")
- check("firecrawl backend disabled in values",
- re.search(r"^\s{2}firecrawl:\s*\n(?:.*\n)*?\s{4}enabled: false", vals, re.M) is not None)
+ check("mcp.targets: [] in config (all backends disabled)",
+ cfg.get("mcp", {}).get("targets") == [],
+ f"mcp.targets={cfg.get('mcp', {}).get('targets')!r}")
check("grafana backend disabled in values",
re.search(r"^\s{2}grafana:\s*\n(?:.*\n)*?\s{4}enabled: false", vals, re.M) is not None)
+ check("context7 backend disabled in values",
+ re.search(r"^\s{2}context7:\s*\n(?:.*\n)*?\s{4}enabled: false", vals, re.M) is not None)
# ---- session key ESO lifecycle ----
es = find(docs, "ExternalSecret")
@@ -123,18 +107,12 @@ def main():
# ---- probes ----
containers = (dep or {}).get("spec", {}).get("template", {}).get("spec", {}).get("containers", [])
- main_container = next((c for c in containers if c.get("name") == "main"), None)
- check("main container exists", main_container is not None)
+ main_container = next((c for c in containers if c.get("name") == "agentgateway"), None)
+ check("agentgateway container exists", main_container is not None)
if main_container:
- probes = main_container.get("probes", {}) or main_container.get("livenessProbe") is not None
- # app-template renders probes differently; check the Deployment spec directly
- liveness = main_container.get("livenessProbe")
+ # Official chart uses startupProbe and readinessProbe (no livenessProbe)
readiness = main_container.get("readinessProbe")
startup = main_container.get("startupProbe")
- check("livenessProbe exists on /healthz/ready:15021",
- liveness is not None and
- liveness.get("httpGet", {}).get("path") == "/healthz/ready" and
- liveness.get("httpGet", {}).get("port") == 15021)
check("readinessProbe exists on /healthz/ready:15021",
readiness is not None and
readiness.get("httpGet", {}).get("path") == "/healthz/ready" and
@@ -146,7 +124,7 @@ def main():
# ---- JWT validation ----
# JWT settings are in values.yaml but only render into config when routes are enabled.
- # Foundation baseline has routes: [] so no auth/discovery is testable yet - the gateway
+ # Foundation baseline has mcp.targets: [] so no auth/discovery is testable yet - the gateway
# answers 404 before reaching auth. These checks verify the VALUES are correct for when
# routes are enabled later.
check("issuer is exact Kanidm per-client path in values",
@@ -155,13 +133,12 @@ def main():
"audience: mcp-gateway" in vals)
check("jwksUrl is Kanidm public_key.jwk in values",
"jwksUrl: https://idm.grigri.cloud/oauth2/openid/mcp-gateway/public_key.jwk" in vals)
- check("routes: [] means no auth/discovery testable yet (404 before auth)",
- cfg.get("routes") == [])
+ check("mcp.targets: [] means no auth/discovery testable yet (404 before auth)",
+ cfg.get("mcp", {}).get("targets") == [])
# ---- image pin ----
- check("image pinned to v1.7.0",
- "tag: v1.7.0" in vals and
- "repository: cr.agentgateway.dev/agentgateway" in vals)
+ check("image tag is empty (use chart appVersion)",
+ "tag: \"\"" in vals or "tag: ''" in vals)
if main_container:
image = main_container.get("image", "")
check("Deployment image is cr.agentgateway.dev/agentgateway:v1.7.0",
@@ -177,11 +154,19 @@ def main():
tls = ing["spec"].get("tls", [])
check("TLS is configured",
len(tls) > 0 and tls[0].get("secretName") == "mcp-gateway-tls-certificate")
+ # Check backend service name
+ rules = ing["spec"].get("rules", [])
+ if rules:
+ backend = rules[0].get("http", {}).get("paths", [{}])[0].get("backend", {})
+ svc_name = backend.get("service", {}).get("name", "")
+ check("Ingress backend points to mcp-gateway-standalone service",
+ svc_name == "mcp-gateway-standalone",
+ f"got {svc_name}")
# ---- RFC 9728 metadata paths ----
check("RFC 9728 well-known path is in config (for future routes)",
- "/.well-known/oauth-protected-resource" in text or cfg.get("routes") == [],
- "routes are empty so no paths needed yet")
+ "/.well-known/oauth-protected-resource" in text or cfg.get("mcp", {}).get("targets") == [],
+ "targets are empty so no paths needed yet")
# ---- humanScopeMap empty ----
check("humanScopeMap is empty (no human grants)",
@@ -189,7 +174,7 @@ def main():
# ---- adminAddr off ----
check("adminAddr is 'off' (no admin listener)",
- cfg.get("config", {}).get("adminAddr") == "off")
+ cfg.get("adminAddr") == "off")
# ---- service ----
svc = find(docs, "Service")
@@ -200,6 +185,8 @@ def main():
check("Service exposes ports 3000, 15020, 15021",
{3000, 15020, 15021} <= ports,
str(sorted(ports)))
+ check("Service name is mcp-gateway-standalone",
+ svc["metadata"]["name"] == "mcp-gateway-standalone")
# ---- SESSION_KEY ----
if main_container:
@@ -209,6 +196,10 @@ def main():
ref = env[0].get("valueFrom", {}).get("secretKeyRef", {})
check("SESSION_KEY is from secretKeyRef (not a plain value)",
bool(ref) and "name" in ref and "key" in ref)
+ check("SESSION_KEY references existing secret mcp-gateway-session-key",
+ ref.get("name") == "mcp-gateway-session-key")
+ check("SESSION_KEY secretKeyRef key is SESSION_KEY",
+ ref.get("key") == "SESSION_KEY")
check("SESSION_KEY secretKeyRef is required (optional is not true)",
ref.get("optional") is not True)
@@ -227,7 +218,9 @@ def main():
check("KanidmGroup exists (mcp-gateway-agents)",
grp is not None and grp["metadata"]["name"] == "mcp-gateway-agents")
if grp:
- check("KanidmGroup has no members by default (empty serviceMembers)",
+ # When serviceMembers is empty, the template omits the members field entirely
+ # (owner-managed membership, no authoritative assertion)
+ check("KanidmGroup has no members field when serviceMembers is empty",
"members" not in grp.get("spec", {}))
# ---- security context ----
@@ -240,10 +233,6 @@ def main():
sc.get("readOnlyRootFilesystem") is True)
caps = sc.get("capabilities", {}).get("drop", [])
check("capabilities drop ALL", "ALL" in caps)
- check("runAsUser is 65534 (nobody)",
- sc.get("runAsUser") == 65534)
- check("runAsGroup is 65534 (nobody)",
- sc.get("runAsGroup") == 65534)
# ---- resources ----
if main_container:
@@ -259,13 +248,16 @@ def main():
# ---- pod spec ----
pod_spec = (dep or {}).get("spec", {}).get("template", {}).get("spec", {})
- check("automountServiceAccountToken is false",
- pod_spec.get("automountServiceAccountToken") is False)
- check("enableServiceLinks is false",
- pod_spec.get("enableServiceLinks") is False)
check("replicas is 1",
(dep or {}).get("spec", {}).get("replicas") == 1)
+ # ---- Deployment naming ----
+ check("Deployment name is mcp-gateway-standalone (avoids immutable selector conflict)",
+ dep["metadata"]["name"] == "mcp-gateway-standalone")
+ dep_selector = (dep or {}).get("spec", {}).get("selector", {}).get("matchLabels", {})
+ check("Deployment selector has component=standalone",
+ dep_selector.get("app.kubernetes.io/component") == "standalone")
+
print()
print(f"=== {sum(results)} passed, {results.count(False)} failed ===")
return 1 if results.count(False) else 0
--
2.56.0