From 1bb51082241de2a777d1de50474adfb02eba62d2 Mon Sep 17 00:00:00 2001 From: bentito Date: Mon, 16 Feb 2026 16:14:20 -0500 Subject: [PATCH 1/6] NE-2447: Implement inspect_route tool --- pkg/toolsets/netedge/routes.go | 87 +++++++++++++++++++++ pkg/toolsets/netedge/routes_test.go | 114 ++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 pkg/toolsets/netedge/routes.go create mode 100644 pkg/toolsets/netedge/routes_test.go diff --git a/pkg/toolsets/netedge/routes.go b/pkg/toolsets/netedge/routes.go new file mode 100644 index 000000000..ac9965ddf --- /dev/null +++ b/pkg/toolsets/netedge/routes.go @@ -0,0 +1,87 @@ +package netedge + +import ( + "encoding/json" + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/google/jsonschema-go/jsonschema" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func initRoutes() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "inspect_route", + Description: "Inspect an OpenShift Route to view its configuration, status, and related services.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Route namespace", + }, + "route": { + Type: "string", + Description: "Route name", + }, + }, + Required: []string{"namespace", "route"}, + }, + Annotations: api.ToolAnnotations{ + Title: "Inspect Route", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: inspectRoute, + }, + } +} + +func inspectRoute(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace, err := api.RequiredString(params, "namespace") + if err != nil { + return api.NewToolCallResult("", err), nil + } + routeName, err := api.RequiredString(params, "route") + if err != nil { + return api.NewToolCallResult("", err), nil + } + + cfg := params.RESTConfig() + if cfg == nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil + } + + cl, err := newClientFunc(cfg, client.Options{Scheme: kubernetes.Scheme}) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to create controller-runtime client: %w", err)), nil + } + + // Use Unstructured for Route + route := &unstructured.Unstructured{} + route.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "route.openshift.io", + Version: "v1", + Kind: "Route", + }) + + err = cl.Get(params.Context, client.ObjectKey{Namespace: namespace, Name: routeName}, route) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get route %s/%s: %w", namespace, routeName, err)), nil + } + + data, err := json.MarshalIndent(route.Object, "", " ") + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal route: %w", err)), nil + } + + return api.NewToolCallResult(string(data), nil), nil +} diff --git a/pkg/toolsets/netedge/routes_test.go b/pkg/toolsets/netedge/routes_test.go new file mode 100644 index 000000000..c92ab14dc --- /dev/null +++ b/pkg/toolsets/netedge/routes_test.go @@ -0,0 +1,114 @@ +package netedge + +import ( + "context" + "encoding/json" + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestInspectRoute(t *testing.T) { + tests := []struct { + name string + namespace string + route string + existingObjs []runtime.Object + expectedError string + validate func(t *testing.T, result string) + }{ + { + name: "successful retrieval", + namespace: "default", + route: "my-route", + existingObjs: []runtime.Object{ + &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "route.openshift.io/v1", + "kind": "Route", + "metadata": map[string]interface{}{ + "name": "my-route", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "host": "example.com", + }, + }, + }, + }, + validate: func(t *testing.T, result string) { + var r map[string]interface{} + err := json.Unmarshal([]byte(result), &r) + require.NoError(t, err) + assert.Equal(t, "my-route", r["metadata"].(map[string]interface{})["name"]) + assert.Equal(t, "example.com", r["spec"].(map[string]interface{})["host"]) + }, + }, + { + name: "route not found", + namespace: "default", + route: "missing", + existingObjs: []runtime.Object{}, + expectedError: "failed to get route", + }, + { + name: "missing arguments", + namespace: "", + route: "", + expectedError: "parameter required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Override the client creation function + oldNewClientFunc := newClientFunc + newClientFunc = func(config *rest.Config, options client.Options) (client.Client, error) { + return fake.NewClientBuilder().WithRuntimeObjects(tt.existingObjs...).Build(), nil + } + defer func() { newClientFunc = oldNewClientFunc }() + + // Create mock params + args := make(map[string]any) + if tt.namespace != "" { + args["namespace"] = tt.namespace + } + if tt.route != "" { + args["route"] = tt.route + } + + // Mock ToolHandlerParams + mockReq := &mockToolCallRequest{args: args} + + // We need a non-nil RESTConfig to pass the check in the handler + params := api.ToolHandlerParams{ + Context: context.Background(), + ToolCallRequest: mockReq, + KubernetesClient: &mockKubernetesClient{restConfig: &rest.Config{}}, + } + + result, err := inspectRoute(params) + + if tt.expectedError != "" { + assert.NoError(t, err) + require.NotNil(t, result) + require.Error(t, result.Error) + assert.Contains(t, result.Error.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + require.NotNil(t, result) + assert.NoError(t, result.Error) + if tt.validate != nil { + tt.validate(t, result.Content) + } + } + }) + } +} From c240f870e8bf6d14d5ef1c08a9963b30cd83212b Mon Sep 17 00:00:00 2001 From: bentito Date: Mon, 16 Feb 2026 18:38:12 -0500 Subject: [PATCH 2/6] Register inspect_route tool in toolset --- pkg/toolsets/netedge/routes.go | 26 +++--------- pkg/toolsets/netedge/routes_test.go | 64 +++++++++++------------------ pkg/toolsets/netedge/toolset.go | 1 + 3 files changed, 32 insertions(+), 59 deletions(-) diff --git a/pkg/toolsets/netedge/routes.go b/pkg/toolsets/netedge/routes.go index ac9965ddf..d7e131d62 100644 --- a/pkg/toolsets/netedge/routes.go +++ b/pkg/toolsets/netedge/routes.go @@ -5,12 +5,10 @@ import ( "fmt" "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/google/jsonschema-go/jsonschema" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client" ) func initRoutes() []api.ServerTool { @@ -55,25 +53,13 @@ func inspectRoute(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult("", err), nil } - cfg := params.RESTConfig() - if cfg == nil { - return api.NewToolCallResult("", fmt.Errorf("failed to get REST config")), nil + gvr := schema.GroupVersionResource{ + Group: "route.openshift.io", + Version: "v1", + Resource: "routes", } - cl, err := newClientFunc(cfg, client.Options{Scheme: kubernetes.Scheme}) - if err != nil { - return api.NewToolCallResult("", fmt.Errorf("failed to create controller-runtime client: %w", err)), nil - } - - // Use Unstructured for Route - route := &unstructured.Unstructured{} - route.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "route.openshift.io", - Version: "v1", - Kind: "Route", - }) - - err = cl.Get(params.Context, client.ObjectKey{Namespace: namespace, Name: routeName}, route) + route, err := params.DynamicClient().Resource(gvr).Namespace(namespace).Get(params.Context, routeName, metav1.GetOptions{}) if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to get route %s/%s: %w", namespace, routeName, err)), nil } diff --git a/pkg/toolsets/netedge/routes_test.go b/pkg/toolsets/netedge/routes_test.go index c92ab14dc..3eeaca1f6 100644 --- a/pkg/toolsets/netedge/routes_test.go +++ b/pkg/toolsets/netedge/routes_test.go @@ -1,28 +1,22 @@ package netedge import ( - "context" "encoding/json" - "testing" - "github.com/containers/kubernetes-mcp-server/pkg/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/rest" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" + "k8s.io/client-go/dynamic/fake" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" ) -func TestInspectRoute(t *testing.T) { +func (s *NetEdgeTestSuite) TestInspectRoute() { tests := []struct { name string namespace string route string existingObjs []runtime.Object expectedError string - validate func(t *testing.T, result string) + validate func(result string) }{ { name: "successful retrieval", @@ -43,12 +37,12 @@ func TestInspectRoute(t *testing.T) { }, }, }, - validate: func(t *testing.T, result string) { + validate: func(result string) { var r map[string]interface{} err := json.Unmarshal([]byte(result), &r) - require.NoError(t, err) - assert.Equal(t, "my-route", r["metadata"].(map[string]interface{})["name"]) - assert.Equal(t, "example.com", r["spec"].(map[string]interface{})["host"]) + s.Require().NoError(err) + s.Assert().Equal("my-route", r["metadata"].(map[string]interface{})["name"]) + s.Assert().Equal("example.com", r["spec"].(map[string]interface{})["host"]) }, }, { @@ -67,13 +61,12 @@ func TestInspectRoute(t *testing.T) { } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Override the client creation function - oldNewClientFunc := newClientFunc - newClientFunc = func(config *rest.Config, options client.Options) (client.Client, error) { - return fake.NewClientBuilder().WithRuntimeObjects(tt.existingObjs...).Build(), nil - } - defer func() { newClientFunc = oldNewClientFunc }() + s.Run(tt.name, func() { + // Create fake dynamic client + scheme := runtime.NewScheme() + err := clientgoscheme.AddToScheme(scheme) + s.Require().NoError(err) + dynClient := fake.NewSimpleDynamicClient(scheme, tt.existingObjs...) // Create mock params args := make(map[string]any) @@ -84,29 +77,22 @@ func TestInspectRoute(t *testing.T) { args["route"] = tt.route } - // Mock ToolHandlerParams - mockReq := &mockToolCallRequest{args: args} - - // We need a non-nil RESTConfig to pass the check in the handler - params := api.ToolHandlerParams{ - Context: context.Background(), - ToolCallRequest: mockReq, - KubernetesClient: &mockKubernetesClient{restConfig: &rest.Config{}}, - } + s.SetArgs(args) + s.SetDynamicClient(dynClient) - result, err := inspectRoute(params) + result, err := inspectRoute(s.params) if tt.expectedError != "" { - assert.NoError(t, err) - require.NotNil(t, result) - require.Error(t, result.Error) - assert.Contains(t, result.Error.Error(), tt.expectedError) + s.Assert().NoError(err) + s.Require().NotNil(result) + s.Require().Error(result.Error) + s.Assert().Contains(result.Error.Error(), tt.expectedError) } else { - assert.NoError(t, err) - require.NotNil(t, result) - assert.NoError(t, result.Error) + s.Assert().NoError(err) + s.Require().NotNil(result) + s.Assert().NoError(result.Error) if tt.validate != nil { - tt.validate(t, result.Content) + tt.validate(result.Content) } } }) diff --git a/pkg/toolsets/netedge/toolset.go b/pkg/toolsets/netedge/toolset.go index 006733fa2..76ac80f18 100644 --- a/pkg/toolsets/netedge/toolset.go +++ b/pkg/toolsets/netedge/toolset.go @@ -27,6 +27,7 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { initCoreDNS(), initEndpoints(), initProbeDNSLocal(), + initRoutes(), ) } From 7ceb2ec40e71d3f2a5a5602592aded4e6accbaaf Mon Sep 17 00:00:00 2001 From: bentito Date: Mon, 16 Feb 2026 22:20:52 -0500 Subject: [PATCH 3/6] Fix lint and test errors by refactoring mocks --- pkg/toolsets/netedge/coredns_test.go | 1 - pkg/toolsets/netedge/mock_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 pkg/toolsets/netedge/mock_test.go diff --git a/pkg/toolsets/netedge/coredns_test.go b/pkg/toolsets/netedge/coredns_test.go index 35acdd15d..5d02e537e 100644 --- a/pkg/toolsets/netedge/coredns_test.go +++ b/pkg/toolsets/netedge/coredns_test.go @@ -9,7 +9,6 @@ import ( ) func (s *NetEdgeTestSuite) TestGetCoreDNSConfig() { - tests := []struct { name string configMap *corev1.ConfigMap diff --git a/pkg/toolsets/netedge/mock_test.go b/pkg/toolsets/netedge/mock_test.go new file mode 100644 index 000000000..f2919b6e1 --- /dev/null +++ b/pkg/toolsets/netedge/mock_test.go @@ -0,0 +1,28 @@ +package netedge + +import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" + "k8s.io/client-go/rest" +) + +// Mock implementations +type mockToolCallRequest struct { + args map[string]interface{} +} + +func (m *mockToolCallRequest) GetArguments() map[string]interface{} { + return m.args +} + +func (m *mockToolCallRequest) GetName() string { + return "mock_tool" +} + +type mockKubernetesClient struct { + api.KubernetesClient + restConfig *rest.Config +} + +func (m *mockKubernetesClient) RESTConfig() *rest.Config { + return m.restConfig +} From 7d691b8a4d9263e7ed2b0a1d24734bb73b3f0efd Mon Sep 17 00:00:00 2001 From: bentito Date: Wed, 18 Feb 2026 15:54:47 -0500 Subject: [PATCH 4/6] Address PR comments: Use DynamicClient and update description --- pkg/toolsets/netedge/mock_test.go | 8 +++++++- pkg/toolsets/netedge/routes.go | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/toolsets/netedge/mock_test.go b/pkg/toolsets/netedge/mock_test.go index f2919b6e1..9d2c8490d 100644 --- a/pkg/toolsets/netedge/mock_test.go +++ b/pkg/toolsets/netedge/mock_test.go @@ -2,6 +2,7 @@ package netedge import ( "github.com/containers/kubernetes-mcp-server/pkg/api" + "k8s.io/client-go/dynamic" "k8s.io/client-go/rest" ) @@ -20,9 +21,14 @@ func (m *mockToolCallRequest) GetName() string { type mockKubernetesClient struct { api.KubernetesClient - restConfig *rest.Config + restConfig *rest.Config + dynamicClient dynamic.Interface } func (m *mockKubernetesClient) RESTConfig() *rest.Config { return m.restConfig } + +func (m *mockKubernetesClient) DynamicClient() dynamic.Interface { + return m.dynamicClient +} diff --git a/pkg/toolsets/netedge/routes.go b/pkg/toolsets/netedge/routes.go index d7e131d62..07b3549eb 100644 --- a/pkg/toolsets/netedge/routes.go +++ b/pkg/toolsets/netedge/routes.go @@ -16,7 +16,7 @@ func initRoutes() []api.ServerTool { { Tool: api.Tool{ Name: "inspect_route", - Description: "Inspect an OpenShift Route to view its configuration, status, and related services.", + Description: "Inspect an OpenShift Route to view its full configuration and status.", InputSchema: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ From 7e14c9c239b31fb1d058afe21293374b1a99f2bd Mon Sep 17 00:00:00 2001 From: bentito Date: Mon, 2 Mar 2026 16:23:43 -0500 Subject: [PATCH 5/6] feat(netedge): highlight key fields and output yaml in inspect_route Signed-off-by: bentito --- pkg/toolsets/netedge/routes.go | 37 ++++++++++++++++++++++++++--- pkg/toolsets/netedge/routes_test.go | 14 +++++++---- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/pkg/toolsets/netedge/routes.go b/pkg/toolsets/netedge/routes.go index 07b3549eb..0a0805af3 100644 --- a/pkg/toolsets/netedge/routes.go +++ b/pkg/toolsets/netedge/routes.go @@ -1,14 +1,15 @@ package netedge import ( - "encoding/json" "fmt" "github.com/containers/kubernetes-mcp-server/pkg/api" "github.com/google/jsonschema-go/jsonschema" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" + "sigs.k8s.io/yaml" ) func initRoutes() []api.ServerTool { @@ -64,9 +65,39 @@ func inspectRoute(params api.ToolHandlerParams) (*api.ToolCallResult, error) { return api.NewToolCallResult("", fmt.Errorf("failed to get route %s/%s: %w", namespace, routeName, err)), nil } - data, err := json.MarshalIndent(route.Object, "", " ") + keyFields := map[string]interface{}{ + "Name": route.GetName(), + "Namespace": route.GetNamespace(), + } + + if host, found, err := unstructured.NestedString(route.Object, "spec", "host"); found && err == nil { + keyFields["Host"] = host + } + + if tls, found, err := unstructured.NestedMap(route.Object, "spec", "tls"); found && err == nil { + keyFields["TLS"] = tls + } + + if to, found, err := unstructured.NestedMap(route.Object, "spec", "to"); found && err == nil { + keyFields["To"] = to + } + + if port, found, err := unstructured.NestedMap(route.Object, "spec", "port"); found && err == nil { + keyFields["Port"] = port + } + + if ingress, found, err := unstructured.NestedSlice(route.Object, "status", "ingress"); found && err == nil { + keyFields["IngressStatus"] = ingress + } + + resultObj := map[string]interface{}{ + "KeyFields": keyFields, + "RawRoute": route.Object, + } + + data, err := yaml.Marshal(resultObj) if err != nil { - return api.NewToolCallResult("", fmt.Errorf("failed to marshal route: %w", err)), nil + return api.NewToolCallResult("", fmt.Errorf("failed to marshal route as yaml: %w", err)), nil } return api.NewToolCallResult(string(data), nil), nil diff --git a/pkg/toolsets/netedge/routes_test.go b/pkg/toolsets/netedge/routes_test.go index 3eeaca1f6..f6b90973d 100644 --- a/pkg/toolsets/netedge/routes_test.go +++ b/pkg/toolsets/netedge/routes_test.go @@ -1,12 +1,11 @@ package netedge import ( - "encoding/json" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/dynamic/fake" clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/yaml" ) func (s *NetEdgeTestSuite) TestInspectRoute() { @@ -39,10 +38,15 @@ func (s *NetEdgeTestSuite) TestInspectRoute() { }, validate: func(result string) { var r map[string]interface{} - err := json.Unmarshal([]byte(result), &r) + err := yaml.Unmarshal([]byte(result), &r) s.Require().NoError(err) - s.Assert().Equal("my-route", r["metadata"].(map[string]interface{})["name"]) - s.Assert().Equal("example.com", r["spec"].(map[string]interface{})["host"]) + rawRoute := r["RawRoute"].(map[string]interface{}) + keyFields := r["KeyFields"].(map[string]interface{}) + s.Assert().Equal("my-route", rawRoute["metadata"].(map[string]interface{})["name"]) + s.Assert().Equal("example.com", rawRoute["spec"].(map[string]interface{})["host"]) + s.Assert().Equal("my-route", keyFields["Name"]) + s.Assert().Equal("default", keyFields["Namespace"]) + s.Assert().Equal("example.com", keyFields["Host"]) }, }, { From b32c4d9f7dc8d0e539e7ae3758d7125fde82ac06 Mon Sep 17 00:00:00 2001 From: bentito Date: Thu, 19 Mar 2026 15:47:31 -0400 Subject: [PATCH 6/6] Remove redundant mock_test.go --- pkg/toolsets/netedge/mock_test.go | 34 ------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 pkg/toolsets/netedge/mock_test.go diff --git a/pkg/toolsets/netedge/mock_test.go b/pkg/toolsets/netedge/mock_test.go deleted file mode 100644 index 9d2c8490d..000000000 --- a/pkg/toolsets/netedge/mock_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package netedge - -import ( - "github.com/containers/kubernetes-mcp-server/pkg/api" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/rest" -) - -// Mock implementations -type mockToolCallRequest struct { - args map[string]interface{} -} - -func (m *mockToolCallRequest) GetArguments() map[string]interface{} { - return m.args -} - -func (m *mockToolCallRequest) GetName() string { - return "mock_tool" -} - -type mockKubernetesClient struct { - api.KubernetesClient - restConfig *rest.Config - dynamicClient dynamic.Interface -} - -func (m *mockKubernetesClient) RESTConfig() *rest.Config { - return m.restConfig -} - -func (m *mockKubernetesClient) DynamicClient() dynamic.Interface { - return m.dynamicClient -}