Skip to content

Commit 9fe1fd7

Browse files
authored
fix: mcp route to gateway cross namespace reference (#1489)
**Description** Previously when MCPRoute being reconciled, the controller assumed that all the parentRefs are local, which was a bug for cross-namespace use cases. This fixes the bug which existed in the syncGateawy function of MCPRoute controller. **Related Issues / PRs** Fixes #1483 --------- Signed-off-by: Hritik003 <[email protected]>
1 parent c092761 commit 9fe1fd7

File tree

4 files changed

+190
-1
lines changed

4 files changed

+190
-1
lines changed

internal/controller/mcp_route.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,7 +432,11 @@ func (c *MCPRouteController) newPerBackendRefHTTPRoute(ctx context.Context, dst
432432
// syncGateways synchronizes the gateways referenced by the MCPRoute by sending events to the gateway controller.
433433
func (c *MCPRouteController) syncGateways(ctx context.Context, mcpRoute *aigv1a1.MCPRoute) error {
434434
for _, p := range mcpRoute.Spec.ParentRefs {
435-
c.syncGateway(ctx, mcpRoute.Namespace, string(p.Name))
435+
gwNamespace := mcpRoute.Namespace
436+
if p.Namespace != nil {
437+
gwNamespace = string(*p.Namespace)
438+
}
439+
c.syncGateway(ctx, gwNamespace, string(p.Name))
436440
}
437441
return nil
438442
}

internal/controller/mcp_route_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,3 +372,44 @@ func TestMCPRouteController_ensureMCPBackendRefHTTPFilter(t *testing.T) {
372372
require.NoError(t, err)
373373
require.Nil(t, httpFilter.Spec.CredentialInjection)
374374
}
375+
376+
func TestMCPRouteController_syncGateways_NamespaceCrossReference(t *testing.T) {
377+
c := requireNewFakeClientWithIndexesForMCP(t)
378+
eventCh := internaltesting.NewControllerEventChan[*gwapiv1.Gateway]()
379+
380+
gateway1 := &gwapiv1.Gateway{
381+
ObjectMeta: metav1.ObjectMeta{Name: "gateway1", Namespace: "default"},
382+
}
383+
gateway2 := &gwapiv1.Gateway{
384+
ObjectMeta: metav1.ObjectMeta{Name: "gateway2", Namespace: "other-ns"},
385+
}
386+
387+
err := c.Create(t.Context(), gateway1)
388+
require.NoError(t, err)
389+
err = c.Create(t.Context(), gateway2)
390+
require.NoError(t, err)
391+
392+
ctrlr := NewMCPRouteController(c, fakekube.NewClientset(), logr.Discard(), eventCh.Ch)
393+
394+
mcpRoute := &aigv1a1.MCPRoute{
395+
ObjectMeta: metav1.ObjectMeta{Name: "test-route", Namespace: "default"},
396+
Spec: aigv1a1.MCPRouteSpec{
397+
ParentRefs: []gwapiv1.ParentReference{
398+
{Name: gwapiv1.ObjectName("gateway1"), Namespace: ptr.To(gwapiv1.Namespace("default"))},
399+
{Name: gwapiv1.ObjectName("gateway2"), Namespace: ptr.To(gwapiv1.Namespace("other-ns"))},
400+
},
401+
},
402+
}
403+
err = ctrlr.syncGateways(t.Context(), mcpRoute)
404+
require.NoError(t, err)
405+
406+
// Verify that events were sent for both gateways.
407+
// We should receive 2 events (one for each parent reference).
408+
gateways := eventCh.RequireItemsEventually(t, 2)
409+
require.Len(t, gateways, 2)
410+
411+
require.Equal(t, "gateway1", gateways[0].Name)
412+
require.Equal(t, "default", gateways[0].Namespace)
413+
require.Equal(t, "gateway2", gateways[1].Name)
414+
require.Equal(t, "other-ns", gateways[1].Namespace)
415+
}

tests/e2e/cross_namespace_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ package e2e
77

88
import (
99
"context"
10+
"fmt"
1011
"testing"
1112
"time"
1213

14+
"github.com/modelcontextprotocol/go-sdk/mcp"
1315
"github.com/openai/openai-go"
1416
"github.com/openai/openai-go/option"
1517
"github.com/stretchr/testify/require"
@@ -69,3 +71,37 @@ func TestCrossNamespace(t *testing.T) {
6971
}, 40*time.Second, 3*time.Second)
7072
})
7173
}
74+
75+
// TestCrossNamespaceMCPRoute tests MCPRoute with cross-namespace references.
76+
// This test validates that:
77+
// 1. A Gateway in one namespace (mcp-gw) can be referenced by an MCPRoute in another namespace (mcp-tenant)
78+
func TestCrossNamespaceMCPRoute(t *testing.T) {
79+
const manifest = "testdata/cross_namespace_mcproute.yaml"
80+
require.NoError(t, e2elib.KubectlApplyManifest(t.Context(), manifest))
81+
t.Cleanup(func() {
82+
_ = e2elib.KubectlDeleteManifest(context.Background(), manifest)
83+
})
84+
85+
const egSelector = "gateway.envoyproxy.io/owning-gateway-name=mcp-gateway"
86+
e2elib.RequireWaitForGatewayPodReady(t, egSelector)
87+
fwd := e2elib.RequireNewHTTPPortForwarder(t, e2elib.EnvoyGatewayNamespace, egSelector, e2elib.EnvoyGatewayDefaultServicePort)
88+
defer fwd.Kill()
89+
client := mcp.NewClient(&mcp.Implementation{Name: "demo-http-client", Version: "0.1.0"}, nil)
90+
91+
require.Eventually(t, func() bool {
92+
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
93+
defer cancel()
94+
var err error
95+
sess, err := client.Connect(
96+
ctx,
97+
&mcp.StreamableClientTransport{
98+
Endpoint: fmt.Sprintf("%s/mcp/cross-ns", fwd.Address()),
99+
}, nil)
100+
if err != nil {
101+
t.Logf("failed to connect to MCP server: %v", err)
102+
return false
103+
}
104+
defer sess.Close()
105+
return true
106+
}, 40*time.Second, 3*time.Second, "failed to connect to MCP server")
107+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Copyright Envoy AI Gateway Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
# The full text of the Apache license is available in the LICENSE file at
4+
# the root of the repo.
5+
---
6+
apiVersion: v1
7+
kind: Namespace
8+
metadata:
9+
name: mcp-tenant
10+
---
11+
apiVersion: v1
12+
kind: Namespace
13+
metadata:
14+
name: mcp-gw
15+
---
16+
apiVersion: gateway.networking.k8s.io/v1
17+
kind: GatewayClass
18+
metadata:
19+
name: mcp-gateway-class
20+
spec:
21+
controllerName: gateway.envoyproxy.io/gatewayclass-controller
22+
---
23+
apiVersion: gateway.networking.k8s.io/v1
24+
kind: Gateway
25+
metadata:
26+
name: mcp-gateway
27+
namespace: mcp-gw
28+
spec:
29+
gatewayClassName: mcp-gateway-class
30+
listeners:
31+
- name: http
32+
protocol: HTTP
33+
port: 80
34+
allowedRoutes:
35+
namespaces:
36+
from: All
37+
infrastructure:
38+
parametersRef:
39+
group: gateway.envoyproxy.io
40+
kind: EnvoyProxy
41+
name: mcp-envoyproxy
42+
---
43+
apiVersion: gateway.envoyproxy.io/v1alpha1
44+
kind: EnvoyProxy
45+
metadata:
46+
name: mcp-envoyproxy
47+
namespace: mcp-gw
48+
spec:
49+
provider:
50+
type: Kubernetes
51+
kubernetes:
52+
envoyDeployment:
53+
container:
54+
resources: {}
55+
---
56+
# This is the ONLY MCPRoute in this test to avoid side effects from reconciliation of other routes.
57+
apiVersion: aigateway.envoyproxy.io/v1alpha1
58+
kind: MCPRoute
59+
metadata:
60+
name: mcp-tenant-route
61+
namespace: mcp-tenant
62+
spec:
63+
path: "/mcp/cross-ns"
64+
parentRefs:
65+
- name: mcp-gateway
66+
kind: Gateway
67+
group: gateway.networking.k8s.io
68+
namespace: mcp-gw
69+
backendRefs:
70+
- name: mcp-backend-tenant
71+
namespace: mcp-tenant
72+
port: 1063
73+
---
74+
apiVersion: apps/v1
75+
kind: Deployment
76+
metadata:
77+
name: mcp-backend-tenant
78+
namespace: mcp-tenant
79+
spec:
80+
replicas: 1
81+
selector:
82+
matchLabels:
83+
app: mcp-backend-tenant
84+
template:
85+
metadata:
86+
labels:
87+
app: mcp-backend-tenant
88+
spec:
89+
containers:
90+
- name: mcp-backend-tenant
91+
image: docker.io/envoyproxy/ai-gateway-testmcpserver:latest
92+
imagePullPolicy: IfNotPresent
93+
ports:
94+
- containerPort: 1063
95+
---
96+
apiVersion: v1
97+
kind: Service
98+
metadata:
99+
name: mcp-backend-tenant
100+
namespace: mcp-tenant
101+
spec:
102+
selector:
103+
app: mcp-backend-tenant
104+
ports:
105+
- protocol: TCP
106+
port: 1063
107+
targetPort: 1063
108+
type: ClusterIP

0 commit comments

Comments
 (0)