Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions api/v1alpha1/mcpgatewayextension_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ type MCPGatewayExtensionSpec struct {

// privateHost overrides the internal host used for hair-pinning requests
// back through the gateway. Defaults to
// <gateway>-istio.<ns>.svc.cluster.local:<port>, with an https:// scheme
// prefix when the targeted Gateway listener uses the HTTPS protocol. The
// <gateway>-<gatewayClassName>.<ns>.svc.cluster.local:<port>, with an https://
// scheme prefix when the targeted Gateway listener uses the HTTPS protocol. The
// value supplied here is honoured verbatim, so an operator can include a
// scheme (e.g. "https://my-gw:443") or pin to a different port.
// +optional
Expand Down Expand Up @@ -295,16 +295,18 @@ func (m *MCPGatewayExtension) SetReadyCondition(status metav1.ConditionStatus, r
})
}

// InternalHost returns the internal/private host computed from the targetRef
func (m *MCPGatewayExtension) InternalHost(port uint32) string {
// InternalHost returns the internal/private host computed from the targetRef.
// gatewayClassName is used to derive the Service name created by the Gateway controller,
// which follows the convention <gateway-name>-<gatewayClassName>.<namespace>.svc.cluster.local.
func (m *MCPGatewayExtension) InternalHost(port uint32, gatewayClassName string) string {
if m.Spec.PrivateHost != "" {
return m.Spec.PrivateHost
}
gatewayNamespace := m.Spec.TargetRef.Namespace
if gatewayNamespace == "" {
gatewayNamespace = m.Namespace
}
return fmt.Sprintf(m.Spec.TargetRef.Name+"-istio."+gatewayNamespace+".svc.cluster.local:%v", port)
return fmt.Sprintf("%s-%s.%s.svc.cluster.local:%v", m.Spec.TargetRef.Name, gatewayClassName, gatewayNamespace, port)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// HTTPRouteDisabled returns true if HTTPRouteManagement is set to Disabled
Expand Down
34 changes: 24 additions & 10 deletions api/v1alpha1/mcpgatewayextension_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ import (

func TestMCPGatewayExtension_InternalHost(t *testing.T) {
tests := []struct {
name string
namespace string
targetRef MCPGatewayExtensionTargetReference
privateHost string
want string
name string
namespace string
targetRef MCPGatewayExtensionTargetReference
privateHost string
gatewayClassName string
want string
}{
{
name: "uses targetRef namespace when specified",
Expand All @@ -21,15 +22,27 @@ func TestMCPGatewayExtension_InternalHost(t *testing.T) {
Name: "my-gateway",
Namespace: "gateway-system",
},
want: "my-gateway-istio.gateway-system.svc.cluster.local:8080",
gatewayClassName: "istio",
want: "my-gateway-istio.gateway-system.svc.cluster.local:8080",
},
{
name: "falls back to extension namespace when targetRef namespace empty",
namespace: "team-a",
targetRef: MCPGatewayExtensionTargetReference{
Name: "my-gateway",
},
want: "my-gateway-istio.team-a.svc.cluster.local:8080",
gatewayClassName: "istio",
want: "my-gateway-istio.team-a.svc.cluster.local:8080",
},
{
name: "openshift-default gateway class uses correct service name",
namespace: "mcp-system",
targetRef: MCPGatewayExtensionTargetReference{
Name: "my-gateway",
Namespace: "gateway-system",
},
gatewayClassName: "openshift-default",
want: "my-gateway-openshift-default.gateway-system.svc.cluster.local:8080",
},
{
name: "privateHost overrides computed value",
Expand All @@ -38,8 +51,9 @@ func TestMCPGatewayExtension_InternalHost(t *testing.T) {
Name: "my-gateway",
Namespace: "gateway-system",
},
privateHost: "custom-gateway.custom-ns.svc.cluster.local:9090",
want: "custom-gateway.custom-ns.svc.cluster.local:9090",
gatewayClassName: "istio",
privateHost: "custom-gateway.custom-ns.svc.cluster.local:9090",
want: "custom-gateway.custom-ns.svc.cluster.local:9090",
},
}

Expand All @@ -54,7 +68,7 @@ func TestMCPGatewayExtension_InternalHost(t *testing.T) {
PrivateHost: tt.privateHost,
},
}
if got := m.InternalHost(8080); got != tt.want {
if got := m.InternalHost(8080, tt.gatewayClassName); got != tt.want {
t.Errorf("InternalHost() = %v, want %v", got, tt.want)
}
})
Expand Down
2 changes: 1 addition & 1 deletion bundle/manifests/mcp-gateway.clusterserviceversion.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ metadata:
capabilities: Basic Install
categories: Integration & Delivery
containerImage: ghcr.io/kuadrant/mcp-controller:latest
createdAt: "2026-06-25T14:19:00Z"
createdAt: "2026-07-02T17:28:58Z"
description: An Envoy-based gateway for Model Context Protocol (MCP) servers
operators.operatorframework.io/builder: operator-sdk-v1.38.0
operators.operatorframework.io/project_layout: go.kubebuilder.io/v4
Expand Down
4 changes: 2 additions & 2 deletions bundle/manifests/mcp.kuadrant.io_mcpgatewayextensions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ spec:
description: |-
privateHost overrides the internal host used for hair-pinning requests
back through the gateway. Defaults to
<gateway>-istio.<ns>.svc.cluster.local:<port>, with an https:// scheme
prefix when the targeted Gateway listener uses the HTTPS protocol. The
<gateway>-<gatewayClassName>.<ns>.svc.cluster.local:<port>, with an https://
scheme prefix when the targeted Gateway listener uses the HTTPS protocol. The
value supplied here is honoured verbatim, so an operator can include a
scheme (e.g. "https://my-gw:443") or pin to a different port.
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ spec:
description: |-
privateHost overrides the internal host used for hair-pinning requests
back through the gateway. Defaults to
<gateway>-istio.<ns>.svc.cluster.local:<port>, with an https:// scheme
prefix when the targeted Gateway listener uses the HTTPS protocol. The
<gateway>-<gatewayClassName>.<ns>.svc.cluster.local:<port>, with an https://
scheme prefix when the targeted Gateway listener uses the HTTPS protocol. The
value supplied here is honoured verbatim, so an operator can include a
scheme (e.g. "https://my-gw:443") or pin to a different port.
type: string
Expand Down
4 changes: 2 additions & 2 deletions config/crd/mcp.kuadrant.io_mcpgatewayextensions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ spec:
description: |-
privateHost overrides the internal host used for hair-pinning requests
back through the gateway. Defaults to
<gateway>-istio.<ns>.svc.cluster.local:<port>, with an https:// scheme
prefix when the targeted Gateway listener uses the HTTPS protocol. The
<gateway>-<gatewayClassName>.<ns>.svc.cluster.local:<port>, with an https://
scheme prefix when the targeted Gateway listener uses the HTTPS protocol. The
value supplied here is honoured verbatim, so an operator can include a
scheme (e.g. "https://my-gw:443") or pin to a different port.
type: string
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/mcpgatewayextension.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
|-----------|----------|:------------:|-----------------|
| `targetRef` | [MCPGatewayExtensionTargetReference](#mcpgatewayextensiontargetreference) | Yes | The Gateway listener to extend with MCP protocol support |
| `publicHost` | String | No | Overrides the public host derived from the listener hostname. Use when the listener has a wildcard and you need a specific host |
| `privateHost` | String | No | Overrides the internal host used for hair-pinning requests back through the gateway. Defaults to `<gateway>-istio.<ns>.svc.cluster.local:<port>`, with an `https://` scheme prefix when the targeted Gateway listener uses the HTTPS protocol. The supplied value is honoured verbatim, so an operator can include a scheme (e.g. `https://my-gw:443`) or pin to a different port. |
| `privateHost` | String | No | Overrides the internal host used for hair-pinning requests back through the gateway. Defaults to `<gateway>-<gatewayClassName>.<ns>.svc.cluster.local:<port>`, with an `https://` scheme prefix when the targeted Gateway listener uses the HTTPS protocol. The supplied value is honoured verbatim, so an operator can include a scheme (e.g. `https://my-gw:443`) or pin to a different port. |
| `backendPingIntervalSeconds` | Integer | No | How often (in seconds) the broker pings upstream MCP servers. Min: 10, Max: 7200, Default: 60 |
| `trustedHeadersKey` | [TrustedHeadersKey](#trustedheaderskey) | No | Configures trusted-header key pair for JWT-based tool filtering. When set, the public key secret is injected into the broker deployment via the `TRUSTED_HEADER_PUBLIC_KEY` env var |
| `httpRouteManagement` | String | No | Controls whether the operator manages the gateway HTTPRoute. `Enabled` (default): creates and manages the HTTPRoute. `Disabled`: does not create an HTTPRoute. Disabling does not delete a previously created route |
Expand Down
8 changes: 4 additions & 4 deletions internal/controller/broker_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,25 +331,25 @@ func derivePublicHost(listenerConfig *mcpv1alpha1.ListenerConfig, annotationOver
// port). Otherwise the host is computed from the targetRef and listener port,
// and an https:// scheme prefix is added when the listener is HTTPS so the
// broker hairpin doesn't send plain HTTP to a TLS-only port (issue #917).
func derivePrivateHost(mcpExt *mcpv1alpha1.MCPGatewayExtension, listenerConfig *mcpv1alpha1.ListenerConfig) string {
func derivePrivateHost(mcpExt *mcpv1alpha1.MCPGatewayExtension, listenerConfig *mcpv1alpha1.ListenerConfig, gatewayClassName string) string {
if mcpExt.Spec.PrivateHost != "" {
return mcpExt.Spec.PrivateHost
}
host := mcpExt.InternalHost(listenerConfig.Port)
host := mcpExt.InternalHost(listenerConfig.Port, gatewayClassName)
// listener.Protocol is the Gateway API protocol string, e.g. "HTTPS".
if strings.EqualFold(listenerConfig.Protocol, "HTTPS") {
return "https://" + host
}
return host
}

func (r *MCPGatewayExtensionReconciler) reconcileBrokerRouter(ctx context.Context, mcpExt *mcpv1alpha1.MCPGatewayExtension, listenerConfig *mcpv1alpha1.ListenerConfig) (bool, error) {
func (r *MCPGatewayExtensionReconciler) reconcileBrokerRouter(ctx context.Context, mcpExt *mcpv1alpha1.MCPGatewayExtension, listenerConfig *mcpv1alpha1.ListenerConfig, gatewayClassName string) (bool, error) {
// derive values from listener config before building resources
publicHost, err := derivePublicHost(listenerConfig, mcpExt.Spec.PublicHost)
if err != nil {
return false, newValidationError(mcpv1alpha1.ConditionReasonInvalid, err.Error())
}
internalHost := derivePrivateHost(mcpExt, listenerConfig)
internalHost := derivePrivateHost(mcpExt, listenerConfig, gatewayClassName)

// reconcile service account (must exist before deployment)
serviceAccount := r.buildBrokerRouterServiceAccount(mcpExt)
Expand Down
78 changes: 54 additions & 24 deletions internal/controller/deployment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,22 +436,33 @@ func TestBuildBrokerRouterDeployment_InternalHost(t *testing.T) {
extNamespace string
targetRefName string
targetRefNS string
gatewayClassName string
wantInternalHost string
}{
{
name: "uses targetRef namespace when specified",
extNamespace: "team-a",
targetRefName: "my-gateway",
targetRefNS: "gateway-system",
gatewayClassName: "istio",
wantInternalHost: "my-gateway-istio.gateway-system.svc.cluster.local:8080",
},
{
name: "falls back to extension namespace when targetRef namespace empty",
extNamespace: "team-a",
targetRefName: "my-gateway",
targetRefNS: "",
gatewayClassName: "istio",
wantInternalHost: "my-gateway-istio.team-a.svc.cluster.local:8080",
},
{
name: "uses gatewayClassName for openshift-default",
extNamespace: "mcp-system",
targetRefName: "my-gateway",
targetRefNS: "gateway-system",
gatewayClassName: "openshift-default",
wantInternalHost: "my-gateway-openshift-default.gateway-system.svc.cluster.local:8080",
},
}

for _, tt := range tests {
Expand All @@ -472,7 +483,7 @@ func TestBuildBrokerRouterDeployment_InternalHost(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, tt.gatewayClassName))
command := deployment.Spec.Template.Spec.Containers[0].Command

wantFlag := "--mcp-gateway-private-host=" + tt.wantInternalHost
Expand Down Expand Up @@ -528,7 +539,7 @@ func TestBuildBrokerRouterDeployment_PollInterval(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, "istio"))
command := deployment.Spec.Template.Spec.Containers[0].Command

if tt.wantAbsent {
Expand Down Expand Up @@ -576,7 +587,7 @@ func TestBuildBrokerRouterDeployment_NoRouterKeyFlag(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, "istio"))
command := deployment.Spec.Template.Spec.Containers[0].Command

for _, arg := range command {
Expand Down Expand Up @@ -659,7 +670,7 @@ func TestBuildBrokerRouterDeployment_LogLevel(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, "istio"))
command := deployment.Spec.Template.Spec.Containers[0].Command

var got string
Expand Down Expand Up @@ -716,7 +727,7 @@ func TestBuildBrokerRouterDeployment_TrustedHeadersKey(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, "istio"))
container := deployment.Spec.Template.Spec.Containers[0]

// JWT_SESSION_SIGNING_KEY should always be present
Expand Down Expand Up @@ -883,7 +894,7 @@ func TestBuildBrokerRouterDeployment_ServiceAccount(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, "istio"))

if deployment.Spec.Template.Spec.ServiceAccountName != brokerRouterName {
t.Errorf("expected ServiceAccountName %q, got %q", brokerRouterName, deployment.Spec.Template.Spec.ServiceAccountName)
Expand Down Expand Up @@ -997,10 +1008,11 @@ func TestDerivePublicHost(t *testing.T) {

func TestDerivePrivateHost(t *testing.T) {
tests := []struct {
name string
spec mcpv1alpha1.MCPGatewayExtensionSpec
listenerConfig *mcpv1alpha1.ListenerConfig
want string
name string
spec mcpv1alpha1.MCPGatewayExtensionSpec
listenerConfig *mcpv1alpha1.ListenerConfig
gatewayClassName string
want string
}{
{
name: "HTTP listener: bare host, no scheme prefix (backwards compatible)",
Expand All @@ -1010,8 +1022,9 @@ func TestDerivePrivateHost(t *testing.T) {
Namespace: "gateway-system",
},
},
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 8080, Protocol: "HTTP"},
want: "my-gw-istio.gateway-system.svc.cluster.local:8080",
gatewayClassName: "istio",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 8080, Protocol: "HTTP"},
want: "my-gw-istio.gateway-system.svc.cluster.local:8080",
},
{
name: "HTTPS listener: scheme is prepended (issue #917)",
Expand All @@ -1021,8 +1034,9 @@ func TestDerivePrivateHost(t *testing.T) {
Namespace: "gateway-system",
},
},
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "HTTPS"},
want: "https://my-gw-istio.gateway-system.svc.cluster.local:443",
gatewayClassName: "istio",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "HTTPS"},
want: "https://my-gw-istio.gateway-system.svc.cluster.local:443",
},
{
name: "HTTPS listener with mixed-case protocol value still detected",
Expand All @@ -1032,8 +1046,21 @@ func TestDerivePrivateHost(t *testing.T) {
Namespace: "gateway-system",
},
},
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "https"},
want: "https://my-gw-istio.gateway-system.svc.cluster.local:443",
gatewayClassName: "istio",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "https"},
want: "https://my-gw-istio.gateway-system.svc.cluster.local:443",
},
{
name: "openshift-default gateway class uses correct service name",
spec: mcpv1alpha1.MCPGatewayExtensionSpec{
TargetRef: mcpv1alpha1.MCPGatewayExtensionTargetReference{
Name: "my-gateway",
Namespace: "gateway-system",
},
},
gatewayClassName: "openshift-default",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "HTTPS"},
want: "https://my-gateway-openshift-default.gateway-system.svc.cluster.local:443",
},
{
name: "PrivateHost override is honoured verbatim (no scheme injection)",
Expand All @@ -1044,8 +1071,9 @@ func TestDerivePrivateHost(t *testing.T) {
},
PrivateHost: "my-gw-istio.gateway-system.svc.cluster.local:8081",
},
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "HTTPS"},
want: "my-gw-istio.gateway-system.svc.cluster.local:8081",
gatewayClassName: "istio",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 443, Protocol: "HTTPS"},
want: "my-gw-istio.gateway-system.svc.cluster.local:8081",
},
{
name: "PrivateHost override may carry its own scheme",
Expand All @@ -1056,8 +1084,9 @@ func TestDerivePrivateHost(t *testing.T) {
},
PrivateHost: "https://custom.example.com:443",
},
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 8080, Protocol: "HTTP"},
want: "https://custom.example.com:443",
gatewayClassName: "istio",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 8080, Protocol: "HTTP"},
want: "https://custom.example.com:443",
},
{
name: "TCP listener (no recognised TLS): fallback to plain host (no scheme)",
Expand All @@ -1067,15 +1096,16 @@ func TestDerivePrivateHost(t *testing.T) {
Namespace: "gateway-system",
},
},
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 9090, Protocol: "TCP"},
want: "my-gw-istio.gateway-system.svc.cluster.local:9090",
gatewayClassName: "istio",
listenerConfig: &mcpv1alpha1.ListenerConfig{Port: 9090, Protocol: "TCP"},
want: "my-gw-istio.gateway-system.svc.cluster.local:9090",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mcpExt := &mcpv1alpha1.MCPGatewayExtension{Spec: tt.spec}
got := derivePrivateHost(mcpExt, tt.listenerConfig)
got := derivePrivateHost(mcpExt, tt.listenerConfig, tt.gatewayClassName)
if got != tt.want {
t.Errorf("derivePrivateHost() = %q, want %q", got, tt.want)
}
Expand Down Expand Up @@ -2027,7 +2057,7 @@ func TestBuildBrokerRouterDeployment_ReadinessProbe(t *testing.T) {
},
}

deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080))
deployment := r.buildBrokerRouterDeployment(mcpExt, "mcp.example.com", mcpExt.InternalHost(8080, "istio"))
probe := deployment.Spec.Template.Spec.Containers[0].ReadinessProbe

if probe == nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/controller/mcpgatewayextension_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ func (r *MCPGatewayExtensionReconciler) reconcileActive(ctx context.Context, mcp
return ctrl.Result{}, err
}

deploymentReady, err := r.reconcileBrokerRouter(ctx, mcpExt, listenerConfig)
deploymentReady, err := r.reconcileBrokerRouter(ctx, mcpExt, listenerConfig, string(targetGateway.Spec.GatewayClassName))
if err != nil {
var valErr *validationError
if errors.As(err, &valErr) {
Expand Down
Loading