Skip to content

Instantly share code, notes, and snippets.

@david-martin
Last active July 9, 2026 16:43
Show Gist options
  • Select an option

  • Save david-martin/8f09f824acc8aeff006e64b19545529c to your computer and use it in GitHub Desktop.

Select an option

Save david-martin/8f09f824acc8aeff006e64b19545529c to your computer and use it in GitHub Desktop.
MCP Gateway on OpenShift — Install Guide

MCP Gateway on OpenShift — Install Guide

Overview

This guide installs MCP Gateway on OpenShift using OLM operators, with:

  • openshift-default GatewayClass (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 istio instead of openshift-default, see the differences section at the end.


Prerequisites

  • OpenShift 4.16+ cluster with oc CLI authenticated
  • redhat-operators CatalogSource available in openshift-marketplace

Configuration

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"

Step 1 — Install operators via OLM

Order matters: Service Mesh before RHCL, RHCL before MCP Gateway.

1a. Create namespaces

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 -

1b. GatewayClass

oc apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: openshift-default
spec:
  controllerName: openshift.io/gateway-controller/v1
EOF

1c. OpenShift Service Mesh (Istio as Gateway API provider)

oc 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
EOF

Wait 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
EOF

Verify:

oc wait --for=condition=Ready istio/default -n istio-system --timeout=120s

1d. Red Hat Connectivity Link (Kuadrant)

oc 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
EOF

Wait 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}
EOF

Verify:

oc wait --for=condition=Ready kuadrant/kuadrant -n ${MCP_NAMESPACE} --timeout=120s

1e. MCP Gateway operator

oc 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
EOF

Verify:

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: False with "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.


Step 2 — Gateway and MCPGatewayExtension

2a. Determine the gateway service name

With openshift-default, the auto-created service is named <gateway-name>-openshift-default:

export GW_SVC="mcp-gateway-openshift-default"

2b. Create the Gateway

Critical: Both listeners (mcp and mcps) must be on the same port (8080). The router's :authority rewrite for tools/call routing 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
EOF

2c. ReferenceGrant (cross-namespace)

The 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}
EOF

2d. MCPGatewayExtension

Critical: privateHost must be set explicitly. The auto-derived value assumes an -istio suffix, which is wrong for openshift-default clusters.

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
EOF

Verify:

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"

Step 3 — OpenShift Route and client HTTPRoute

3a. OCP Route (external TLS termination)

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
EOF

3b. Client HTTPRoute

This 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
EOF

Verify:

oc get httproute mcp-gateway-route -n ${MCP_NAMESPACE} -o jsonpath='{.status.parents[0].conditions[?(@.type=="Accepted")].status}'
# Expected: True
echo

Step 4 — Deploy and register a test MCP server

4a. Deploy the everything-server

oc 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=120s

4b. ReferenceGrant (mcp-system → mcp-test)

The 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
EOF

4c. Upstream HTTPRoute

Critical: Do NOT add header matches. The router's hairpin initialize request doesn't carry x-mcp-servername, so a header match would cause 404 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
EOF

4d. MCPServerRegistration

oc 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
EOF

Verify:

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 syncFrequency interval.


Step 5 — AuthPolicy (Kubernetes Token Review)

Critical: The AuthPolicy must target sectionName: mcp (the client listener only). Without sectionName, it covers ALL listeners including mcps where hairpin traffic flows. The hairpin uses a JWT signed by GATEWAY_SIGNING_KEY, not a K8s SA token, so kubernetesTokenReview would reject it with 401.

The when predicate exempts hairpin requests (those carrying the router-key header) 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."}'
EOF

Customize: Add authorization rules (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
echo

Step 6 — End-to-end verification

Create a test service account and token

oc 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)

Test 1: Initialize

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 present

Extract 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}"

Test 2: List tools

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 tools

Test 3: Call a tool

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": 3,
    "method": "tools/call",
    "params": {
      "name": "hello_echo",
      "arguments": {"message": "hello from openshift"}
    }
  }'
# Expected: SSE response with echoed message

Test 4: Unauthenticated request (should fail)

curl -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 Unauthorized

Cleanup

oc 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-found

Istio GatewayClass differences

If 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.


Common pitfalls

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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment