This guide installs MCP Gateway on OpenShift using OLM operators, with:
openshift-defaultGatewayClass (OCP's built-in gateway controller)- Red Hat Connectivity Link (RHCL) for AuthPolicy support
- A test MCP server to verify the full flow
Verified on OCP 4.22 with MCP Gateway v0.7.1.
Istio GatewayClass alternative: If using
istioinstead ofopenshift-default, see the differences section at the end.
- OpenShift 4.16+ cluster with
ocCLI authenticated redhat-operatorsCatalogSource available inopenshift-marketplace
Set these once. Everything below references them.
export MCP_GATEWAY_VERSION="0.7.1"
export BASE_DOMAIN=$(oc get dns cluster -o jsonpath='{.spec.baseDomain}')
export MCP_HOST="mcp.apps.${BASE_DOMAIN}"
export GATEWAY_NAMESPACE="gateway-system"
export MCP_NAMESPACE="mcp-system"
export CHANNEL="preview"Order matters: Service Mesh before RHCL, RHCL before MCP Gateway.
oc create ns ${GATEWAY_NAMESPACE} --dry-run=client -o yaml | oc apply -f -
oc create ns ${MCP_NAMESPACE} --dry-run=client -o yaml | oc apply -f -oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: openshift-default
spec:
controllerName: openshift.io/gateway-controller/v1
EOFoc apply -f - <<EOF
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: servicemeshoperator3
namespace: openshift-operators
spec:
channel: stable
name: servicemeshoperator3
source: redhat-operators
sourceNamespace: openshift-marketplace
EOFWait for CRDs:
echo "Waiting for Istio CRDs..."
until oc wait crd/istios.sailoperator.io --for=condition=established &>/dev/null 2>&1; do sleep 5; done
until oc wait crd/istiocnis.sailoperator.io --for=condition=established &>/dev/null 2>&1; do sleep 5; done
echo "Istio CRDs ready"Deploy Istio instance:
oc create ns istio-system --dry-run=client -o yaml | oc apply -f -
oc apply -f - <<EOF
apiVersion: sailoperator.io/v1
kind: Istio
metadata:
name: default
namespace: istio-system
spec:
namespace: istio-system
EOFVerify:
oc wait --for=condition=Ready istio/default -n istio-system --timeout=120soc create ns kuadrant-system --dry-run=client -o yaml | oc apply -f -
oc apply -f - <<EOF
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
name: kuadrant
namespace: kuadrant-system
spec: {}
EOF
oc apply -f - <<EOF
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: rhcl-operator
namespace: kuadrant-system
spec:
channel: stable
name: rhcl-operator
source: redhat-operators
sourceNamespace: openshift-marketplace
EOFWait for CRD:
echo "Waiting for Kuadrant CRD..."
until oc wait crd/kuadrants.kuadrant.io --for=condition=established &>/dev/null 2>&1; do sleep 5; done
echo "Kuadrant CRD ready"Deploy Kuadrant instance:
oc apply -f - <<EOF
apiVersion: kuadrant.io/v1beta1
kind: Kuadrant
metadata:
name: kuadrant
namespace: ${MCP_NAMESPACE}
EOFVerify:
oc wait --for=condition=Ready kuadrant/kuadrant -n ${MCP_NAMESPACE} --timeout=120soc apply -f - <<EOF
apiVersion: operators.coreos.com/v1
kind: OperatorGroup
metadata:
name: mcp-gateway
namespace: ${MCP_NAMESPACE}
spec: {}
EOF
oc apply -f - <<EOF
apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
name: mcp-gateway
namespace: ${MCP_NAMESPACE}
spec:
channel: ${CHANNEL}
name: mcp-gateway
source: redhat-operators
sourceNamespace: openshift-marketplace
EOFVerify:
echo "Waiting for MCP Gateway operator..."
until oc get csv -n ${MCP_NAMESPACE} \
-l "operators.coreos.com/mcp-gateway.${MCP_NAMESPACE}=" \
-o jsonpath='{.items[0].status.phase}' 2>/dev/null | grep -q "Succeeded"; do
sleep 5
done
echo "MCP Gateway operator installed"Troubleshooting: If Kuadrant's AuthPolicy later shows
Accepted: Falsewith "Gateway API provider not installed", restart kuadrant-system pods:oc delete pod -n kuadrant-system --all. This happens when RHCL starts before Service Mesh finishes initializing.
With openshift-default, the auto-created service is named <gateway-name>-openshift-default:
export GW_SVC="mcp-gateway-openshift-default"Critical: Both listeners (
mcpandmcps) must be on the same port (8080). The router's:authorityrewrite fortools/callrouting only works within a single Envoy filter chain (same port). Different ports = tools/call routing breaks silently.
oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: mcp-gateway
namespace: ${GATEWAY_NAMESPACE}
labels:
istio: ingressgateway
istio.io/rev: openshift-gateway
spec:
gatewayClassName: openshift-default
listeners:
- name: mcp
hostname: "${MCP_HOST}"
port: 8080
protocol: HTTP
allowedRoutes:
namespaces:
from: All
- name: mcps
hostname: "*.mcp.local"
port: 8080
protocol: HTTP
allowedRoutes:
namespaces:
from: All
EOFThe MCPGatewayExtension in mcp-system references the Gateway in gateway-system. Without this ReferenceGrant, the cross-namespace reference is rejected.
oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: mcp-system-to-gateway-system
namespace: ${GATEWAY_NAMESPACE}
spec:
from:
- group: mcp.kuadrant.io
kind: MCPGatewayExtension
namespace: ${MCP_NAMESPACE}
to:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: ${GATEWAY_NAMESPACE}
EOFCritical:
privateHostmust be set explicitly. The auto-derived value assumes an-istiosuffix, which is wrong foropenshift-defaultclusters.
oc apply -f - <<EOF
apiVersion: mcp.kuadrant.io/v1alpha1
kind: MCPGatewayExtension
metadata:
name: mcp-gateway-extension
namespace: ${MCP_NAMESPACE}
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gateway
namespace: ${GATEWAY_NAMESPACE}
sectionName: mcp
privateHost: "${GW_SVC}.${GATEWAY_NAMESPACE}.svc.cluster.local:8080"
httpRouteManagement: Disabled
EOFVerify:
echo "Waiting for MCPGatewayExtension to be Ready..."
until oc get mcpgatewayextension mcp-gateway-extension -n ${MCP_NAMESPACE} \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -q "True"; do
sleep 5
done
echo "MCPGatewayExtension Ready"oc apply -f - <<EOF
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: mcp-gateway
namespace: ${GATEWAY_NAMESPACE}
spec:
host: ${MCP_HOST}
port:
targetPort: mcp
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
to:
kind: Service
name: ${GW_SVC}
weight: 100
wildcardPolicy: None
EOFThis routes external MCP traffic through the gateway. Must target the same sectionName as the MCPGatewayExtension (mcp).
oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mcp-gateway-route
namespace: ${MCP_NAMESPACE}
spec:
hostnames:
- ${MCP_HOST}
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gateway
namespace: ${GATEWAY_NAMESPACE}
sectionName: mcp
rules:
- backendRefs:
- name: mcp-gateway
port: 8080
matches:
- path:
type: PathPrefix
value: /mcp
- backendRefs:
- name: mcp-gateway
port: 8080
matches:
- path:
type: PathPrefix
value: /.well-known/oauth-protected-resource
EOFVerify:
oc get httproute mcp-gateway-route -n ${MCP_NAMESPACE} -o jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}'
# Expected: True
echooc create ns mcp-test --dry-run=client -o yaml | oc apply -f -
oc apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: everything-server
namespace: mcp-test
spec:
replicas: 1
selector:
matchLabels:
app: everything-server
template:
metadata:
labels:
app: everything-server
spec:
containers:
- name: everything-server
image: ghcr.io/kuadrant/mcp-gateway/test-everything-server:latest
imagePullPolicy: Always
ports:
- containerPort: 9090
env:
- name: PORT
value: "9090"
---
apiVersion: v1
kind: Service
metadata:
name: everything-server
namespace: mcp-test
spec:
selector:
app: everything-server
ports:
- port: 9090
targetPort: 9090
EOF
echo "Waiting for everything-server..."
oc wait --for=condition=ready pod -l app=everything-server -n mcp-test --timeout=120sThe upstream HTTPRoute in mcp-system references the Service in mcp-test:
oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: mcp-system-to-mcp-test
namespace: mcp-test
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: ${MCP_NAMESPACE}
to:
- group: ""
kind: Service
namespace: mcp-test
EOFCritical: Do NOT add header matches. The router's hairpin initialize request doesn't carry
x-mcp-servername, so a header match would cause404 NR route_not_found.
oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: everything-server-route
namespace: ${MCP_NAMESPACE}
labels:
mcp-server: "true"
spec:
hostnames:
- everything-server.mcp.local
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gateway
namespace: ${GATEWAY_NAMESPACE}
sectionName: mcps
rules:
- backendRefs:
- group: ""
kind: Service
name: everything-server
namespace: mcp-test
port: 9090
matches:
- path:
type: PathPrefix
value: /mcp
EOFoc apply -f - <<EOF
apiVersion: mcp.kuadrant.io/v1alpha1
kind: MCPServerRegistration
metadata:
name: everything-server
namespace: ${MCP_NAMESPACE}
spec:
prefix: hello_
path: /mcp
targetRef:
group: gateway.networking.k8s.io
kind: HTTPRoute
name: everything-server-route
EOFVerify:
echo "Waiting for MCPServerRegistration to be Ready..."
until oc get mcpserverregistration everything-server -n ${MCP_NAMESPACE} \
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -q "True"; do
sleep 5
done
echo "MCPServerRegistration Ready"
oc get mcpserverregistration -n ${MCP_NAMESPACE}Note: Tool discovery can take up to 60 seconds after the MCPServerRegistration becomes Ready. This is because kubelet syncs mounted Secrets at its
syncFrequencyinterval.
Critical: The AuthPolicy must target
sectionName: mcp(the client listener only). WithoutsectionName, it covers ALL listeners includingmcpswhere hairpin traffic flows. The hairpin uses a JWT signed byGATEWAY_SIGNING_KEY, not a K8s SA token, sokubernetesTokenReviewwould reject it with 401.
The
whenpredicate exempts hairpin requests (those carrying therouter-keyheader) from auth evaluation.
oc apply -f - <<EOF
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
name: mcp-auth-policy
namespace: ${GATEWAY_NAMESPACE}
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gateway
sectionName: mcp
when:
- predicate: "!request.headers.exists(h, h == 'router-key')"
rules:
authentication:
openshift-oauth:
kubernetesTokenReview:
audiences:
- https://kubernetes.default.svc
credentials:
authorizationHeader:
prefix: Bearer
response:
unauthenticated:
code: 401
body:
value: '{"error":"Unauthorized","message":"Authentication required."}'
EOFCustomize: Add
authorizationrules (OPA rego, K8s SubjectAccessReview) to restrict by group/role as needed.
Verify:
oc get authpolicy mcp-auth-policy -n ${GATEWAY_NAMESPACE} \
-o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}'
# Expected: True
echooc create serviceaccount mcp-test -n ${MCP_NAMESPACE} --dry-run=client -o yaml | oc apply -f -
SA_TOKEN=$(oc create token mcp-test -n ${MCP_NAMESPACE} \
--audience="https://kubernetes.default.svc" --duration=1h)curl -sk "https://${MCP_HOST}/mcp" \
-H "Authorization: Bearer ${SA_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}
}' -i 2>&1 | grep -E "^HTTP|^mcp-session-id"
# Expected: HTTP/2 200, mcp-session-id header presentExtract the session ID:
SESSION_ID=$(curl -sk "https://${MCP_HOST}/mcp" \
-H "Authorization: Bearer ${SA_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}
}' -D - -o /dev/null 2>/dev/null | grep -i mcp-session-id | tr -d '\r' | awk '{print $2}')
echo "Session ID: ${SESSION_ID}"curl -sk "https://${MCP_HOST}/mcp" \
-H "Authorization: Bearer ${SA_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: ${SESSION_ID}" \
-d '{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list"
}'
# Expected: JSON with tools array containing hello_* prefixed toolscurl -sk "https://${MCP_HOST}/mcp" \
-H "Authorization: Bearer ${SA_TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "mcp-session-id: ${SESSION_ID}" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "hello_echo",
"arguments": {"message": "hello from openshift"}
}
}'
# Expected: SSE response with echoed messagecurl -sk "https://${MCP_HOST}/mcp" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"}
}
}' -w "\nHTTP Status: %{http_code}\n"
# Expected: 401 Unauthorizedoc delete mcpserverregistration everything-server -n ${MCP_NAMESPACE} --ignore-not-found
oc delete httproute everything-server-route -n ${MCP_NAMESPACE} --ignore-not-found
oc delete mcpgatewayextension mcp-gateway-extension -n ${MCP_NAMESPACE} --ignore-not-found
oc delete httproute mcp-gateway-route -n ${MCP_NAMESPACE} --ignore-not-found
oc delete authpolicy mcp-auth-policy -n ${GATEWAY_NAMESPACE} --ignore-not-found
oc delete route mcp-gateway -n ${GATEWAY_NAMESPACE} --ignore-not-found
oc delete gateway mcp-gateway -n ${GATEWAY_NAMESPACE} --ignore-not-found
oc delete ns mcp-test --ignore-not-foundIf the customer uses istio instead of openshift-default:
| Aspect | openshift-default |
istio |
|---|---|---|
| GatewayClass | openshift-default |
istio |
| Service naming | <gateway>-openshift-default |
<gateway>-istio |
| privateHost | mcp-gateway-openshift-default.gateway-system.svc.cluster.local:8080 |
mcp-gateway-istio.gateway-system.svc.cluster.local:8080 |
| OCP Route targetPort | Listener name (mcp) |
Listener name (mcp) |
| External access | OCP Route (TLS edge termination) | OCP Route or Istio Gateway directly |
| Service Mesh operator | Still needed (provides Istio CRDs for RHCL) | Still needed (also provides the gateway) |
The key change: replace GW_SVC="mcp-gateway-openshift-default" with GW_SVC="mcp-gateway-istio" and update the GatewayClass name in the Gateway spec.
| Symptom | Cause | Fix |
|---|---|---|
tools/call returns 404 Not Found (empty body) |
Listeners on different ports | Both listeners must be on the same port (8080). Envoy can't route across filter chains |
tools/call returns 500 no such host |
Wrong privateHost (e.g. -istio suffix on openshift-default cluster) |
Set privateHost to <gateway>-openshift-default.<ns>.svc.cluster.local:8080 |
| Config change not taking effect | Kubelet Secret sync delay (up to 60s) | Wait 60 seconds after changes. Pod restart alone doesn't help if Secret hasn't synced |
RHCL CSVs stuck in Failed |
OperatorGroup has targetNamespaces set (OwnNamespace mode not supported) |
Use spec: {} on OperatorGroup for AllNamespaces mode |
| MCPGatewayExtension not Ready | Missing ReferenceGrant for cross-namespace Gateway ref | Create ReferenceGrant in Gateway's namespace |
| Tools not appearing after registration | Kubelet Secret sync delay (up to 60s) | Wait 60 seconds, no restart needed |
AuthPolicy shows Accepted: False |
RHCL started before Service Mesh finished | oc delete pod -n kuadrant-system --all to restart |
| ext_proc never fires | Client HTTPRoute on different sectionName than MCPGatewayExtension | Both must use same sectionName (mcp) |
406 Not Acceptable from upstream |
Missing Accept: application/json, text/event-stream header |
Always include the Accept header (MCP clients do this automatically) |