#
tokens: 46018/50000 12/144 files (page 3/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 of 5. Use http://codebase.md/manusa/kubernetes-mcp-server?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .github
│   ├── dependabot.yml
│   └── workflows
│       ├── build.yaml
│       ├── release-image.yml
│       └── release.yaml
├── .gitignore
├── AGENTS.md
├── build
│   ├── keycloak.mk
│   ├── kind.mk
│   └── tools.mk
├── CLAUDE.md
├── cmd
│   └── kubernetes-mcp-server
│       ├── main_test.go
│       └── main.go
├── dev
│   └── config
│       ├── cert-manager
│       │   └── selfsigned-issuer.yaml
│       ├── ingress
│       │   └── nginx-ingress.yaml
│       ├── keycloak
│       │   ├── client-scopes
│       │   │   ├── groups.json
│       │   │   ├── mcp-openshift.json
│       │   │   └── mcp-server.json
│       │   ├── clients
│       │   │   ├── mcp-client.json
│       │   │   ├── mcp-server-update.json
│       │   │   ├── mcp-server.json
│       │   │   └── openshift.json
│       │   ├── deployment.yaml
│       │   ├── ingress.yaml
│       │   ├── mappers
│       │   │   ├── groups-membership.json
│       │   │   ├── mcp-server-audience.json
│       │   │   ├── openshift-audience.json
│       │   │   └── username.json
│       │   ├── rbac.yaml
│       │   ├── realm
│       │   │   ├── realm-create.json
│       │   │   └── realm-events-config.json
│       │   └── users
│       │       └── mcp.json
│       └── kind
│           └── cluster.yaml
├── Dockerfile
├── docs
│   └── images
│       ├── kubernetes-mcp-server-github-copilot.jpg
│       └── vibe-coding.jpg
├── go.mod
├── go.sum
├── hack
│   └── generate-placeholder-ca.sh
├── internal
│   ├── test
│   │   ├── env.go
│   │   ├── kubernetes.go
│   │   ├── mcp.go
│   │   ├── mock_server.go
│   │   └── test.go
│   └── tools
│       └── update-readme
│           └── main.go
├── LICENSE
├── Makefile
├── npm
│   ├── kubernetes-mcp-server
│   │   ├── bin
│   │   │   └── index.js
│   │   └── package.json
│   ├── kubernetes-mcp-server-darwin-amd64
│   │   └── package.json
│   ├── kubernetes-mcp-server-darwin-arm64
│   │   └── package.json
│   ├── kubernetes-mcp-server-linux-amd64
│   │   └── package.json
│   ├── kubernetes-mcp-server-linux-arm64
│   │   └── package.json
│   ├── kubernetes-mcp-server-windows-amd64
│   │   └── package.json
│   └── kubernetes-mcp-server-windows-arm64
│       └── package.json
├── pkg
│   ├── api
│   │   ├── toolsets_test.go
│   │   └── toolsets.go
│   ├── config
│   │   ├── config_default_overrides.go
│   │   ├── config_default.go
│   │   ├── config_test.go
│   │   ├── config.go
│   │   ├── provider_config_test.go
│   │   └── provider_config.go
│   ├── helm
│   │   └── helm.go
│   ├── http
│   │   ├── authorization_test.go
│   │   ├── authorization.go
│   │   ├── http_test.go
│   │   ├── http.go
│   │   ├── middleware.go
│   │   ├── sts_test.go
│   │   ├── sts.go
│   │   └── wellknown.go
│   ├── kubernetes
│   │   ├── accesscontrol_clientset.go
│   │   ├── accesscontrol_restmapper.go
│   │   ├── accesscontrol.go
│   │   ├── common_test.go
│   │   ├── configuration.go
│   │   ├── events.go
│   │   ├── impersonate_roundtripper.go
│   │   ├── kubernetes_derived_test.go
│   │   ├── kubernetes.go
│   │   ├── manager_test.go
│   │   ├── manager.go
│   │   ├── namespaces.go
│   │   ├── nodes.go
│   │   ├── openshift.go
│   │   ├── pods.go
│   │   ├── provider_kubeconfig_test.go
│   │   ├── provider_kubeconfig.go
│   │   ├── provider_registry_test.go
│   │   ├── provider_registry.go
│   │   ├── provider_single_test.go
│   │   ├── provider_single.go
│   │   ├── provider_test.go
│   │   ├── provider.go
│   │   ├── resources.go
│   │   └── token.go
│   ├── kubernetes-mcp-server
│   │   └── cmd
│   │       ├── root_test.go
│   │       ├── root.go
│   │       └── testdata
│   │           ├── empty-config.toml
│   │           └── valid-config.toml
│   ├── mcp
│   │   ├── common_test.go
│   │   ├── configuration_test.go
│   │   ├── events_test.go
│   │   ├── helm_test.go
│   │   ├── m3labs.go
│   │   ├── mcp_middleware_test.go
│   │   ├── mcp_test.go
│   │   ├── mcp_tools_test.go
│   │   ├── mcp.go
│   │   ├── modules.go
│   │   ├── namespaces_test.go
│   │   ├── nodes_test.go
│   │   ├── pods_exec_test.go
│   │   ├── pods_test.go
│   │   ├── pods_top_test.go
│   │   ├── resources_test.go
│   │   ├── testdata
│   │   │   ├── helm-chart-no-op
│   │   │   │   └── Chart.yaml
│   │   │   ├── helm-chart-secret
│   │   │   │   ├── Chart.yaml
│   │   │   │   └── templates
│   │   │   │       └── secret.yaml
│   │   │   ├── toolsets-config-tools.json
│   │   │   ├── toolsets-core-tools.json
│   │   │   ├── toolsets-full-tools-multicluster-enum.json
│   │   │   ├── toolsets-full-tools-multicluster.json
│   │   │   ├── toolsets-full-tools-openshift.json
│   │   │   ├── toolsets-full-tools.json
│   │   │   └── toolsets-helm-tools.json
│   │   ├── tool_filter_test.go
│   │   ├── tool_filter.go
│   │   ├── tool_mutator_test.go
│   │   ├── tool_mutator.go
│   │   └── toolsets_test.go
│   ├── output
│   │   ├── output_test.go
│   │   └── output.go
│   ├── toolsets
│   │   ├── config
│   │   │   ├── configuration.go
│   │   │   └── toolset.go
│   │   ├── core
│   │   │   ├── events.go
│   │   │   ├── namespaces.go
│   │   │   ├── nodes.go
│   │   │   ├── pods.go
│   │   │   ├── resources.go
│   │   │   └── toolset.go
│   │   ├── helm
│   │   │   ├── helm.go
│   │   │   └── toolset.go
│   │   ├── toolsets_test.go
│   │   └── toolsets.go
│   └── version
│       └── version.go
├── python
│   ├── kubernetes_mcp_server
│   │   ├── __init__.py
│   │   ├── __main__.py
│   │   └── kubernetes_mcp_server.py
│   ├── pyproject.toml
│   └── README.md
├── README.md
└── smithery.yaml
```

# Files

--------------------------------------------------------------------------------
/pkg/mcp/configuration_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/mark3labs/mcp-go/mcp"
  8 | 	"github.com/stretchr/testify/suite"
  9 | 	"k8s.io/client-go/rest"
 10 | 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
 11 | 	v1 "k8s.io/client-go/tools/clientcmd/api/v1"
 12 | 	"sigs.k8s.io/yaml"
 13 | 
 14 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 15 | 	"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
 16 | )
 17 | 
 18 | type ConfigurationSuite struct {
 19 | 	BaseMcpSuite
 20 | }
 21 | 
 22 | func (s *ConfigurationSuite) SetupTest() {
 23 | 	s.BaseMcpSuite.SetupTest()
 24 | 	// Use mock server for predictable kubeconfig content
 25 | 	mockServer := test.NewMockServer()
 26 | 	s.T().Cleanup(mockServer.Close)
 27 | 	kubeconfig := mockServer.Kubeconfig()
 28 | 	for i := 0; i < 10; i++ {
 29 | 		// Add multiple fake contexts to force configuration_contexts_list tool to appear
 30 | 		// and test minification in configuration_view tool
 31 | 		name := fmt.Sprintf("cluster-%d", i)
 32 | 		kubeconfig.Contexts[name] = clientcmdapi.NewContext()
 33 | 		kubeconfig.Clusters[name+"-cluster"] = clientcmdapi.NewCluster()
 34 | 		kubeconfig.AuthInfos[name+"-auth"] = clientcmdapi.NewAuthInfo()
 35 | 		kubeconfig.Contexts[name].Cluster = name + "-cluster"
 36 | 		kubeconfig.Contexts[name].AuthInfo = name + "-auth"
 37 | 	}
 38 | 	s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
 39 | }
 40 | 
 41 | func (s *ConfigurationSuite) TestContextsList() {
 42 | 	s.InitMcpClient()
 43 | 	s.Run("configuration_contexts_list", func() {
 44 | 		toolResult, err := s.CallTool("configuration_contexts_list", map[string]interface{}{})
 45 | 		s.Run("returns contexts", func() {
 46 | 			s.Nilf(err, "call tool failed %v", err)
 47 | 		})
 48 | 		s.Require().NotNil(toolResult, "Expected tool result from call")
 49 | 		s.Lenf(toolResult.Content, 1, "invalid tool result content length %v", len(toolResult.Content))
 50 | 		s.Run("contains context count", func() {
 51 | 			s.Regexpf(`^Available Kubernetes contexts \(11 total`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool count result content %v", toolResult.Content[0].(mcp.TextContent).Text)
 52 | 		})
 53 | 		s.Run("contains default context name", func() {
 54 | 			s.Regexpf(`^Available Kubernetes contexts \(\d+ total, default: fake-context\)`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
 55 | 			s.Regexpf(`(?m)^\*fake-context -> http:\/\/127\.0\.0\.1:\d*$`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
 56 | 		})
 57 | 	})
 58 | }
 59 | 
 60 | func (s *ConfigurationSuite) TestConfigurationView() {
 61 | 	s.InitMcpClient()
 62 | 	s.Run("configuration_view", func() {
 63 | 		toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
 64 | 		s.Run("returns configuration", func() {
 65 | 			s.Nilf(err, "call tool failed %v", err)
 66 | 		})
 67 | 		s.Require().NotNil(toolResult, "Expected tool result from call")
 68 | 		var decoded *v1.Config
 69 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
 70 | 		s.Run("has yaml content", func() {
 71 | 			s.Nilf(err, "invalid tool result content %v", err)
 72 | 		})
 73 | 		s.Run("returns current-context", func() {
 74 | 			s.Equalf("fake-context", decoded.CurrentContext, "fake-context not found: %v", decoded.CurrentContext)
 75 | 		})
 76 | 		s.Run("returns context info", func() {
 77 | 			s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
 78 | 			s.Equalf("fake-context", decoded.Contexts[0].Name, "fake-context not found: %v", decoded.Contexts)
 79 | 			s.Equalf("fake", decoded.Contexts[0].Context.Cluster, "fake-cluster not found: %v", decoded.Contexts)
 80 | 			s.Equalf("fake", decoded.Contexts[0].Context.AuthInfo, "fake-auth not found: %v", decoded.Contexts)
 81 | 		})
 82 | 		s.Run("returns cluster info", func() {
 83 | 			s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
 84 | 			s.Equalf("fake", decoded.Clusters[0].Name, "fake-cluster not found: %v", decoded.Clusters)
 85 | 			s.Regexpf(`^https?://(127\.0\.0\.1|localhost):\d{1,5}$`, decoded.Clusters[0].Cluster.Server, "fake-server not found: %v", decoded.Clusters)
 86 | 		})
 87 | 		s.Run("returns auth info", func() {
 88 | 			s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
 89 | 			s.Equalf("fake", decoded.AuthInfos[0].Name, "fake-auth not found: %v", decoded.AuthInfos)
 90 | 		})
 91 | 	})
 92 | 	s.Run("configuration_view(minified=false)", func() {
 93 | 		toolResult, err := s.CallTool("configuration_view", map[string]interface{}{
 94 | 			"minified": false,
 95 | 		})
 96 | 		s.Run("returns configuration", func() {
 97 | 			s.Nilf(err, "call tool failed %v", err)
 98 | 		})
 99 | 		var decoded *v1.Config
100 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
101 | 		s.Run("has yaml content", func() {
102 | 			s.Nilf(err, "invalid tool result content %v", err)
103 | 		})
104 | 		s.Run("returns additional context info", func() {
105 | 			s.Lenf(decoded.Contexts, 11, "invalid context count, expected 12, got %v", len(decoded.Contexts))
106 | 			s.Equalf("cluster-0", decoded.Contexts[0].Name, "cluster-0 not found: %v", decoded.Contexts)
107 | 			s.Equalf("cluster-0-cluster", decoded.Contexts[0].Context.Cluster, "cluster-0-cluster not found: %v", decoded.Contexts)
108 | 			s.Equalf("cluster-0-auth", decoded.Contexts[0].Context.AuthInfo, "cluster-0-auth not found: %v", decoded.Contexts)
109 | 			s.Equalf("fake", decoded.Contexts[10].Context.Cluster, "fake not found: %v", decoded.Contexts)
110 | 			s.Equalf("fake", decoded.Contexts[10].Context.AuthInfo, "fake not found: %v", decoded.Contexts)
111 | 			s.Equalf("fake-context", decoded.Contexts[10].Name, "fake-context not found: %v", decoded.Contexts)
112 | 		})
113 | 		s.Run("returns cluster info", func() {
114 | 			s.Lenf(decoded.Clusters, 11, "invalid cluster count, expected 2, got %v", len(decoded.Clusters))
115 | 			s.Equalf("cluster-0-cluster", decoded.Clusters[0].Name, "cluster-0-cluster not found: %v", decoded.Clusters)
116 | 			s.Equalf("fake", decoded.Clusters[10].Name, "fake not found: %v", decoded.Clusters)
117 | 		})
118 | 		s.Run("configuration_view with minified=false returns auth info", func() {
119 | 			s.Lenf(decoded.AuthInfos, 11, "invalid auth info count, expected 2, got %v", len(decoded.AuthInfos))
120 | 			s.Equalf("cluster-0-auth", decoded.AuthInfos[0].Name, "cluster-0-auth not found: %v", decoded.AuthInfos)
121 | 			s.Equalf("fake", decoded.AuthInfos[10].Name, "fake not found: %v", decoded.AuthInfos)
122 | 		})
123 | 	})
124 | }
125 | 
126 | func (s *ConfigurationSuite) TestConfigurationViewInCluster() {
127 | 	s.Cfg.KubeConfig = "" // Force in-cluster
128 | 	kubernetes.InClusterConfig = func() (*rest.Config, error) {
129 | 		return &rest.Config{
130 | 			Host:        "https://kubernetes.default.svc",
131 | 			BearerToken: "fake-token",
132 | 		}, nil
133 | 	}
134 | 	s.T().Cleanup(func() { kubernetes.InClusterConfig = rest.InClusterConfig })
135 | 	s.InitMcpClient()
136 | 	s.Run("configuration_view", func() {
137 | 		toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
138 | 		s.Run("returns configuration", func() {
139 | 			s.Nilf(err, "call tool failed %v", err)
140 | 		})
141 | 		s.Require().NotNil(toolResult, "Expected tool result from call")
142 | 		var decoded *v1.Config
143 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
144 | 		s.Run("has yaml content", func() {
145 | 			s.Nilf(err, "invalid tool result content %v", err)
146 | 		})
147 | 		s.Run("returns current-context", func() {
148 | 			s.Equalf("in-cluster", decoded.CurrentContext, "context not found: %v", decoded.CurrentContext)
149 | 		})
150 | 		s.Run("returns context info", func() {
151 | 			s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
152 | 			s.Equalf("in-cluster", decoded.Contexts[0].Name, "context not found: %v", decoded.Contexts)
153 | 			s.Equalf("cluster", decoded.Contexts[0].Context.Cluster, "cluster not found: %v", decoded.Contexts)
154 | 			s.Equalf("user", decoded.Contexts[0].Context.AuthInfo, "user not found: %v", decoded.Contexts)
155 | 		})
156 | 		s.Run("returns cluster info", func() {
157 | 			s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
158 | 			s.Equalf("cluster", decoded.Clusters[0].Name, "cluster not found: %v", decoded.Clusters)
159 | 			s.Equalf("https://kubernetes.default.svc", decoded.Clusters[0].Cluster.Server, "server not found: %v", decoded.Clusters)
160 | 		})
161 | 		s.Run("returns auth info", func() {
162 | 			s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
163 | 			s.Equalf("user", decoded.AuthInfos[0].Name, "user not found: %v", decoded.AuthInfos)
164 | 		})
165 | 	})
166 | }
167 | 
168 | func TestConfiguration(t *testing.T) {
169 | 	suite.Run(t, new(ConfigurationSuite))
170 | }
171 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/pods.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"context"
  6 | 	"errors"
  7 | 	"fmt"
  8 | 
  9 | 	v1 "k8s.io/api/core/v1"
 10 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 11 | 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 12 | 	labelutil "k8s.io/apimachinery/pkg/labels"
 13 | 	"k8s.io/apimachinery/pkg/runtime"
 14 | 	"k8s.io/apimachinery/pkg/runtime/schema"
 15 | 	"k8s.io/apimachinery/pkg/util/intstr"
 16 | 	"k8s.io/apimachinery/pkg/util/rand"
 17 | 	"k8s.io/client-go/tools/remotecommand"
 18 | 	"k8s.io/metrics/pkg/apis/metrics"
 19 | 	metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
 20 | 	"k8s.io/utils/ptr"
 21 | 
 22 | 	"github.com/containers/kubernetes-mcp-server/pkg/version"
 23 | )
 24 | 
 25 | // Default number of lines to retrieve from the end of the logs
 26 | const DefaultTailLines = int64(100)
 27 | 
 28 | type PodsTopOptions struct {
 29 | 	metav1.ListOptions
 30 | 	AllNamespaces bool
 31 | 	Namespace     string
 32 | 	Name          string
 33 | }
 34 | 
 35 | func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
 36 | 	return k.ResourcesList(ctx, &schema.GroupVersionKind{
 37 | 		Group: "", Version: "v1", Kind: "Pod",
 38 | 	}, "", options)
 39 | }
 40 | 
 41 | func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
 42 | 	return k.ResourcesList(ctx, &schema.GroupVersionKind{
 43 | 		Group: "", Version: "v1", Kind: "Pod",
 44 | 	}, namespace, options)
 45 | }
 46 | 
 47 | func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
 48 | 	return k.ResourcesGet(ctx, &schema.GroupVersionKind{
 49 | 		Group: "", Version: "v1", Kind: "Pod",
 50 | 	}, k.NamespaceOrDefault(namespace), name)
 51 | }
 52 | 
 53 | func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
 54 | 	namespace = k.NamespaceOrDefault(namespace)
 55 | 	pod, err := k.ResourcesGet(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
 56 | 	if err != nil {
 57 | 		return "", err
 58 | 	}
 59 | 
 60 | 	isManaged := pod.GetLabels()[AppKubernetesManagedBy] == version.BinaryName
 61 | 	managedLabelSelector := labelutil.Set{
 62 | 		AppKubernetesManagedBy: version.BinaryName,
 63 | 		AppKubernetesName:      pod.GetLabels()[AppKubernetesName],
 64 | 	}.AsSelector()
 65 | 
 66 | 	// Delete managed service
 67 | 	if isManaged {
 68 | 		services, err := k.manager.accessControlClientSet.Services(namespace)
 69 | 		if err != nil {
 70 | 			return "", err
 71 | 		}
 72 | 		if sl, _ := services.List(ctx, metav1.ListOptions{
 73 | 			LabelSelector: managedLabelSelector.String(),
 74 | 		}); sl != nil {
 75 | 			for _, svc := range sl.Items {
 76 | 				_ = services.Delete(ctx, svc.Name, metav1.DeleteOptions{})
 77 | 			}
 78 | 		}
 79 | 	}
 80 | 
 81 | 	// Delete managed Route
 82 | 	if isManaged && k.supportsGroupVersion("route.openshift.io/v1") {
 83 | 		routeResources := k.manager.dynamicClient.
 84 | 			Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
 85 | 			Namespace(namespace)
 86 | 		if rl, _ := routeResources.List(ctx, metav1.ListOptions{
 87 | 			LabelSelector: managedLabelSelector.String(),
 88 | 		}); rl != nil {
 89 | 			for _, route := range rl.Items {
 90 | 				_ = routeResources.Delete(ctx, route.GetName(), metav1.DeleteOptions{})
 91 | 			}
 92 | 		}
 93 | 
 94 | 	}
 95 | 	return "Pod deleted successfully",
 96 | 		k.ResourcesDelete(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
 97 | }
 98 | 
 99 | func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string, previous bool, tail int64) (string, error) {
100 | 	pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
101 | 	if err != nil {
102 | 		return "", err
103 | 	}
104 | 
105 | 	logOptions := &v1.PodLogOptions{
106 | 		Container: container,
107 | 		Previous:  previous,
108 | 	}
109 | 
110 | 	// Only set tailLines if a value is provided (non-zero)
111 | 	if tail > 0 {
112 | 		logOptions.TailLines = &tail
113 | 	} else {
114 | 		// Default to DefaultTailLines lines when not specified
115 | 		logOptions.TailLines = ptr.To(DefaultTailLines)
116 | 	}
117 | 
118 | 	req := pods.GetLogs(name, logOptions)
119 | 	res := req.Do(ctx)
120 | 	if res.Error() != nil {
121 | 		return "", res.Error()
122 | 	}
123 | 	rawData, err := res.Raw()
124 | 	if err != nil {
125 | 		return "", err
126 | 	}
127 | 	return string(rawData), nil
128 | }
129 | 
130 | func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) ([]*unstructured.Unstructured, error) {
131 | 	if name == "" {
132 | 		name = version.BinaryName + "-run-" + rand.String(5)
133 | 	}
134 | 	labels := map[string]string{
135 | 		AppKubernetesName:      name,
136 | 		AppKubernetesComponent: name,
137 | 		AppKubernetesManagedBy: version.BinaryName,
138 | 		AppKubernetesPartOf:    version.BinaryName + "-run-sandbox",
139 | 	}
140 | 	// NewPod
141 | 	var resources []any
142 | 	pod := &v1.Pod{
143 | 		TypeMeta:   metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
144 | 		ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
145 | 		Spec: v1.PodSpec{Containers: []v1.Container{{
146 | 			Name:            name,
147 | 			Image:           image,
148 | 			ImagePullPolicy: v1.PullAlways,
149 | 		}}},
150 | 	}
151 | 	resources = append(resources, pod)
152 | 	if port > 0 {
153 | 		pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
154 | 		resources = append(resources, &v1.Service{
155 | 			TypeMeta:   metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
156 | 			ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
157 | 			Spec: v1.ServiceSpec{
158 | 				Selector: labels,
159 | 				Type:     v1.ServiceTypeClusterIP,
160 | 				Ports:    []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}},
161 | 			},
162 | 		})
163 | 	}
164 | 	if port > 0 && k.supportsGroupVersion("route.openshift.io/v1") {
165 | 		resources = append(resources, &unstructured.Unstructured{
166 | 			Object: map[string]interface{}{
167 | 				"apiVersion": "route.openshift.io/v1",
168 | 				"kind":       "Route",
169 | 				"metadata": map[string]interface{}{
170 | 					"name":      name,
171 | 					"namespace": k.NamespaceOrDefault(namespace),
172 | 					"labels":    labels,
173 | 				},
174 | 				"spec": map[string]interface{}{
175 | 					"to": map[string]interface{}{
176 | 						"kind":   "Service",
177 | 						"name":   name,
178 | 						"weight": 100,
179 | 					},
180 | 					"port": map[string]interface{}{
181 | 						"targetPort": intstr.FromInt32(port),
182 | 					},
183 | 					"tls": map[string]interface{}{
184 | 						"termination":                   "edge",
185 | 						"insecureEdgeTerminationPolicy": "Redirect",
186 | 					},
187 | 				},
188 | 			},
189 | 		})
190 | 
191 | 	}
192 | 
193 | 	// Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality
194 | 	converter := runtime.DefaultUnstructuredConverter
195 | 	var toCreate []*unstructured.Unstructured
196 | 	for _, obj := range resources {
197 | 		m, err := converter.ToUnstructured(obj)
198 | 		if err != nil {
199 | 			return nil, err
200 | 		}
201 | 		u := &unstructured.Unstructured{}
202 | 		if err = converter.FromUnstructured(m, u); err != nil {
203 | 			return nil, err
204 | 		}
205 | 		toCreate = append(toCreate, u)
206 | 	}
207 | 	return k.resourcesCreateOrUpdate(ctx, toCreate)
208 | }
209 | 
210 | func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metrics.PodMetricsList, error) {
211 | 	// TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster
212 | 	if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) {
213 | 		return nil, errors.New("metrics API is not available")
214 | 	}
215 | 	namespace := options.Namespace
216 | 	if options.AllNamespaces && namespace == "" {
217 | 		namespace = ""
218 | 	} else {
219 | 		namespace = k.NamespaceOrDefault(namespace)
220 | 	}
221 | 	return k.manager.accessControlClientSet.PodsMetricses(ctx, namespace, options.Name, options.ListOptions)
222 | }
223 | 
224 | func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
225 | 	namespace = k.NamespaceOrDefault(namespace)
226 | 	pods, err := k.manager.accessControlClientSet.Pods(namespace)
227 | 	if err != nil {
228 | 		return "", err
229 | 	}
230 | 	pod, err := pods.Get(ctx, name, metav1.GetOptions{})
231 | 	if err != nil {
232 | 		return "", err
233 | 	}
234 | 	// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L350-L352
235 | 	if pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed {
236 | 		return "", fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase)
237 | 	}
238 | 	if container == "" {
239 | 		container = pod.Spec.Containers[0].Name
240 | 	}
241 | 	podExecOptions := &v1.PodExecOptions{
242 | 		Container: container,
243 | 		Command:   command,
244 | 		Stdout:    true,
245 | 		Stderr:    true,
246 | 	}
247 | 	executor, err := k.manager.accessControlClientSet.PodsExec(namespace, name, podExecOptions)
248 | 	if err != nil {
249 | 		return "", err
250 | 	}
251 | 	stdout := bytes.NewBuffer(make([]byte, 0))
252 | 	stderr := bytes.NewBuffer(make([]byte, 0))
253 | 	if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
254 | 		Stdout: stdout, Stderr: stderr, Tty: false,
255 | 	}); err != nil {
256 | 		return "", err
257 | 	}
258 | 	if stdout.Len() > 0 {
259 | 		return stdout.String(), nil
260 | 	}
261 | 	if stderr.Len() > 0 {
262 | 		return stderr.String(), nil
263 | 	}
264 | 	return "", nil
265 | }
266 | 
```

--------------------------------------------------------------------------------
/dev/config/ingress/nginx-ingress.yaml:
--------------------------------------------------------------------------------

```yaml
  1 | ---
  2 | apiVersion: v1
  3 | kind: Namespace
  4 | metadata:
  5 |   name: ingress-nginx
  6 |   labels:
  7 |     app.kubernetes.io/name: ingress-nginx
  8 |     app.kubernetes.io/instance: ingress-nginx
  9 | ---
 10 | apiVersion: v1
 11 | kind: ServiceAccount
 12 | metadata:
 13 |   labels:
 14 |     app.kubernetes.io/name: ingress-nginx
 15 |     app.kubernetes.io/instance: ingress-nginx
 16 |     app.kubernetes.io/component: controller
 17 |   name: ingress-nginx
 18 |   namespace: ingress-nginx
 19 | ---
 20 | apiVersion: v1
 21 | kind: ConfigMap
 22 | metadata:
 23 |   labels:
 24 |     app.kubernetes.io/name: ingress-nginx
 25 |     app.kubernetes.io/instance: ingress-nginx
 26 |     app.kubernetes.io/component: controller
 27 |   name: ingress-nginx-controller
 28 |   namespace: ingress-nginx
 29 | data:
 30 |   allow-snippet-annotations: "true"
 31 | ---
 32 | apiVersion: rbac.authorization.k8s.io/v1
 33 | kind: ClusterRole
 34 | metadata:
 35 |   labels:
 36 |     app.kubernetes.io/name: ingress-nginx
 37 |     app.kubernetes.io/instance: ingress-nginx
 38 |   name: ingress-nginx
 39 | rules:
 40 |   - apiGroups:
 41 |       - ""
 42 |     resources:
 43 |       - configmaps
 44 |       - endpoints
 45 |       - nodes
 46 |       - pods
 47 |       - secrets
 48 |       - namespaces
 49 |     verbs:
 50 |       - list
 51 |       - watch
 52 |   - apiGroups:
 53 |       - coordination.k8s.io
 54 |     resources:
 55 |       - leases
 56 |     verbs:
 57 |       - list
 58 |       - watch
 59 |   - apiGroups:
 60 |       - ""
 61 |     resources:
 62 |       - nodes
 63 |     verbs:
 64 |       - get
 65 |   - apiGroups:
 66 |       - ""
 67 |     resources:
 68 |       - services
 69 |     verbs:
 70 |       - get
 71 |       - list
 72 |       - watch
 73 |   - apiGroups:
 74 |       - networking.k8s.io
 75 |     resources:
 76 |       - ingresses
 77 |     verbs:
 78 |       - get
 79 |       - list
 80 |       - watch
 81 |   - apiGroups:
 82 |       - ""
 83 |     resources:
 84 |       - events
 85 |     verbs:
 86 |       - create
 87 |       - patch
 88 |   - apiGroups:
 89 |       - networking.k8s.io
 90 |     resources:
 91 |       - ingresses/status
 92 |     verbs:
 93 |       - update
 94 |   - apiGroups:
 95 |       - networking.k8s.io
 96 |     resources:
 97 |       - ingressclasses
 98 |     verbs:
 99 |       - get
100 |       - list
101 |       - watch
102 |   - apiGroups:
103 |       - discovery.k8s.io
104 |     resources:
105 |       - endpointslices
106 |     verbs:
107 |       - list
108 |       - watch
109 |       - get
110 | ---
111 | apiVersion: rbac.authorization.k8s.io/v1
112 | kind: ClusterRoleBinding
113 | metadata:
114 |   labels:
115 |     app.kubernetes.io/name: ingress-nginx
116 |     app.kubernetes.io/instance: ingress-nginx
117 |   name: ingress-nginx
118 | roleRef:
119 |   apiGroup: rbac.authorization.k8s.io
120 |   kind: ClusterRole
121 |   name: ingress-nginx
122 | subjects:
123 |   - kind: ServiceAccount
124 |     name: ingress-nginx
125 |     namespace: ingress-nginx
126 | ---
127 | apiVersion: rbac.authorization.k8s.io/v1
128 | kind: Role
129 | metadata:
130 |   labels:
131 |     app.kubernetes.io/name: ingress-nginx
132 |     app.kubernetes.io/instance: ingress-nginx
133 |     app.kubernetes.io/component: controller
134 |   name: ingress-nginx
135 |   namespace: ingress-nginx
136 | rules:
137 |   - apiGroups:
138 |       - ""
139 |     resources:
140 |       - namespaces
141 |     verbs:
142 |       - get
143 |   - apiGroups:
144 |       - ""
145 |     resources:
146 |       - configmaps
147 |       - pods
148 |       - secrets
149 |       - endpoints
150 |     verbs:
151 |       - get
152 |       - list
153 |       - watch
154 |   - apiGroups:
155 |       - ""
156 |     resources:
157 |       - services
158 |     verbs:
159 |       - get
160 |       - list
161 |       - watch
162 |   - apiGroups:
163 |       - networking.k8s.io
164 |     resources:
165 |       - ingresses
166 |     verbs:
167 |       - get
168 |       - list
169 |       - watch
170 |   - apiGroups:
171 |       - networking.k8s.io
172 |     resources:
173 |       - ingresses/status
174 |     verbs:
175 |       - update
176 |   - apiGroups:
177 |       - networking.k8s.io
178 |     resources:
179 |       - ingressclasses
180 |     verbs:
181 |       - get
182 |       - list
183 |       - watch
184 |   - apiGroups:
185 |       - coordination.k8s.io
186 |     resources:
187 |       - leases
188 |     resourceNames:
189 |       - ingress-nginx-leader
190 |     verbs:
191 |       - get
192 |       - update
193 |   - apiGroups:
194 |       - coordination.k8s.io
195 |     resources:
196 |       - leases
197 |     verbs:
198 |       - create
199 |   - apiGroups:
200 |       - ""
201 |     resources:
202 |       - events
203 |     verbs:
204 |       - create
205 |       - patch
206 |   - apiGroups:
207 |       - discovery.k8s.io
208 |     resources:
209 |       - endpointslices
210 |     verbs:
211 |       - list
212 |       - watch
213 |       - get
214 | ---
215 | apiVersion: rbac.authorization.k8s.io/v1
216 | kind: RoleBinding
217 | metadata:
218 |   labels:
219 |     app.kubernetes.io/name: ingress-nginx
220 |     app.kubernetes.io/instance: ingress-nginx
221 |     app.kubernetes.io/component: controller
222 |   name: ingress-nginx
223 |   namespace: ingress-nginx
224 | roleRef:
225 |   apiGroup: rbac.authorization.k8s.io
226 |   kind: Role
227 |   name: ingress-nginx
228 | subjects:
229 |   - kind: ServiceAccount
230 |     name: ingress-nginx
231 |     namespace: ingress-nginx
232 | ---
233 | apiVersion: v1
234 | kind: Service
235 | metadata:
236 |   labels:
237 |     app.kubernetes.io/name: ingress-nginx
238 |     app.kubernetes.io/instance: ingress-nginx
239 |     app.kubernetes.io/component: controller
240 |   name: ingress-nginx-controller
241 |   namespace: ingress-nginx
242 | spec:
243 |   type: NodePort
244 |   ports:
245 |     - name: http
246 |       port: 80
247 |       protocol: TCP
248 |       targetPort: http
249 |       appProtocol: http
250 |     - name: https
251 |       port: 443
252 |       protocol: TCP
253 |       targetPort: https
254 |       appProtocol: https
255 |   selector:
256 |     app.kubernetes.io/name: ingress-nginx
257 |     app.kubernetes.io/instance: ingress-nginx
258 |     app.kubernetes.io/component: controller
259 | ---
260 | apiVersion: apps/v1
261 | kind: Deployment
262 | metadata:
263 |   labels:
264 |     app.kubernetes.io/name: ingress-nginx
265 |     app.kubernetes.io/instance: ingress-nginx
266 |     app.kubernetes.io/component: controller
267 |   name: ingress-nginx-controller
268 |   namespace: ingress-nginx
269 | spec:
270 |   selector:
271 |     matchLabels:
272 |       app.kubernetes.io/name: ingress-nginx
273 |       app.kubernetes.io/instance: ingress-nginx
274 |       app.kubernetes.io/component: controller
275 |   replicas: 1
276 |   revisionHistoryLimit: 10
277 |   minReadySeconds: 0
278 |   template:
279 |     metadata:
280 |       labels:
281 |         app.kubernetes.io/name: ingress-nginx
282 |         app.kubernetes.io/instance: ingress-nginx
283 |         app.kubernetes.io/component: controller
284 |     spec:
285 |       dnsPolicy: ClusterFirst
286 |       containers:
287 |         - name: controller
288 |           image: registry.k8s.io/ingress-nginx/controller:v1.11.1
289 |           imagePullPolicy: IfNotPresent
290 |           lifecycle:
291 |             preStop:
292 |               exec:
293 |                 command:
294 |                   - /wait-shutdown
295 |           args:
296 |             - /nginx-ingress-controller
297 |             - --election-id=ingress-nginx-leader
298 |             - --controller-class=k8s.io/ingress-nginx
299 |             - --ingress-class=nginx
300 |             - --configmap=$(POD_NAMESPACE)/ingress-nginx-controller
301 |             - --watch-ingress-without-class=true
302 |           securityContext:
303 |             runAsNonRoot: true
304 |             runAsUser: 101
305 |             allowPrivilegeEscalation: false
306 |             seccompProfile:
307 |               type: RuntimeDefault
308 |             capabilities:
309 |               drop:
310 |                 - ALL
311 |               add:
312 |                 - NET_BIND_SERVICE
313 |           env:
314 |             - name: POD_NAME
315 |               valueFrom:
316 |                 fieldRef:
317 |                   fieldPath: metadata.name
318 |             - name: POD_NAMESPACE
319 |               valueFrom:
320 |                 fieldRef:
321 |                   fieldPath: metadata.namespace
322 |             - name: LD_PRELOAD
323 |               value: /usr/local/lib/libmimalloc.so
324 |           livenessProbe:
325 |             failureThreshold: 5
326 |             httpGet:
327 |               path: /healthz
328 |               port: 10254
329 |               scheme: HTTP
330 |             initialDelaySeconds: 10
331 |             periodSeconds: 10
332 |             successThreshold: 1
333 |             timeoutSeconds: 1
334 |           readinessProbe:
335 |             failureThreshold: 3
336 |             httpGet:
337 |               path: /healthz
338 |               port: 10254
339 |               scheme: HTTP
340 |             initialDelaySeconds: 10
341 |             periodSeconds: 10
342 |             successThreshold: 1
343 |             timeoutSeconds: 1
344 |           ports:
345 |             - name: http
346 |               containerPort: 80
347 |               protocol: TCP
348 |               hostPort: 80
349 |             - name: https
350 |               containerPort: 443
351 |               protocol: TCP
352 |               hostPort: 443
353 |             - name: https-alt
354 |               containerPort: 443
355 |               protocol: TCP
356 |               hostPort: 8443
357 |             - name: webhook
358 |               containerPort: 8443
359 |               protocol: TCP
360 |           resources:
361 |             requests:
362 |               cpu: 100m
363 |               memory: 90Mi
364 |       nodeSelector:
365 |         ingress-ready: "true"
366 |         kubernetes.io/os: linux
367 |       serviceAccountName: ingress-nginx
368 |       terminationGracePeriodSeconds: 0
369 |       tolerations:
370 |         - effect: NoSchedule
371 |           key: node-role.kubernetes.io/master
372 |           operator: Equal
373 |         - effect: NoSchedule
374 |           key: node-role.kubernetes.io/control-plane
375 |           operator: Equal
376 | ---
377 | apiVersion: networking.k8s.io/v1
378 | kind: IngressClass
379 | metadata:
380 |   labels:
381 |     app.kubernetes.io/name: ingress-nginx
382 |     app.kubernetes.io/instance: ingress-nginx
383 |     app.kubernetes.io/component: controller
384 |   name: nginx
385 | spec:
386 |   controller: k8s.io/ingress-nginx
387 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/kubernetes_derived_test.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"os"
  6 | 	"path/filepath"
  7 | 	"strings"
  8 | 	"testing"
  9 | 
 10 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 11 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 12 | 	"github.com/stretchr/testify/suite"
 13 | )
 14 | 
 15 | type DerivedTestSuite struct {
 16 | 	suite.Suite
 17 | }
 18 | 
 19 | func (s *DerivedTestSuite) TestKubeConfig() {
 20 | 	// Create a temporary kubeconfig file for testing
 21 | 	tempDir := s.T().TempDir()
 22 | 	kubeconfigPath := filepath.Join(tempDir, "config")
 23 | 	kubeconfigContent := `
 24 | apiVersion: v1
 25 | kind: Config
 26 | clusters:
 27 | - cluster:
 28 |     server: https://test-cluster.example.com
 29 |   name: test-cluster
 30 | contexts:
 31 | - context:
 32 |     cluster: test-cluster
 33 |     user: test-user
 34 |   name: test-context
 35 | current-context: test-context
 36 | users:
 37 | - name: test-user
 38 |   user:
 39 |     username: test-username
 40 |     password: test-password
 41 | `
 42 | 	err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644)
 43 | 	s.Require().NoError(err, "failed to create kubeconfig file")
 44 | 
 45 | 	s.Run("with no RequireOAuth (default) config", func() {
 46 | 		testStaticConfig := test.Must(config.ReadToml([]byte(`
 47 | 			kubeconfig = "` + strings.ReplaceAll(kubeconfigPath, `\`, `\\`) + `"
 48 | 		`)))
 49 | 		s.Run("without authorization header returns original manager", func() {
 50 | 			testManager, err := NewKubeconfigManager(testStaticConfig, "")
 51 | 			s.Require().NoErrorf(err, "failed to create test manager: %v", err)
 52 | 			s.T().Cleanup(testManager.Close)
 53 | 
 54 | 			derived, err := testManager.Derived(s.T().Context())
 55 | 			s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
 56 | 
 57 | 			s.Equal(derived.manager, testManager, "expected original manager, got different manager")
 58 | 		})
 59 | 
 60 | 		s.Run("with invalid authorization header returns original manager", func() {
 61 | 			testManager, err := NewKubeconfigManager(testStaticConfig, "")
 62 | 			s.Require().NoErrorf(err, "failed to create test manager: %v", err)
 63 | 			s.T().Cleanup(testManager.Close)
 64 | 
 65 | 			ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "invalid-token")
 66 | 			derived, err := testManager.Derived(ctx)
 67 | 			s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
 68 | 
 69 | 			s.Equal(derived.manager, testManager, "expected original manager, got different manager")
 70 | 		})
 71 | 
 72 | 		s.Run("with valid bearer token creates derived manager with correct configuration", func() {
 73 | 			testManager, err := NewKubeconfigManager(testStaticConfig, "")
 74 | 			s.Require().NoErrorf(err, "failed to create test manager: %v", err)
 75 | 			s.T().Cleanup(testManager.Close)
 76 | 
 77 | 			ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "Bearer aiTana-julIA")
 78 | 			derived, err := testManager.Derived(ctx)
 79 | 			s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
 80 | 
 81 | 			s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager")
 82 | 			s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager")
 83 | 
 84 | 			s.Run("RestConfig is correctly copied and sensitive fields are omitted", func() {
 85 | 				derivedCfg := derived.manager.cfg
 86 | 				s.Require().NotNil(derivedCfg, "derived config is nil")
 87 | 
 88 | 				originalCfg := testManager.cfg
 89 | 				s.Equalf(originalCfg.Host, derivedCfg.Host, "expected Host %s, got %s", originalCfg.Host, derivedCfg.Host)
 90 | 				s.Equalf(originalCfg.APIPath, derivedCfg.APIPath, "expected APIPath %s, got %s", originalCfg.APIPath, derivedCfg.APIPath)
 91 | 				s.Equalf(originalCfg.QPS, derivedCfg.QPS, "expected QPS %f, got %f", originalCfg.QPS, derivedCfg.QPS)
 92 | 				s.Equalf(originalCfg.Burst, derivedCfg.Burst, "expected Burst %d, got %d", originalCfg.Burst, derivedCfg.Burst)
 93 | 				s.Equalf(originalCfg.Timeout, derivedCfg.Timeout, "expected Timeout %v, got %v", originalCfg.Timeout, derivedCfg.Timeout)
 94 | 
 95 | 				s.Equalf(originalCfg.Insecure, derivedCfg.Insecure, "expected TLS Insecure %v, got %v", originalCfg.Insecure, derivedCfg.Insecure)
 96 | 				s.Equalf(originalCfg.ServerName, derivedCfg.ServerName, "expected TLS ServerName %s, got %s", originalCfg.ServerName, derivedCfg.ServerName)
 97 | 				s.Equalf(originalCfg.CAFile, derivedCfg.CAFile, "expected TLS CAFile %s, got %s", originalCfg.CAFile, derivedCfg.CAFile)
 98 | 				s.Equalf(string(originalCfg.CAData), string(derivedCfg.CAData), "expected TLS CAData %s, got %s", string(originalCfg.CAData), string(derivedCfg.CAData))
 99 | 
100 | 				s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken)
101 | 				s.Equalf("kubernetes-mcp-server/bearer-token-auth", derivedCfg.UserAgent, "expected UserAgent \"kubernetes-mcp-server/bearer-token-auth\", got %s", derivedCfg.UserAgent)
102 | 
103 | 				// Verify that sensitive fields are NOT copied to prevent credential leakage
104 | 				// The derived config should only use the bearer token from the Authorization header
105 | 				// and not inherit any authentication credentials from the original kubeconfig
106 | 				s.Emptyf(derivedCfg.CertFile, "expected TLS CertFile to be empty, got %s", derivedCfg.CertFile)
107 | 				s.Emptyf(derivedCfg.KeyFile, "expected TLS KeyFile to be empty, got %s", derivedCfg.KeyFile)
108 | 				s.Emptyf(len(derivedCfg.CertData), "expected TLS CertData to be empty, got %v", derivedCfg.CertData)
109 | 				s.Emptyf(len(derivedCfg.KeyData), "expected TLS KeyData to be empty, got %v", derivedCfg.KeyData)
110 | 
111 | 				s.Emptyf(derivedCfg.Username, "expected Username to be empty, got %s", derivedCfg.Username)
112 | 				s.Emptyf(derivedCfg.Password, "expected Password to be empty, got %s", derivedCfg.Password)
113 | 				s.Nilf(derivedCfg.AuthProvider, "expected AuthProvider to be nil, got %v", derivedCfg.AuthProvider)
114 | 				s.Nilf(derivedCfg.ExecProvider, "expected ExecProvider to be nil, got %v", derivedCfg.ExecProvider)
115 | 				s.Emptyf(derivedCfg.BearerTokenFile, "expected BearerTokenFile to be empty, got %s", derivedCfg.BearerTokenFile)
116 | 				s.Emptyf(derivedCfg.Impersonate.UserName, "expected Impersonate.UserName to be empty, got %s", derivedCfg.Impersonate.UserName)
117 | 
118 | 				// Verify that the original manager still has the sensitive data
119 | 				s.Falsef(originalCfg.Username == "" && originalCfg.Password == "", "original kubeconfig shouldn't be modified")
120 | 
121 | 			})
122 | 			s.Run("derived manager has initialized clients", func() {
123 | 				// Verify that the derived manager has proper clients initialized
124 | 				s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized")
125 | 				s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager")
126 | 				s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized")
127 | 				s.NotNilf(derived.manager.accessControlRESTMapper, "expected accessControlRESTMapper to be initialized")
128 | 				s.Equalf(testStaticConfig, derived.manager.accessControlRESTMapper.staticConfig, "staticConfig not properly wired to derived manager")
129 | 				s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized")
130 | 			})
131 | 		})
132 | 	})
133 | 
134 | 	s.Run("with RequireOAuth=true", func() {
135 | 		testStaticConfig := test.Must(config.ReadToml([]byte(`
136 | 			kubeconfig = "` + strings.ReplaceAll(kubeconfigPath, `\`, `\\`) + `"
137 | 			require_oauth = true
138 | 		`)))
139 | 
140 | 		s.Run("with no authorization header returns oauth token required error", func() {
141 | 			testManager, err := NewKubeconfigManager(testStaticConfig, "")
142 | 			s.Require().NoErrorf(err, "failed to create test manager: %v", err)
143 | 			s.T().Cleanup(testManager.Close)
144 | 
145 | 			derived, err := testManager.Derived(s.T().Context())
146 | 			s.Require().Error(err, "expected error for missing oauth token, got nil")
147 | 			s.EqualError(err, "oauth token required", "expected error 'oauth token required', got %s", err.Error())
148 | 			s.Nil(derived, "expected nil derived manager when oauth token required")
149 | 		})
150 | 
151 | 		s.Run("with invalid authorization header returns oauth token required error", func() {
152 | 			testManager, err := NewKubeconfigManager(testStaticConfig, "")
153 | 			s.Require().NoErrorf(err, "failed to create test manager: %v", err)
154 | 			s.T().Cleanup(testManager.Close)
155 | 
156 | 			ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "invalid-token")
157 | 			derived, err := testManager.Derived(ctx)
158 | 			s.Require().Error(err, "expected error for invalid oauth token, got nil")
159 | 			s.EqualError(err, "oauth token required", "expected error 'oauth token required', got %s", err.Error())
160 | 			s.Nil(derived, "expected nil derived manager when oauth token required")
161 | 		})
162 | 
163 | 		s.Run("with valid bearer token creates derived manager", func() {
164 | 			testManager, err := NewKubeconfigManager(testStaticConfig, "")
165 | 			s.Require().NoErrorf(err, "failed to create test manager: %v", err)
166 | 			s.T().Cleanup(testManager.Close)
167 | 
168 | 			ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "Bearer aiTana-julIA")
169 | 			derived, err := testManager.Derived(ctx)
170 | 			s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
171 | 
172 | 			s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager")
173 | 			s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager")
174 | 
175 | 			derivedCfg := derived.manager.cfg
176 | 			s.Require().NotNil(derivedCfg, "derived config is nil")
177 | 
178 | 			s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken)
179 | 		})
180 | 	})
181 | }
182 | 
183 | func TestDerived(t *testing.T) {
184 | 	suite.Run(t, new(DerivedTestSuite))
185 | }
186 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/manager.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 	"strings"
  8 | 
  9 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 10 | 	"github.com/containers/kubernetes-mcp-server/pkg/helm"
 11 | 	"github.com/fsnotify/fsnotify"
 12 | 	authenticationv1api "k8s.io/api/authentication/v1"
 13 | 	"k8s.io/apimachinery/pkg/api/meta"
 14 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 15 | 	"k8s.io/client-go/discovery"
 16 | 	"k8s.io/client-go/discovery/cached/memory"
 17 | 	"k8s.io/client-go/dynamic"
 18 | 	"k8s.io/client-go/rest"
 19 | 	"k8s.io/client-go/restmapper"
 20 | 	"k8s.io/client-go/tools/clientcmd"
 21 | 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
 22 | 	"k8s.io/klog/v2"
 23 | )
 24 | 
 25 | type Manager struct {
 26 | 	cfg                     *rest.Config
 27 | 	clientCmdConfig         clientcmd.ClientConfig
 28 | 	discoveryClient         discovery.CachedDiscoveryInterface
 29 | 	accessControlClientSet  *AccessControlClientset
 30 | 	accessControlRESTMapper *AccessControlRESTMapper
 31 | 	dynamicClient           *dynamic.DynamicClient
 32 | 
 33 | 	staticConfig         *config.StaticConfig
 34 | 	CloseWatchKubeConfig CloseWatchKubeConfig
 35 | }
 36 | 
 37 | var _ helm.Kubernetes = (*Manager)(nil)
 38 | var _ Openshift = (*Manager)(nil)
 39 | 
 40 | var (
 41 | 	ErrorKubeconfigInClusterNotAllowed = errors.New("kubeconfig manager cannot be used in in-cluster deployments")
 42 | 	ErrorInClusterNotInCluster         = errors.New("in-cluster manager cannot be used outside of a cluster")
 43 | )
 44 | 
 45 | func NewKubeconfigManager(config *config.StaticConfig, kubeconfigContext string) (*Manager, error) {
 46 | 	if IsInCluster(config) {
 47 | 		return nil, ErrorKubeconfigInClusterNotAllowed
 48 | 	}
 49 | 
 50 | 	pathOptions := clientcmd.NewDefaultPathOptions()
 51 | 	if config.KubeConfig != "" {
 52 | 		pathOptions.LoadingRules.ExplicitPath = config.KubeConfig
 53 | 	}
 54 | 	clientCmdConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
 55 | 		pathOptions.LoadingRules,
 56 | 		&clientcmd.ConfigOverrides{
 57 | 			ClusterInfo:    clientcmdapi.Cluster{Server: ""},
 58 | 			CurrentContext: kubeconfigContext,
 59 | 		})
 60 | 
 61 | 	restConfig, err := clientCmdConfig.ClientConfig()
 62 | 	if err != nil {
 63 | 		return nil, fmt.Errorf("failed to create kubernetes rest config from kubeconfig: %v", err)
 64 | 	}
 65 | 
 66 | 	return newManager(config, restConfig, clientCmdConfig)
 67 | }
 68 | 
 69 | func NewInClusterManager(config *config.StaticConfig) (*Manager, error) {
 70 | 	if config.KubeConfig != "" {
 71 | 		return nil, fmt.Errorf("kubeconfig file %s cannot be used with the in-cluster deployments: %v", config.KubeConfig, ErrorKubeconfigInClusterNotAllowed)
 72 | 	}
 73 | 
 74 | 	if !IsInCluster(config) {
 75 | 		return nil, ErrorInClusterNotInCluster
 76 | 	}
 77 | 
 78 | 	restConfig, err := InClusterConfig()
 79 | 	if err != nil {
 80 | 		return nil, fmt.Errorf("failed to create in-cluster kubernetes rest config: %v", err)
 81 | 	}
 82 | 
 83 | 	// Create a dummy kubeconfig clientcmdapi.Config for in-cluster config to be used in places where clientcmd.ClientConfig is required
 84 | 	clientCmdConfig := clientcmdapi.NewConfig()
 85 | 	clientCmdConfig.Clusters["cluster"] = &clientcmdapi.Cluster{
 86 | 		Server:                restConfig.Host,
 87 | 		InsecureSkipTLSVerify: restConfig.Insecure,
 88 | 	}
 89 | 	clientCmdConfig.AuthInfos["user"] = &clientcmdapi.AuthInfo{
 90 | 		Token: restConfig.BearerToken,
 91 | 	}
 92 | 	clientCmdConfig.Contexts[inClusterKubeConfigDefaultContext] = &clientcmdapi.Context{
 93 | 		Cluster:  "cluster",
 94 | 		AuthInfo: "user",
 95 | 	}
 96 | 	clientCmdConfig.CurrentContext = inClusterKubeConfigDefaultContext
 97 | 
 98 | 	return newManager(config, restConfig, clientcmd.NewDefaultClientConfig(*clientCmdConfig, nil))
 99 | }
100 | 
101 | func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdConfig clientcmd.ClientConfig) (*Manager, error) {
102 | 	k8s := &Manager{
103 | 		staticConfig:    config,
104 | 		cfg:             restConfig,
105 | 		clientCmdConfig: clientCmdConfig,
106 | 	}
107 | 	if k8s.cfg.UserAgent == "" {
108 | 		k8s.cfg.UserAgent = rest.DefaultKubernetesUserAgent()
109 | 	}
110 | 	var err error
111 | 	// TODO: Won't work because not all client-go clients use the shared context (e.g. discovery client uses context.TODO())
112 | 	//k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper {
113 | 	//	return &impersonateRoundTripper{original}
114 | 	//})
115 | 	k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig)
116 | 	if err != nil {
117 | 		return nil, err
118 | 	}
119 | 	k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient())
120 | 	k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
121 | 		restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
122 | 		k8s.staticConfig,
123 | 	)
124 | 	k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
125 | 	if err != nil {
126 | 		return nil, err
127 | 	}
128 | 	return k8s, nil
129 | }
130 | 
131 | func (m *Manager) WatchKubeConfig(onKubeConfigChange func() error) {
132 | 	if m.clientCmdConfig == nil {
133 | 		return
134 | 	}
135 | 	kubeConfigFiles := m.clientCmdConfig.ConfigAccess().GetLoadingPrecedence()
136 | 	if len(kubeConfigFiles) == 0 {
137 | 		return
138 | 	}
139 | 	watcher, err := fsnotify.NewWatcher()
140 | 	if err != nil {
141 | 		return
142 | 	}
143 | 	for _, file := range kubeConfigFiles {
144 | 		_ = watcher.Add(file)
145 | 	}
146 | 	go func() {
147 | 		for {
148 | 			select {
149 | 			case _, ok := <-watcher.Events:
150 | 				if !ok {
151 | 					return
152 | 				}
153 | 				_ = onKubeConfigChange()
154 | 			case _, ok := <-watcher.Errors:
155 | 				if !ok {
156 | 					return
157 | 				}
158 | 			}
159 | 		}
160 | 	}()
161 | 	if m.CloseWatchKubeConfig != nil {
162 | 		_ = m.CloseWatchKubeConfig()
163 | 	}
164 | 	m.CloseWatchKubeConfig = watcher.Close
165 | }
166 | 
167 | func (m *Manager) Close() {
168 | 	if m.CloseWatchKubeConfig != nil {
169 | 		_ = m.CloseWatchKubeConfig()
170 | 	}
171 | }
172 | 
173 | func (m *Manager) configuredNamespace() string {
174 | 	if ns, _, nsErr := m.clientCmdConfig.Namespace(); nsErr == nil {
175 | 		return ns
176 | 	}
177 | 	return ""
178 | }
179 | 
180 | func (m *Manager) NamespaceOrDefault(namespace string) string {
181 | 	if namespace == "" {
182 | 		return m.configuredNamespace()
183 | 	}
184 | 	return namespace
185 | }
186 | 
187 | func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
188 | 	return m.discoveryClient, nil
189 | }
190 | 
191 | func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
192 | 	return m.accessControlRESTMapper, nil
193 | }
194 | 
195 | // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter)
196 | func (m *Manager) ToRESTConfig() (*rest.Config, error) {
197 | 	return m.cfg, nil
198 | }
199 | 
200 | // ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter)
201 | func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
202 | 	return m.clientCmdConfig
203 | }
204 | 
205 | func (m *Manager) VerifyToken(ctx context.Context, token, audience string) (*authenticationv1api.UserInfo, []string, error) {
206 | 	tokenReviewClient, err := m.accessControlClientSet.TokenReview()
207 | 	if err != nil {
208 | 		return nil, nil, err
209 | 	}
210 | 	tokenReview := &authenticationv1api.TokenReview{
211 | 		TypeMeta: metav1.TypeMeta{
212 | 			APIVersion: "authentication.k8s.io/v1",
213 | 			Kind:       "TokenReview",
214 | 		},
215 | 		Spec: authenticationv1api.TokenReviewSpec{
216 | 			Token:     token,
217 | 			Audiences: []string{audience},
218 | 		},
219 | 	}
220 | 
221 | 	result, err := tokenReviewClient.Create(ctx, tokenReview, metav1.CreateOptions{})
222 | 	if err != nil {
223 | 		return nil, nil, fmt.Errorf("failed to create token review: %v", err)
224 | 	}
225 | 
226 | 	if !result.Status.Authenticated {
227 | 		if result.Status.Error != "" {
228 | 			return nil, nil, fmt.Errorf("token authentication failed: %s", result.Status.Error)
229 | 		}
230 | 		return nil, nil, fmt.Errorf("token authentication failed")
231 | 	}
232 | 
233 | 	return &result.Status.User, result.Status.Audiences, nil
234 | }
235 | 
236 | func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
237 | 	authorization, ok := ctx.Value(OAuthAuthorizationHeader).(string)
238 | 	if !ok || !strings.HasPrefix(authorization, "Bearer ") {
239 | 		if m.staticConfig.RequireOAuth {
240 | 			return nil, errors.New("oauth token required")
241 | 		}
242 | 		return &Kubernetes{manager: m}, nil
243 | 	}
244 | 	klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader)
245 | 	derivedCfg := &rest.Config{
246 | 		Host:    m.cfg.Host,
247 | 		APIPath: m.cfg.APIPath,
248 | 		// Copy only server verification TLS settings (CA bundle and server name)
249 | 		TLSClientConfig: rest.TLSClientConfig{
250 | 			Insecure:   m.cfg.Insecure,
251 | 			ServerName: m.cfg.ServerName,
252 | 			CAFile:     m.cfg.CAFile,
253 | 			CAData:     m.cfg.CAData,
254 | 		},
255 | 		BearerToken: strings.TrimPrefix(authorization, "Bearer "),
256 | 		// pass custom UserAgent to identify the client
257 | 		UserAgent:   CustomUserAgent,
258 | 		QPS:         m.cfg.QPS,
259 | 		Burst:       m.cfg.Burst,
260 | 		Timeout:     m.cfg.Timeout,
261 | 		Impersonate: rest.ImpersonationConfig{},
262 | 	}
263 | 	clientCmdApiConfig, err := m.clientCmdConfig.RawConfig()
264 | 	if err != nil {
265 | 		if m.staticConfig.RequireOAuth {
266 | 			klog.Errorf("failed to get kubeconfig: %v", err)
267 | 			return nil, errors.New("failed to get kubeconfig")
268 | 		}
269 | 		return &Kubernetes{manager: m}, nil
270 | 	}
271 | 	clientCmdApiConfig.AuthInfos = make(map[string]*clientcmdapi.AuthInfo)
272 | 	derived := &Kubernetes{
273 | 		manager: &Manager{
274 | 			clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil),
275 | 			cfg:             derivedCfg,
276 | 			staticConfig:    m.staticConfig,
277 | 		},
278 | 	}
279 | 	derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
280 | 	if err != nil {
281 | 		if m.staticConfig.RequireOAuth {
282 | 			klog.Errorf("failed to get kubeconfig: %v", err)
283 | 			return nil, errors.New("failed to get kubeconfig")
284 | 		}
285 | 		return &Kubernetes{manager: m}, nil
286 | 	}
287 | 	derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
288 | 	derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
289 | 		restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
290 | 		derived.manager.staticConfig,
291 | 	)
292 | 	derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
293 | 	if err != nil {
294 | 		if m.staticConfig.RequireOAuth {
295 | 			klog.Errorf("failed to initialize dynamic client: %v", err)
296 | 			return nil, errors.New("failed to initialize dynamic client")
297 | 		}
298 | 		return &Kubernetes{manager: m}, nil
299 | 	}
300 | 	return derived, nil
301 | }
302 | 
```

--------------------------------------------------------------------------------
/pkg/http/authorization.go:
--------------------------------------------------------------------------------

```go
  1 | package http
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"context"
  6 | 	"encoding/json"
  7 | 	"fmt"
  8 | 	"io"
  9 | 	"net/http"
 10 | 	"strings"
 11 | 
 12 | 	"github.com/coreos/go-oidc/v3/oidc"
 13 | 	"github.com/go-jose/go-jose/v4"
 14 | 	"github.com/go-jose/go-jose/v4/jwt"
 15 | 	"golang.org/x/oauth2"
 16 | 	authenticationapiv1 "k8s.io/api/authentication/v1"
 17 | 	"k8s.io/klog/v2"
 18 | 	"k8s.io/utils/strings/slices"
 19 | 
 20 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 21 | 	"github.com/containers/kubernetes-mcp-server/pkg/mcp"
 22 | )
 23 | 
 24 | type KubernetesApiTokenVerifier interface {
 25 | 	// KubernetesApiVerifyToken TODO: clarify proper implementation
 26 | 	KubernetesApiVerifyToken(ctx context.Context, cluster, token, audience string) (*authenticationapiv1.UserInfo, []string, error)
 27 | 	// GetTargetParameterName returns the parameter name used for target identification in MCP requests
 28 | 	GetTargetParameterName() string
 29 | }
 30 | 
 31 | // extractTargetFromRequest extracts cluster parameter from MCP request body
 32 | func extractTargetFromRequest(r *http.Request, targetName string) (string, error) {
 33 | 	if r.Body == nil {
 34 | 		return "", nil
 35 | 	}
 36 | 
 37 | 	// Read the body
 38 | 	body, err := io.ReadAll(r.Body)
 39 | 	if err != nil {
 40 | 		return "", err
 41 | 	}
 42 | 
 43 | 	// Restore the body for downstream handlers
 44 | 	r.Body = io.NopCloser(bytes.NewBuffer(body))
 45 | 
 46 | 	// Parse the MCP request
 47 | 	var mcpRequest struct {
 48 | 		Params struct {
 49 | 			Arguments map[string]interface{} `json:"arguments"`
 50 | 		} `json:"params"`
 51 | 	}
 52 | 
 53 | 	if err := json.Unmarshal(body, &mcpRequest); err != nil {
 54 | 		// If we can't parse the request, just return empty cluster (will use default)
 55 | 		return "", nil
 56 | 	}
 57 | 
 58 | 	// Extract target parameter
 59 | 	if cluster, ok := mcpRequest.Params.Arguments[targetName].(string); ok {
 60 | 		return cluster, nil
 61 | 	}
 62 | 
 63 | 	return "", nil
 64 | }
 65 | 
 66 | // write401 sends a 401/Unauthorized response with WWW-Authenticate header.
 67 | func write401(w http.ResponseWriter, wwwAuthenticateHeader, errorType, message string) {
 68 | 	w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+fmt.Sprintf(`, error="%s"`, errorType))
 69 | 	http.Error(w, message, http.StatusUnauthorized)
 70 | }
 71 | 
 72 | // AuthorizationMiddleware validates the OAuth flow for protected resources.
 73 | //
 74 | // The flow is skipped for unprotected resources, such as health checks and well-known endpoints.
 75 | //
 76 | //	There are several auth scenarios supported by this middleware:
 77 | //
 78 | //	 1. requireOAuth is false:
 79 | //
 80 | //	    - The OAuth flow is skipped, and the server is effectively unprotected.
 81 | //	    - The request is passed to the next handler without any validation.
 82 | //
 83 | //	    see TestAuthorizationRequireOAuthFalse
 84 | //
 85 | //	 2. requireOAuth is set to true, server is protected:
 86 | //
 87 | //	    2.1. Raw Token Validation (oidcProvider is nil):
 88 | //	         - The token is validated offline for basic sanity checks (expiration).
 89 | //	         - If OAuthAudience is set, the token is validated against the audience.
 90 | //	         - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview.
 91 | //
 92 | //	         see TestAuthorizationRawToken
 93 | //
 94 | //	    2.2. OIDC Provider Validation (oidcProvider is not nil):
 95 | //	         - The token is validated offline for basic sanity checks (audience and expiration).
 96 | //	         - If OAuthAudience is set, the token is validated against the audience.
 97 | //	         - The token is then validated against the OIDC Provider.
 98 | //	         - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview.
 99 | //
100 | //	         see TestAuthorizationOidcToken
101 | //
102 | //	    2.3. OIDC Token Exchange (oidcProvider is not nil, StsClientId and StsAudience are set):
103 | //	         - The token is validated offline for basic sanity checks (audience and expiration).
104 | //	         - If OAuthAudience is set, the token is validated against the audience.
105 | //	         - The token is then validated against the OIDC Provider.
106 | //	         - If the token is valid, an external account token exchange is performed using
107 | //	           the OIDC Provider to obtain a new token with the specified audience and scopes.
108 | //	         - If ValidateToken is set, the exchanged token is then used against the Kubernetes API Server for TokenReview.
109 | //
110 | //	         see TestAuthorizationOidcTokenExchange
111 | func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oidc.Provider, verifier KubernetesApiTokenVerifier, httpClient *http.Client) func(http.Handler) http.Handler {
112 | 	return func(next http.Handler) http.Handler {
113 | 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114 | 			if r.URL.Path == healthEndpoint || slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) {
115 | 				next.ServeHTTP(w, r)
116 | 				return
117 | 			}
118 | 			if !staticConfig.RequireOAuth {
119 | 				next.ServeHTTP(w, r)
120 | 				return
121 | 			}
122 | 
123 | 			wwwAuthenticateHeader := "Bearer realm=\"Kubernetes MCP Server\""
124 | 			if staticConfig.OAuthAudience != "" {
125 | 				wwwAuthenticateHeader += fmt.Sprintf(`, audience="%s"`, staticConfig.OAuthAudience)
126 | 			}
127 | 
128 | 			authHeader := r.Header.Get("Authorization")
129 | 			if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
130 | 				klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
131 | 				write401(w, wwwAuthenticateHeader, "missing_token", "Unauthorized: Bearer token required")
132 | 				return
133 | 			}
134 | 
135 | 			token := strings.TrimPrefix(authHeader, "Bearer ")
136 | 
137 | 			claims, err := ParseJWTClaims(token)
138 | 			if err == nil && claims == nil {
139 | 				// Impossible case, but just in case
140 | 				err = fmt.Errorf("failed to parse JWT claims from token")
141 | 			}
142 | 			// Offline validation
143 | 			if err == nil {
144 | 				err = claims.ValidateOffline(staticConfig.OAuthAudience)
145 | 			}
146 | 			// Online OIDC provider validation
147 | 			if err == nil {
148 | 				err = claims.ValidateWithProvider(r.Context(), staticConfig.OAuthAudience, oidcProvider)
149 | 			}
150 | 			// Scopes propagation, they are likely to be used for authorization.
151 | 			if err == nil {
152 | 				scopes := claims.GetScopes()
153 | 				klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
154 | 				r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes))
155 | 			}
156 | 			// Token exchange with OIDC provider
157 | 			sts := NewFromConfig(staticConfig, oidcProvider)
158 | 			// TODO: Maybe the token had already been exchanged, if it has the right audience and scopes, we can skip this step.
159 | 			if err == nil && sts.IsEnabled() {
160 | 				var exchangedToken *oauth2.Token
161 | 				// If the token is valid, we can exchange it for a new token with the specified audience and scopes.
162 | 				ctx := r.Context()
163 | 				if httpClient != nil {
164 | 					ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
165 | 				}
166 | 				exchangedToken, err = sts.ExternalAccountTokenExchange(ctx, &oauth2.Token{
167 | 					AccessToken: claims.Token,
168 | 					TokenType:   "Bearer",
169 | 				})
170 | 				if err == nil {
171 | 					// Replace the original token with the exchanged token
172 | 					token = exchangedToken.AccessToken
173 | 					claims, err = ParseJWTClaims(token)
174 | 					r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // TODO: Implement test to verify, THIS IS A CRITICAL PART
175 | 				}
176 | 			}
177 | 			// Kubernetes API Server TokenReview validation
178 | 			if err == nil && staticConfig.ValidateToken {
179 | 				targetParameterName := verifier.GetTargetParameterName()
180 | 				cluster, clusterErr := extractTargetFromRequest(r, targetParameterName)
181 | 				if clusterErr != nil {
182 | 					klog.V(2).Infof("Failed to extract cluster from request, using default: %v", clusterErr)
183 | 				}
184 | 				err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, cluster, verifier)
185 | 			}
186 | 			if err != nil {
187 | 				klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
188 | 				write401(w, wwwAuthenticateHeader, "invalid_token", "Unauthorized: Invalid token")
189 | 				return
190 | 			}
191 | 
192 | 			next.ServeHTTP(w, r)
193 | 		})
194 | 	}
195 | }
196 | 
197 | var allSignatureAlgorithms = []jose.SignatureAlgorithm{
198 | 	jose.EdDSA,
199 | 	jose.HS256,
200 | 	jose.HS384,
201 | 	jose.HS512,
202 | 	jose.RS256,
203 | 	jose.RS384,
204 | 	jose.RS512,
205 | 	jose.ES256,
206 | 	jose.ES384,
207 | 	jose.ES512,
208 | 	jose.PS256,
209 | 	jose.PS384,
210 | 	jose.PS512,
211 | }
212 | 
213 | type JWTClaims struct {
214 | 	jwt.Claims
215 | 	Token string `json:"-"`
216 | 	Scope string `json:"scope,omitempty"`
217 | }
218 | 
219 | func (c *JWTClaims) GetScopes() []string {
220 | 	if c.Scope == "" {
221 | 		return nil
222 | 	}
223 | 	return strings.Fields(c.Scope)
224 | }
225 | 
226 | // ValidateOffline Checks if the JWT claims are valid and if the audience matches the expected one.
227 | func (c *JWTClaims) ValidateOffline(audience string) error {
228 | 	expected := jwt.Expected{}
229 | 	if audience != "" {
230 | 		expected.AnyAudience = jwt.Audience{audience}
231 | 	}
232 | 	if err := c.Validate(expected); err != nil {
233 | 		return fmt.Errorf("JWT token validation error: %v", err)
234 | 	}
235 | 	return nil
236 | }
237 | 
238 | // ValidateWithProvider validates the JWT claims against the OIDC provider.
239 | func (c *JWTClaims) ValidateWithProvider(ctx context.Context, audience string, provider *oidc.Provider) error {
240 | 	if provider != nil {
241 | 		verifier := provider.Verifier(&oidc.Config{
242 | 			ClientID: audience,
243 | 		})
244 | 		_, err := verifier.Verify(ctx, c.Token)
245 | 		if err != nil {
246 | 			return fmt.Errorf("OIDC token validation error: %v", err)
247 | 		}
248 | 	}
249 | 	return nil
250 | }
251 | 
252 | func (c *JWTClaims) ValidateWithKubernetesApi(ctx context.Context, audience, cluster string, verifier KubernetesApiTokenVerifier) error {
253 | 	if verifier != nil {
254 | 		_, _, err := verifier.KubernetesApiVerifyToken(ctx, cluster, c.Token, audience)
255 | 		if err != nil {
256 | 			return fmt.Errorf("kubernetes API token validation error: %v", err)
257 | 		}
258 | 	}
259 | 	return nil
260 | }
261 | 
262 | func ParseJWTClaims(token string) (*JWTClaims, error) {
263 | 	tkn, err := jwt.ParseSigned(token, allSignatureAlgorithms)
264 | 	if err != nil {
265 | 		return nil, fmt.Errorf("failed to parse JWT token: %w", err)
266 | 	}
267 | 	claims := &JWTClaims{}
268 | 	err = tkn.UnsafeClaimsWithoutVerification(claims)
269 | 	claims.Token = token
270 | 	return claims, err
271 | }
272 | 
```

--------------------------------------------------------------------------------
/pkg/toolsets/core/resources.go:
--------------------------------------------------------------------------------

```go
  1 | package core
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/google/jsonschema-go/jsonschema"
  9 | 	"k8s.io/apimachinery/pkg/runtime/schema"
 10 | 	"k8s.io/utils/ptr"
 11 | 
 12 | 	"github.com/containers/kubernetes-mcp-server/pkg/api"
 13 | 	internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
 14 | 	"github.com/containers/kubernetes-mcp-server/pkg/output"
 15 | )
 16 | 
 17 | func initResources(o internalk8s.Openshift) []api.ServerTool {
 18 | 	commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
 19 | 	if o.IsOpenShift(context.Background()) {
 20 | 		commonApiVersion += ", route.openshift.io/v1 Route"
 21 | 	}
 22 | 	commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
 23 | 	return []api.ServerTool{
 24 | 		{Tool: api.Tool{
 25 | 			Name:        "resources_list",
 26 | 			Description: "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n" + commonApiVersion,
 27 | 			InputSchema: &jsonschema.Schema{
 28 | 				Type: "object",
 29 | 				Properties: map[string]*jsonschema.Schema{
 30 | 					"apiVersion": {
 31 | 						Type:        "string",
 32 | 						Description: "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
 33 | 					},
 34 | 					"kind": {
 35 | 						Type:        "string",
 36 | 						Description: "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)",
 37 | 					},
 38 | 					"namespace": {
 39 | 						Type:        "string",
 40 | 						Description: "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces",
 41 | 					},
 42 | 					"labelSelector": {
 43 | 						Type:        "string",
 44 | 						Description: "Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label",
 45 | 						Pattern:     "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
 46 | 					},
 47 | 				},
 48 | 				Required: []string{"apiVersion", "kind"},
 49 | 			},
 50 | 			Annotations: api.ToolAnnotations{
 51 | 				Title:           "Resources: List",
 52 | 				ReadOnlyHint:    ptr.To(true),
 53 | 				DestructiveHint: ptr.To(false),
 54 | 				IdempotentHint:  ptr.To(false),
 55 | 				OpenWorldHint:   ptr.To(true),
 56 | 			},
 57 | 		}, Handler: resourcesList},
 58 | 		{Tool: api.Tool{
 59 | 			Name:        "resources_get",
 60 | 			Description: "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
 61 | 			InputSchema: &jsonschema.Schema{
 62 | 				Type: "object",
 63 | 				Properties: map[string]*jsonschema.Schema{
 64 | 					"apiVersion": {
 65 | 						Type:        "string",
 66 | 						Description: "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
 67 | 					},
 68 | 					"kind": {
 69 | 						Type:        "string",
 70 | 						Description: "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
 71 | 					},
 72 | 					"namespace": {
 73 | 						Type:        "string",
 74 | 						Description: "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace",
 75 | 					},
 76 | 					"name": {
 77 | 						Type:        "string",
 78 | 						Description: "Name of the resource",
 79 | 					},
 80 | 				},
 81 | 				Required: []string{"apiVersion", "kind", "name"},
 82 | 			},
 83 | 			Annotations: api.ToolAnnotations{
 84 | 				Title:           "Resources: Get",
 85 | 				ReadOnlyHint:    ptr.To(true),
 86 | 				DestructiveHint: ptr.To(false),
 87 | 				IdempotentHint:  ptr.To(false),
 88 | 				OpenWorldHint:   ptr.To(true),
 89 | 			},
 90 | 		}, Handler: resourcesGet},
 91 | 		{Tool: api.Tool{
 92 | 			Name:        "resources_create_or_update",
 93 | 			Description: "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n" + commonApiVersion,
 94 | 			InputSchema: &jsonschema.Schema{
 95 | 				Type: "object",
 96 | 				Properties: map[string]*jsonschema.Schema{
 97 | 					"resource": {
 98 | 						Type:        "string",
 99 | 						Description: "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec",
100 | 					},
101 | 				},
102 | 				Required: []string{"resource"},
103 | 			},
104 | 			Annotations: api.ToolAnnotations{
105 | 				Title:           "Resources: Create or Update",
106 | 				ReadOnlyHint:    ptr.To(false),
107 | 				DestructiveHint: ptr.To(true),
108 | 				IdempotentHint:  ptr.To(true),
109 | 				OpenWorldHint:   ptr.To(true),
110 | 			},
111 | 		}, Handler: resourcesCreateOrUpdate},
112 | 		{Tool: api.Tool{
113 | 			Name:        "resources_delete",
114 | 			Description: "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
115 | 			InputSchema: &jsonschema.Schema{
116 | 				Type: "object",
117 | 				Properties: map[string]*jsonschema.Schema{
118 | 					"apiVersion": {
119 | 						Type:        "string",
120 | 						Description: "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
121 | 					},
122 | 					"kind": {
123 | 						Type:        "string",
124 | 						Description: "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
125 | 					},
126 | 					"namespace": {
127 | 						Type:        "string",
128 | 						Description: "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace",
129 | 					},
130 | 					"name": {
131 | 						Type:        "string",
132 | 						Description: "Name of the resource",
133 | 					},
134 | 				},
135 | 				Required: []string{"apiVersion", "kind", "name"},
136 | 			},
137 | 			Annotations: api.ToolAnnotations{
138 | 				Title:           "Resources: Delete",
139 | 				ReadOnlyHint:    ptr.To(false),
140 | 				DestructiveHint: ptr.To(true),
141 | 				IdempotentHint:  ptr.To(true),
142 | 				OpenWorldHint:   ptr.To(true),
143 | 			},
144 | 		}, Handler: resourcesDelete},
145 | 	}
146 | }
147 | 
148 | func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
149 | 	namespace := params.GetArguments()["namespace"]
150 | 	if namespace == nil {
151 | 		namespace = ""
152 | 	}
153 | 	labelSelector := params.GetArguments()["labelSelector"]
154 | 	resourceListOptions := internalk8s.ResourceListOptions{
155 | 		AsTable: params.ListOutput.AsTable(),
156 | 	}
157 | 
158 | 	if labelSelector != nil {
159 | 		l, ok := labelSelector.(string)
160 | 		if !ok {
161 | 			return api.NewToolCallResult("", fmt.Errorf("labelSelector is not a string")), nil
162 | 		}
163 | 		resourceListOptions.LabelSelector = l
164 | 	}
165 | 	gvk, err := parseGroupVersionKind(params.GetArguments())
166 | 	if err != nil {
167 | 		return api.NewToolCallResult("", fmt.Errorf("failed to list resources, %s", err)), nil
168 | 	}
169 | 
170 | 	ns, ok := namespace.(string)
171 | 	if !ok {
172 | 		return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
173 | 	}
174 | 
175 | 	ret, err := params.ResourcesList(params, gvk, ns, resourceListOptions)
176 | 	if err != nil {
177 | 		return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil
178 | 	}
179 | 	return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
180 | }
181 | 
182 | func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
183 | 	namespace := params.GetArguments()["namespace"]
184 | 	if namespace == nil {
185 | 		namespace = ""
186 | 	}
187 | 	gvk, err := parseGroupVersionKind(params.GetArguments())
188 | 	if err != nil {
189 | 		return api.NewToolCallResult("", fmt.Errorf("failed to get resource, %s", err)), nil
190 | 	}
191 | 	name := params.GetArguments()["name"]
192 | 	if name == nil {
193 | 		return api.NewToolCallResult("", errors.New("failed to get resource, missing argument name")), nil
194 | 	}
195 | 
196 | 	ns, ok := namespace.(string)
197 | 	if !ok {
198 | 		return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
199 | 	}
200 | 
201 | 	n, ok := name.(string)
202 | 	if !ok {
203 | 		return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
204 | 	}
205 | 
206 | 	ret, err := params.ResourcesGet(params, gvk, ns, n)
207 | 	if err != nil {
208 | 		return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil
209 | 	}
210 | 	return api.NewToolCallResult(output.MarshalYaml(ret)), nil
211 | }
212 | 
213 | func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
214 | 	resource := params.GetArguments()["resource"]
215 | 	if resource == nil || resource == "" {
216 | 		return api.NewToolCallResult("", errors.New("failed to create or update resources, missing argument resource")), nil
217 | 	}
218 | 
219 | 	r, ok := resource.(string)
220 | 	if !ok {
221 | 		return api.NewToolCallResult("", fmt.Errorf("resource is not a string")), nil
222 | 	}
223 | 
224 | 	resources, err := params.ResourcesCreateOrUpdate(params, r)
225 | 	if err != nil {
226 | 		return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
227 | 	}
228 | 	marshalledYaml, err := output.MarshalYaml(resources)
229 | 	if err != nil {
230 | 		err = fmt.Errorf("failed to create or update resources:: %v", err)
231 | 	}
232 | 	return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
233 | }
234 | 
235 | func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
236 | 	namespace := params.GetArguments()["namespace"]
237 | 	if namespace == nil {
238 | 		namespace = ""
239 | 	}
240 | 	gvk, err := parseGroupVersionKind(params.GetArguments())
241 | 	if err != nil {
242 | 		return api.NewToolCallResult("", fmt.Errorf("failed to delete resource, %s", err)), nil
243 | 	}
244 | 	name := params.GetArguments()["name"]
245 | 	if name == nil {
246 | 		return api.NewToolCallResult("", errors.New("failed to delete resource, missing argument name")), nil
247 | 	}
248 | 
249 | 	ns, ok := namespace.(string)
250 | 	if !ok {
251 | 		return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
252 | 	}
253 | 
254 | 	n, ok := name.(string)
255 | 	if !ok {
256 | 		return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
257 | 	}
258 | 
259 | 	err = params.ResourcesDelete(params, gvk, ns, n)
260 | 	if err != nil {
261 | 		return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
262 | 	}
263 | 	return api.NewToolCallResult("Resource deleted successfully", err), nil
264 | }
265 | 
266 | func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {
267 | 	apiVersion := arguments["apiVersion"]
268 | 	if apiVersion == nil {
269 | 		return nil, errors.New("missing argument apiVersion")
270 | 	}
271 | 	kind := arguments["kind"]
272 | 	if kind == nil {
273 | 		return nil, errors.New("missing argument kind")
274 | 	}
275 | 
276 | 	a, ok := apiVersion.(string)
277 | 	if !ok {
278 | 		return nil, fmt.Errorf("name is not a string")
279 | 	}
280 | 
281 | 	gv, err := schema.ParseGroupVersion(a)
282 | 	if err != nil {
283 | 		return nil, errors.New("invalid argument apiVersion")
284 | 	}
285 | 	return &schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: kind.(string)}, nil
286 | }
287 | 
```

--------------------------------------------------------------------------------
/pkg/http/authorization_test.go:
--------------------------------------------------------------------------------

```go
  1 | package http
  2 | 
  3 | import (
  4 | 	"strings"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/go-jose/go-jose/v4/jwt"
  8 | )
  9 | 
 10 | const (
 11 | 	// https://jwt.io/#token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q // notsecret
 12 | 	tokenBasicNotExpired = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q" // notsecret
 13 | 	// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ // notsecret
 14 | 	tokenBasicExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ" // notsecret
 15 | 	// https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg // notsecret
 16 | 	tokenMultipleAudienceNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg" // notsecret
 17 | )
 18 | 
 19 | func TestParseJWTClaimsPayloadValid(t *testing.T) {
 20 | 	basicClaims, err := ParseJWTClaims(tokenBasicNotExpired)
 21 | 	t.Run("Is parseable", func(t *testing.T) {
 22 | 		if err != nil {
 23 | 			t.Fatalf("expected no error, got %v", err)
 24 | 		}
 25 | 		if basicClaims == nil {
 26 | 			t.Fatal("expected claims, got nil")
 27 | 		}
 28 | 	})
 29 | 	t.Run("Parses issuer", func(t *testing.T) {
 30 | 		if basicClaims.Issuer != "https://kubernetes.default.svc.cluster.local" {
 31 | 			t.Errorf("expected issuer 'https://kubernetes.default.svc.cluster.local', got %s", basicClaims.Issuer)
 32 | 		}
 33 | 	})
 34 | 	t.Run("Parses audience", func(t *testing.T) {
 35 | 		expectedAudiences := []string{"https://kubernetes.default.svc.cluster.local", "mcp-server"}
 36 | 		for _, expected := range expectedAudiences {
 37 | 			if !basicClaims.Audience.Contains(expected) {
 38 | 				t.Errorf("expected audience to contain %s", expected)
 39 | 			}
 40 | 		}
 41 | 	})
 42 | 	t.Run("Parses expiration", func(t *testing.T) {
 43 | 		if *basicClaims.Expiry != jwt.NumericDate(253402297199) {
 44 | 			t.Errorf("expected expiration 253402297199, got %d", basicClaims.Expiry)
 45 | 		}
 46 | 	})
 47 | 	t.Run("Parses scope", func(t *testing.T) {
 48 | 		scopeClaims, err := ParseJWTClaims(tokenMultipleAudienceNotExpired)
 49 | 		if err != nil {
 50 | 			t.Fatalf("expected no error, got %v", err)
 51 | 		}
 52 | 		if scopeClaims == nil {
 53 | 			t.Fatal("expected claims, got nil")
 54 | 		}
 55 | 
 56 | 		scopes := scopeClaims.GetScopes()
 57 | 
 58 | 		expectedScopes := []string{"read", "write"}
 59 | 		if len(scopes) != len(expectedScopes) {
 60 | 			t.Errorf("expected %d scopes, got %d", len(expectedScopes), len(scopes))
 61 | 		}
 62 | 		for i, expectedScope := range expectedScopes {
 63 | 			if scopes[i] != expectedScope {
 64 | 				t.Errorf("expected scope[%d] to be '%s', got '%s'", i, expectedScope, scopes[i])
 65 | 			}
 66 | 		}
 67 | 	})
 68 | 	t.Run("Parses expired token", func(t *testing.T) {
 69 | 		expiredClaims, err := ParseJWTClaims(tokenBasicExpired)
 70 | 		if err != nil {
 71 | 			t.Fatalf("expected no error, got %v", err)
 72 | 		}
 73 | 
 74 | 		if *expiredClaims.Expiry != jwt.NumericDate(1) {
 75 | 			t.Errorf("expected expiration 1, got %d", basicClaims.Expiry)
 76 | 		}
 77 | 	})
 78 | }
 79 | 
 80 | func TestParseJWTClaimsPayloadInvalid(t *testing.T) {
 81 | 	t.Run("invalid token segments", func(t *testing.T) {
 82 | 		invalidToken := "header.payload.signature.extra"
 83 | 
 84 | 		_, err := ParseJWTClaims(invalidToken)
 85 | 		if err == nil {
 86 | 			t.Fatal("expected error for invalid token segments, got nil")
 87 | 		}
 88 | 
 89 | 		if !strings.Contains(err.Error(), "compact JWS format must have three parts") {
 90 | 			t.Errorf("expected invalid token segments error message, got %v", err)
 91 | 		}
 92 | 	})
 93 | 	t.Run("invalid base64 payload", func(t *testing.T) {
 94 | 		invalidPayload := strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid")
 95 | 
 96 | 		_, err := ParseJWTClaims(invalidPayload)
 97 | 		if err == nil {
 98 | 			t.Fatal("expected error for invalid base64, got nil")
 99 | 		}
100 | 
101 | 		if !strings.Contains(err.Error(), "illegal base64 data") {
102 | 			t.Errorf("expected decode error message, got %v", err)
103 | 		}
104 | 	})
105 | }
106 | 
107 | func TestJWTTokenValidateOffline(t *testing.T) {
108 | 	t.Run("expired token returns error", func(t *testing.T) {
109 | 		claims, err := ParseJWTClaims(tokenBasicExpired)
110 | 		if err != nil {
111 | 			t.Fatalf("expected no error for expired token parsing, got %v", err)
112 | 		}
113 | 
114 | 		err = claims.ValidateOffline("mcp-server")
115 | 		if err == nil {
116 | 			t.Fatalf("expected error for expired token, got nil")
117 | 		}
118 | 
119 | 		if !strings.Contains(err.Error(), "token is expired (exp)") {
120 | 			t.Errorf("expected expiration error message, got %v", err)
121 | 		}
122 | 	})
123 | 
124 | 	t.Run("multiple audiences with correct one", func(t *testing.T) {
125 | 		claims, err := ParseJWTClaims(tokenMultipleAudienceNotExpired)
126 | 		if err != nil {
127 | 			t.Fatalf("expected no error for multiple audience token parsing, got %v", err)
128 | 		}
129 | 		if claims == nil {
130 | 			t.Fatalf("expected claims to be returned, got nil")
131 | 		}
132 | 
133 | 		err = claims.ValidateOffline("mcp-server")
134 | 		if err != nil {
135 | 			t.Fatalf("expected no error for valid audience, got %v", err)
136 | 		}
137 | 	})
138 | 
139 | 	t.Run("multiple audiences with mismatch returns error", func(t *testing.T) {
140 | 		claims, err := ParseJWTClaims(tokenMultipleAudienceNotExpired)
141 | 		if err != nil {
142 | 			t.Fatalf("expected no error for multiple audience token parsing, got %v", err)
143 | 		}
144 | 		if claims == nil {
145 | 			t.Fatalf("expected claims to be returned, got nil")
146 | 		}
147 | 
148 | 		err = claims.ValidateOffline("missing-audience")
149 | 		if err == nil {
150 | 			t.Fatalf("expected error for token with wrong audience, got nil")
151 | 		}
152 | 
153 | 		if !strings.Contains(err.Error(), "invalid audience claim (aud)") {
154 | 			t.Errorf("expected audience mismatch error, got %v", err)
155 | 		}
156 | 	})
157 | }
158 | 
159 | func TestJWTClaimsGetScopes(t *testing.T) {
160 | 	t.Run("no scopes", func(t *testing.T) {
161 | 		claims, err := ParseJWTClaims(tokenBasicExpired)
162 | 		if err != nil {
163 | 			t.Fatalf("expected no error for parsing token, got %v", err)
164 | 		}
165 | 
166 | 		if scopes := claims.GetScopes(); len(scopes) != 0 {
167 | 			t.Errorf("expected no scopes, got %d", len(scopes))
168 | 		}
169 | 	})
170 | 	t.Run("single scope", func(t *testing.T) {
171 | 		claims := &JWTClaims{
172 | 			Scope: "read",
173 | 		}
174 | 		scopes := claims.GetScopes()
175 | 		expected := []string{"read"}
176 | 
177 | 		if len(scopes) != 1 {
178 | 			t.Errorf("expected 1 scope, got %d", len(scopes))
179 | 		}
180 | 		if scopes[0] != expected[0] {
181 | 			t.Errorf("expected scope 'read', got '%s'", scopes[0])
182 | 		}
183 | 	})
184 | 
185 | 	t.Run("multiple scopes", func(t *testing.T) {
186 | 		claims := &JWTClaims{
187 | 			Scope: "read write admin",
188 | 		}
189 | 		scopes := claims.GetScopes()
190 | 		expected := []string{"read", "write", "admin"}
191 | 
192 | 		if len(scopes) != 3 {
193 | 			t.Errorf("expected 3 scopes, got %d", len(scopes))
194 | 		}
195 | 
196 | 		for i, expectedScope := range expected {
197 | 			if i >= len(scopes) || scopes[i] != expectedScope {
198 | 				t.Errorf("expected scope[%d] to be '%s', got '%s'", i, expectedScope, scopes[i])
199 | 			}
200 | 		}
201 | 	})
202 | 
203 | 	t.Run("scopes with extra whitespace", func(t *testing.T) {
204 | 		claims := &JWTClaims{
205 | 			Scope: "  read   write   admin  ",
206 | 		}
207 | 		scopes := claims.GetScopes()
208 | 		expected := []string{"read", "write", "admin"}
209 | 
210 | 		if len(scopes) != 3 {
211 | 			t.Errorf("expected 3 scopes, got %d", len(scopes))
212 | 		}
213 | 
214 | 		for i, expectedScope := range expected {
215 | 			if i >= len(scopes) || scopes[i] != expectedScope {
216 | 				t.Errorf("expected scope[%d] to be '%s', got '%s'", i, expectedScope, scopes[i])
217 | 			}
218 | 		}
219 | 	})
220 | }
221 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/tool_mutator_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/containers/kubernetes-mcp-server/pkg/api"
  7 | 	"github.com/google/jsonschema-go/jsonschema"
  8 | 	"github.com/stretchr/testify/assert"
  9 | 	"github.com/stretchr/testify/require"
 10 | 	"github.com/stretchr/testify/suite"
 11 | 	"k8s.io/utils/ptr"
 12 | )
 13 | 
 14 | // createTestTool creates a basic ServerTool for testing
 15 | func createTestTool(name string) api.ServerTool {
 16 | 	return api.ServerTool{
 17 | 		Tool: api.Tool{
 18 | 			Name:        name,
 19 | 			Description: "A test tool",
 20 | 			InputSchema: &jsonschema.Schema{
 21 | 				Type:       "object",
 22 | 				Properties: make(map[string]*jsonschema.Schema),
 23 | 			},
 24 | 		},
 25 | 	}
 26 | }
 27 | 
 28 | // createTestToolWithNilSchema creates a ServerTool with nil InputSchema for testing
 29 | func createTestToolWithNilSchema(name string) api.ServerTool {
 30 | 	return api.ServerTool{
 31 | 		Tool: api.Tool{
 32 | 			Name:        name,
 33 | 			Description: "A test tool",
 34 | 			InputSchema: nil,
 35 | 		},
 36 | 	}
 37 | }
 38 | 
 39 | // createTestToolWithNilProperties creates a ServerTool with nil Properties for testing
 40 | func createTestToolWithNilProperties(name string) api.ServerTool {
 41 | 	return api.ServerTool{
 42 | 		Tool: api.Tool{
 43 | 			Name:        name,
 44 | 			Description: "A test tool",
 45 | 			InputSchema: &jsonschema.Schema{
 46 | 				Type:       "object",
 47 | 				Properties: nil,
 48 | 			},
 49 | 		},
 50 | 	}
 51 | }
 52 | 
 53 | // createTestToolWithExistingProperties creates a ServerTool with existing properties for testing
 54 | func createTestToolWithExistingProperties(name string) api.ServerTool {
 55 | 	return api.ServerTool{
 56 | 		Tool: api.Tool{
 57 | 			Name:        name,
 58 | 			Description: "A test tool",
 59 | 			InputSchema: &jsonschema.Schema{
 60 | 				Type: "object",
 61 | 				Properties: map[string]*jsonschema.Schema{
 62 | 					"existing-prop": {Type: "string"},
 63 | 				},
 64 | 			},
 65 | 		},
 66 | 	}
 67 | }
 68 | 
 69 | func TestWithClusterParameter(t *testing.T) {
 70 | 	tests := []struct {
 71 | 		name                string
 72 | 		defaultCluster      string
 73 | 		targetParameterName string
 74 | 		clusters            []string
 75 | 		toolName            string
 76 | 		toolFactory         func(string) api.ServerTool
 77 | 		expectCluster       bool
 78 | 		expectEnum          bool
 79 | 		enumCount           int
 80 | 	}{
 81 | 		{
 82 | 			name:           "adds cluster parameter when multiple clusters provided",
 83 | 			defaultCluster: "default-cluster",
 84 | 			clusters:       []string{"cluster1", "cluster2", "cluster3"},
 85 | 			toolName:       "test-tool",
 86 | 			toolFactory:    createTestTool,
 87 | 			expectCluster:  true,
 88 | 			expectEnum:     true,
 89 | 			enumCount:      3,
 90 | 		},
 91 | 		{
 92 | 			name:           "does not add cluster parameter when single cluster provided",
 93 | 			defaultCluster: "default-cluster",
 94 | 			clusters:       []string{"single-cluster"},
 95 | 			toolName:       "test-tool",
 96 | 			toolFactory:    createTestTool,
 97 | 			expectCluster:  false,
 98 | 			expectEnum:     false,
 99 | 			enumCount:      0,
100 | 		},
101 | 		{
102 | 			name:           "creates InputSchema when nil",
103 | 			defaultCluster: "default-cluster",
104 | 			clusters:       []string{"cluster1", "cluster2"},
105 | 			toolName:       "test-tool",
106 | 			toolFactory:    createTestToolWithNilSchema,
107 | 			expectCluster:  true,
108 | 			expectEnum:     true,
109 | 			enumCount:      2,
110 | 		},
111 | 		{
112 | 			name:           "creates Properties map when nil",
113 | 			defaultCluster: "default-cluster",
114 | 			clusters:       []string{"cluster1", "cluster2"},
115 | 			toolName:       "test-tool",
116 | 			toolFactory:    createTestToolWithNilProperties,
117 | 			expectCluster:  true,
118 | 			expectEnum:     true,
119 | 			enumCount:      2,
120 | 		},
121 | 		{
122 | 			name:           "preserves existing properties",
123 | 			defaultCluster: "default-cluster",
124 | 			clusters:       []string{"cluster1", "cluster2"},
125 | 			toolName:       "test-tool",
126 | 			toolFactory:    createTestToolWithExistingProperties,
127 | 			expectCluster:  true,
128 | 			expectEnum:     true,
129 | 			enumCount:      2,
130 | 		},
131 | 	}
132 | 
133 | 	for _, tt := range tests {
134 | 		t.Run(tt.name, func(t *testing.T) {
135 | 			if tt.targetParameterName == "" {
136 | 				tt.targetParameterName = "cluster"
137 | 			}
138 | 			mutator := WithTargetParameter(tt.defaultCluster, tt.targetParameterName, tt.clusters)
139 | 			tool := tt.toolFactory(tt.toolName)
140 | 			originalTool := tool // Keep reference to check if tool was unchanged
141 | 
142 | 			result := mutator(tool)
143 | 
144 | 			if !tt.expectCluster {
145 | 				if tt.toolName == "skip-this-tool" {
146 | 					// For skipped tools, the entire tool should be unchanged
147 | 					assert.Equal(t, originalTool, result)
148 | 				} else {
149 | 					// For single cluster, schema should exist but no cluster property
150 | 					require.NotNil(t, result.Tool.InputSchema)
151 | 					require.NotNil(t, result.Tool.InputSchema.Properties)
152 | 					_, exists := result.Tool.InputSchema.Properties["cluster"]
153 | 					assert.False(t, exists, "cluster property should not exist")
154 | 				}
155 | 				return
156 | 			}
157 | 
158 | 			// Common assertions for cases where cluster parameter should be added
159 | 			require.NotNil(t, result.Tool.InputSchema)
160 | 			assert.Equal(t, "object", result.Tool.InputSchema.Type)
161 | 			require.NotNil(t, result.Tool.InputSchema.Properties)
162 | 
163 | 			clusterProperty, exists := result.Tool.InputSchema.Properties["cluster"]
164 | 			assert.True(t, exists, "cluster property should exist")
165 | 			assert.NotNil(t, clusterProperty)
166 | 			assert.Equal(t, "string", clusterProperty.Type)
167 | 			assert.Contains(t, clusterProperty.Description, tt.defaultCluster)
168 | 
169 | 			if tt.expectEnum {
170 | 				assert.NotNil(t, clusterProperty.Enum)
171 | 				assert.Equal(t, tt.enumCount, len(clusterProperty.Enum))
172 | 				for _, cluster := range tt.clusters {
173 | 					assert.Contains(t, clusterProperty.Enum, cluster)
174 | 				}
175 | 			}
176 | 		})
177 | 	}
178 | }
179 | 
180 | func TestCreateClusterProperty(t *testing.T) {
181 | 	tests := []struct {
182 | 		name           string
183 | 		defaultCluster string
184 | 		targetName     string
185 | 		clusters       []string
186 | 		expectEnum     bool
187 | 		expectedCount  int
188 | 	}{
189 | 		{
190 | 			name:           "creates property with enum when clusters <= maxClustersInEnum",
191 | 			defaultCluster: "default",
192 | 			targetName:     "cluster",
193 | 			clusters:       []string{"cluster1", "cluster2", "cluster3"},
194 | 			expectEnum:     true,
195 | 			expectedCount:  3,
196 | 		},
197 | 		{
198 | 			name:           "creates property without enum when clusters > maxClustersInEnum",
199 | 			defaultCluster: "default",
200 | 			targetName:     "cluster",
201 | 			clusters:       make([]string, maxTargetsInEnum+5), // 20 clusters
202 | 			expectEnum:     false,
203 | 			expectedCount:  0,
204 | 		},
205 | 		{
206 | 			name:           "creates property with exact maxClustersInEnum clusters",
207 | 			defaultCluster: "default",
208 | 			targetName:     "cluster",
209 | 			clusters:       make([]string, maxTargetsInEnum),
210 | 			expectEnum:     true,
211 | 			expectedCount:  maxTargetsInEnum,
212 | 		},
213 | 		{
214 | 			name:           "handles single cluster",
215 | 			defaultCluster: "default",
216 | 			targetName:     "cluster",
217 | 			clusters:       []string{"single-cluster"},
218 | 			expectEnum:     true,
219 | 			expectedCount:  1,
220 | 		},
221 | 		{
222 | 			name:           "handles empty clusters list",
223 | 			defaultCluster: "default",
224 | 			targetName:     "cluster",
225 | 			clusters:       []string{},
226 | 			expectEnum:     true,
227 | 			expectedCount:  0,
228 | 		},
229 | 	}
230 | 
231 | 	for _, tt := range tests {
232 | 		t.Run(tt.name, func(t *testing.T) {
233 | 			// Initialize clusters with names if they were created with make()
234 | 			if len(tt.clusters) > 3 && tt.clusters[0] == "" {
235 | 				for i := range tt.clusters {
236 | 					tt.clusters[i] = "cluster" + string(rune('A'+i))
237 | 				}
238 | 			}
239 | 
240 | 			property := createTargetProperty(tt.defaultCluster, tt.targetName, tt.clusters)
241 | 
242 | 			assert.Equal(t, "string", property.Type)
243 | 			assert.Contains(t, property.Description, tt.defaultCluster)
244 | 			assert.Contains(t, property.Description, "Defaults to "+tt.defaultCluster+" if not set")
245 | 
246 | 			if tt.expectEnum {
247 | 				assert.NotNil(t, property.Enum, "enum should be created")
248 | 				assert.Equal(t, tt.expectedCount, len(property.Enum))
249 | 				if tt.expectedCount > 0 && tt.expectedCount <= 3 {
250 | 					// Only check specific values for small, predefined lists
251 | 					for _, cluster := range tt.clusters {
252 | 						assert.Contains(t, property.Enum, cluster)
253 | 					}
254 | 				}
255 | 			} else {
256 | 				assert.Nil(t, property.Enum, "enum should not be created for too many clusters")
257 | 			}
258 | 		})
259 | 	}
260 | }
261 | 
262 | func TestToolMutatorType(t *testing.T) {
263 | 	t.Run("ToolMutator type can be used as function", func(t *testing.T) {
264 | 		var mutator ToolMutator = func(tool api.ServerTool) api.ServerTool {
265 | 			tool.Tool.Name = "modified-" + tool.Tool.Name
266 | 			return tool
267 | 		}
268 | 
269 | 		originalTool := createTestTool("original")
270 | 		result := mutator(originalTool)
271 | 		assert.Equal(t, "modified-original", result.Tool.Name)
272 | 	})
273 | }
274 | 
275 | func TestMaxClustersInEnumConstant(t *testing.T) {
276 | 	t.Run("maxClustersInEnum has expected value", func(t *testing.T) {
277 | 		assert.Equal(t, 5, maxTargetsInEnum, "maxClustersInEnum should be 5")
278 | 	})
279 | }
280 | 
281 | type TargetParameterToolMutatorSuite struct {
282 | 	suite.Suite
283 | }
284 | 
285 | func (s *TargetParameterToolMutatorSuite) TestClusterAwareTool() {
286 | 	tm := WithTargetParameter("default-cluster", "cluster", []string{"cluster-1", "cluster-2", "cluster-3"})
287 | 	tool := createTestTool("cluster-aware-tool")
288 | 	// Tools are cluster-aware by default
289 | 	tm(tool)
290 | 	s.Require().NotNil(tool.Tool.InputSchema.Properties)
291 | 	s.Run("adds cluster parameter", func() {
292 | 		s.NotNil(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
293 | 	})
294 | 	s.Run("adds correct description", func() {
295 | 		desc := tool.Tool.InputSchema.Properties["cluster"].Description
296 | 		s.Contains(desc, "Optional parameter selecting which cluster to run the tool in", "Expected description to mention cluster selection")
297 | 		s.Contains(desc, "Defaults to default-cluster if not set", "Expected description to mention default cluster")
298 | 	})
299 | 	s.Run("adds enum with clusters", func() {
300 | 		s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
301 | 		enum := tool.Tool.InputSchema.Properties["cluster"].Enum
302 | 		s.NotNilf(enum, "Expected enum to be set")
303 | 		s.Equal(3, len(enum), "Expected enum to have 3 entries")
304 | 		s.Contains(enum, "cluster-1", "Expected enum to contain cluster-1")
305 | 		s.Contains(enum, "cluster-2", "Expected enum to contain cluster-2")
306 | 		s.Contains(enum, "cluster-3", "Expected enum to contain cluster-3")
307 | 	})
308 | }
309 | 
310 | func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolSingleCluster() {
311 | 	tm := WithTargetParameter("default", "cluster", []string{"only-cluster"})
312 | 	tool := createTestTool("cluster-aware-tool-single-cluster")
313 | 	// Tools are cluster-aware by default
314 | 	tm(tool)
315 | 	s.Run("does not add cluster parameter for single cluster", func() {
316 | 		s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added for single cluster")
317 | 	})
318 | }
319 | 
320 | func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolMultipleClusters() {
321 | 	tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2", "cluster-3", "cluster-4", "cluster-5", "cluster-6"})
322 | 	tool := createTestTool("cluster-aware-tool-multiple-clusters")
323 | 	// Tools are cluster-aware by default
324 | 	tm(tool)
325 | 	s.Run("adds cluster parameter", func() {
326 | 		s.NotNilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
327 | 	})
328 | 	s.Run("does not add enum when list of clusters is > 5", func() {
329 | 		s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
330 | 		enum := tool.Tool.InputSchema.Properties["cluster"].Enum
331 | 		s.Nilf(enum, "Expected enum to not be set for too many clusters")
332 | 	})
333 | }
334 | 
335 | func (s *TargetParameterToolMutatorSuite) TestNonClusterAwareTool() {
336 | 	tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2"})
337 | 	tool := createTestTool("non-cluster-aware-tool")
338 | 	tool.ClusterAware = ptr.To(false)
339 | 	tm(tool)
340 | 	s.Run("does not add cluster parameter", func() {
341 | 		s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added")
342 | 	})
343 | }
344 | 
345 | func TestTargetParameterToolMutator(t *testing.T) {
346 | 	suite.Run(t, new(TargetParameterToolMutatorSuite))
347 | }
348 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/pods_top_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"net/http"
  5 | 	"regexp"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/BurntSushi/toml"
  9 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 10 | 	"github.com/mark3labs/mcp-go/mcp"
 11 | 	"github.com/stretchr/testify/suite"
 12 | )
 13 | 
 14 | type PodsTopSuite struct {
 15 | 	BaseMcpSuite
 16 | 	mockServer *test.MockServer
 17 | }
 18 | 
 19 | func (s *PodsTopSuite) SetupTest() {
 20 | 	s.BaseMcpSuite.SetupTest()
 21 | 	s.mockServer = test.NewMockServer()
 22 | 	s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
 23 | }
 24 | 
 25 | func (s *PodsTopSuite) TearDownTest() {
 26 | 	s.BaseMcpSuite.TearDownTest()
 27 | 	if s.mockServer != nil {
 28 | 		s.mockServer.Close()
 29 | 	}
 30 | }
 31 | 
 32 | func (s *PodsTopSuite) TestPodsTopMetricsUnavailable() {
 33 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 34 | 		w.Header().Set("Content-Type", "application/json")
 35 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
 36 | 		if req.URL.Path == "/api" {
 37 | 			_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
 38 | 			return
 39 | 		}
 40 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups)
 41 | 		if req.URL.Path == "/apis" {
 42 | 			_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
 43 | 			return
 44 | 		}
 45 | 	}))
 46 | 	s.InitMcpClient()
 47 | 
 48 | 	s.Run("pods_top with metrics API not available", func() {
 49 | 		result, err := s.CallTool("pods_top", map[string]interface{}{})
 50 | 		s.NoError(err, "call tool failed %v", err)
 51 | 		s.Require().NoError(err)
 52 | 		s.True(result.IsError, "call tool should have returned an error")
 53 | 		s.Equalf("failed to get pods top: metrics API is not available", result.Content[0].(mcp.TextContent).Text,
 54 | 			"call tool returned unexpected content: %s", result.Content[0].(mcp.TextContent).Text)
 55 | 	})
 56 | }
 57 | 
 58 | func (s *PodsTopSuite) TestPodsTopMetricsAvailable() {
 59 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 60 | 		w.Header().Set("Content-Type", "application/json")
 61 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
 62 | 		if req.URL.Path == "/api" {
 63 | 			_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
 64 | 			return
 65 | 		}
 66 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups)
 67 | 		if req.URL.Path == "/apis" {
 68 | 			_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
 69 | 			return
 70 | 		}
 71 | 		// Request Performed by DiscoveryClient to Kube API (Get API Resources)
 72 | 		if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
 73 | 			_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
 74 | 			return
 75 | 		}
 76 | 		// Pod Metrics from all namespaces
 77 | 		if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" {
 78 | 			if req.URL.Query().Get("labelSelector") == "app=pod-ns-5-42" {
 79 | 				_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
 80 | 					`{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi","swap":"42Mi"}}]}` +
 81 | 					`]}`))
 82 | 			} else {
 83 | 				_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
 84 | 					`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi","swap":"13Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi","swap":"37Mi"}}]},` +
 85 | 					`{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi","swap":"42Mi"}}]}` +
 86 | 					`]}`))
 87 | 
 88 | 			}
 89 | 			return
 90 | 		}
 91 | 		// Pod Metrics from configured namespace
 92 | 		if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" {
 93 | 			_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
 94 | 				`{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi","swap":"13Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi","swap":"37Mi"}}]}` +
 95 | 				`]}`))
 96 | 			return
 97 | 		}
 98 | 		// Pod Metrics from ns-5 namespace
 99 | 		if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods" {
100 | 			_, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
101 | 				`{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi","swap":"42Mi"}}]}` +
102 | 				`]}`))
103 | 			return
104 | 		}
105 | 		// Pod Metrics from ns-5 namespace with pod-ns-5-5 pod name
106 | 		if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods/pod-ns-5-5" {
107 | 			_, _ = w.Write([]byte(`{"kind":"PodMetrics","apiVersion":"metrics.k8s.io/v1beta1",` +
108 | 				`"metadata":{"name":"pod-ns-5-5","namespace":"ns-5"},` +
109 | 				`"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi","swap":"42Mi"}}]` +
110 | 				`}`))
111 | 		}
112 | 	}))
113 | 	s.InitMcpClient()
114 | 
115 | 	s.Run("pods_top(defaults) returns pod metrics from all namespaces", func() {
116 | 		result, err := s.CallTool("pods_top", map[string]interface{}{})
117 | 		s.Require().NotNil(result)
118 | 		s.NoErrorf(err, "call tool failed %v", err)
119 | 		textContent := result.Content[0].(mcp.TextContent).Text
120 | 		s.Falsef(result.IsError, "call tool failed %v", textContent)
121 | 
122 | 		expectedHeaders := regexp.MustCompile(`(?m)^\s*NAMESPACE\s+POD\s+NAME\s+CPU\(cores\)\s+MEMORY\(bytes\)\s+SWAP\(bytes\)\s*$`)
123 | 		s.Regexpf(expectedHeaders, textContent, "expected headers '%s' not found in output:\n%s", expectedHeaders.String(), textContent)
124 | 		expectedRows := []string{
125 | 			"default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi\\s+13Mi",
126 | 			"default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi\\s+37Mi",
127 | 			"ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi\\s+42Mi",
128 | 		}
129 | 
130 | 		for _, row := range expectedRows {
131 | 			s.Regexpf(row, textContent, "expected row '%s' not found in output:\n%s", row, textContent)
132 | 		}
133 | 
134 | 		expectedTotal := regexp.MustCompile(`(?m)^\s+600m\s+900Mi\s+92Mi\s*$`)
135 | 		s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
136 | 	})
137 | 
138 | 	s.Run("pods_top(allNamespaces=false) returns pod metrics from configured namespace", func() {
139 | 		result, err := s.CallTool("pods_top", map[string]interface{}{
140 | 			"all_namespaces": false,
141 | 		})
142 | 		s.Require().NotNil(result)
143 | 		s.NoErrorf(err, "call tool failed %v", err)
144 | 		textContent := result.Content[0].(mcp.TextContent).Text
145 | 		s.Falsef(result.IsError, "call tool failed %v", textContent)
146 | 
147 | 		expectedRows := []string{
148 | 			"default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi\\s+13Mi",
149 | 			"default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi\\s+37Mi",
150 | 		}
151 | 		for _, row := range expectedRows {
152 | 			s.Regexpf(row, textContent, "expected row '%s' not found in output:\n%s", row, textContent)
153 | 		}
154 | 
155 | 		expectedTotal := regexp.MustCompile(`(?m)^\s+40m\s+60Mi\s+50Mi\s*$`)
156 | 		s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
157 | 	})
158 | 
159 | 	s.Run("pods_top(namespace=ns-5) returns pod metrics from provided namespace", func() {
160 | 		result, err := s.CallTool("pods_top", map[string]interface{}{
161 | 			"namespace": "ns-5",
162 | 		})
163 | 		s.Require().NotNil(result)
164 | 		s.NoErrorf(err, "call tool failed %v", err)
165 | 		textContent := result.Content[0].(mcp.TextContent).Text
166 | 		s.Falsef(result.IsError, "call tool failed %v", textContent)
167 | 
168 | 		expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-1\s+container-1\s+10m\s+20Mi\s+42Mi`)
169 | 		s.Regexpf(expectedRow, textContent, "expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
170 | 
171 | 		expectedTotal := regexp.MustCompile(`(?m)^\s+10m\s+20Mi\s+42Mi\s*$`)
172 | 		s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
173 | 	})
174 | 
175 | 	s.Run("pods_top(namespace=ns-5,name=pod-ns-5-5) returns pod metrics from provided namespace and name", func() {
176 | 		result, err := s.CallTool("pods_top", map[string]interface{}{
177 | 			"namespace": "ns-5",
178 | 			"name":      "pod-ns-5-5",
179 | 		})
180 | 		s.Require().NotNil(result)
181 | 		s.NoErrorf(err, "call tool failed %v", err)
182 | 		textContent := result.Content[0].(mcp.TextContent).Text
183 | 		s.Falsef(result.IsError, "call tool failed %v", textContent)
184 | 
185 | 		expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-5\s+container-1\s+13m\s+37Mi\s+42Mi`)
186 | 		s.Regexpf(expectedRow, textContent, "expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
187 | 
188 | 		expectedTotal := regexp.MustCompile(`(?m)^\s+13m\s+37Mi\s+42Mi\s*$`)
189 | 		s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
190 | 	})
191 | 
192 | 	s.Run("pods_top[label_selector=app=pod-ns-5-42] returns pod metrics from pods matching selector", func() {
193 | 		result, err := s.CallTool("pods_top", map[string]interface{}{
194 | 			"label_selector": "app=pod-ns-5-42",
195 | 		})
196 | 		s.Require().NotNil(result)
197 | 		s.NoErrorf(err, "call tool failed %v", err)
198 | 		textContent := result.Content[0].(mcp.TextContent).Text
199 | 		s.Falsef(result.IsError, "call tool failed %v", textContent)
200 | 
201 | 		expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-42\s+container-1\s+42m\s+42Mi`)
202 | 		s.Regexpf(expectedRow, textContent, "expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
203 | 
204 | 		expectedTotal := regexp.MustCompile(`(?m)^\s+42m\s+42Mi\s+42Mi\s*$`)
205 | 		s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
206 | 	})
207 | }
208 | 
209 | func (s *PodsTopSuite) TestPodsTopDenied() {
210 | 	s.Require().NoError(toml.Unmarshal([]byte(`
211 | 		denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
212 | 	`), s.Cfg), "Expected to parse denied resources config")
213 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
214 | 		w.Header().Set("Content-Type", "application/json")
215 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
216 | 		if req.URL.Path == "/api" {
217 | 			_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
218 | 			return
219 | 		}
220 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups)
221 | 		if req.URL.Path == "/apis" {
222 | 			_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
223 | 			return
224 | 		}
225 | 		// Request Performed by DiscoveryClient to Kube API (Get API Resources)
226 | 		if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
227 | 			_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
228 | 			return
229 | 		}
230 | 	}))
231 | 	s.InitMcpClient()
232 | 
233 | 	s.Run("pods_top (denied)", func() {
234 | 		result, err := s.CallTool("pods_top", map[string]interface{}{})
235 | 		s.Require().NotNil(result, "toolResult should not be nil")
236 | 		s.Run("has error", func() {
237 | 			s.Truef(result.IsError, "call tool should fail")
238 | 			s.Nilf(err, "call tool should not return error object")
239 | 		})
240 | 		s.Run("describes denial", func() {
241 | 			expectedMessage := "failed to get pods top: resource not allowed: metrics.k8s.io/v1beta1, Kind=PodMetrics"
242 | 			s.Equalf(expectedMessage, result.Content[0].(mcp.TextContent).Text,
243 | 				"expected descriptive error '%s', got %v", expectedMessage, result.Content[0].(mcp.TextContent).Text)
244 | 		})
245 | 	})
246 | }
247 | 
248 | func TestPodsTop(t *testing.T) {
249 | 	suite.Run(t, new(PodsTopSuite))
250 | }
251 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/helm_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/base64"
  6 | 	"path/filepath"
  7 | 	"runtime"
  8 | 	"strings"
  9 | 	"testing"
 10 | 
 11 | 	"github.com/BurntSushi/toml"
 12 | 	"github.com/mark3labs/mcp-go/mcp"
 13 | 	"github.com/stretchr/testify/suite"
 14 | 	corev1 "k8s.io/api/core/v1"
 15 | 	"k8s.io/apimachinery/pkg/api/errors"
 16 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 17 | 	"k8s.io/client-go/kubernetes"
 18 | 	"sigs.k8s.io/yaml"
 19 | )
 20 | 
 21 | type HelmSuite struct {
 22 | 	BaseMcpSuite
 23 | }
 24 | 
 25 | func (s *HelmSuite) SetupTest() {
 26 | 	s.BaseMcpSuite.SetupTest()
 27 | 	clearHelmReleases(s.T().Context(), kubernetes.NewForConfigOrDie(envTestRestConfig))
 28 | }
 29 | 
 30 | func (s *HelmSuite) TestHelmInstall() {
 31 | 	s.InitMcpClient()
 32 | 	s.Run("helm_install(chart=helm-chart-no-op)", func() {
 33 | 		_, file, _, _ := runtime.Caller(0)
 34 | 		chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op")
 35 | 		toolResult, err := s.CallTool("helm_install", map[string]interface{}{
 36 | 			"chart": chartPath,
 37 | 		})
 38 | 		s.Run("no error", func() {
 39 | 			s.Nilf(err, "call tool failed %v", err)
 40 | 			s.Falsef(toolResult.IsError, "call tool failed")
 41 | 		})
 42 | 		s.Run("returns installed chart", func() {
 43 | 			var decoded []map[string]interface{}
 44 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
 45 | 			s.Run("has yaml content", func() {
 46 | 				s.Nilf(err, "invalid tool result content %v", err)
 47 | 			})
 48 | 			s.Run("has 1 item", func() {
 49 | 				s.Lenf(decoded, 1, "invalid helm install count, expected 1, got %v", len(decoded))
 50 | 			})
 51 | 			s.Run("has valid name", func() {
 52 | 				s.Truef(strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-"), "invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
 53 | 			})
 54 | 			s.Run("has valid namespace", func() {
 55 | 				s.Equalf("default", decoded[0]["namespace"], "invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
 56 | 			})
 57 | 			s.Run("has valid chart", func() {
 58 | 				s.Equalf("no-op", decoded[0]["chart"], "invalid helm install name, expected release name, got empty")
 59 | 			})
 60 | 			s.Run("has valid chartVersion", func() {
 61 | 				s.Equalf("1.33.7", decoded[0]["chartVersion"], "invalid helm install version, expected 1.33.7, got empty")
 62 | 			})
 63 | 			s.Run("has valid status", func() {
 64 | 				s.Equalf("deployed", decoded[0]["status"], "invalid helm install status, expected deployed, got %v", decoded[0]["status"])
 65 | 			})
 66 | 			s.Run("has valid revision", func() {
 67 | 				s.Equalf(float64(1), decoded[0]["revision"], "invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
 68 | 			})
 69 | 		})
 70 | 	})
 71 | }
 72 | 
 73 | func (s *HelmSuite) TestHelmInstallDenied() {
 74 | 	s.Require().NoError(toml.Unmarshal([]byte(`
 75 | 		denied_resources = [ { version = "v1", kind = "Secret" } ]
 76 | 	`), s.Cfg), "Expected to parse denied resources config")
 77 | 	s.InitMcpClient()
 78 | 	s.Run("helm_install(chart=helm-chart-secret, denied)", func() {
 79 | 		_, file, _, _ := runtime.Caller(0)
 80 | 		chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-secret")
 81 | 		toolResult, err := s.CallTool("helm_install", map[string]interface{}{
 82 | 			"chart": chartPath,
 83 | 		})
 84 | 		s.Run("has error", func() {
 85 | 			s.Truef(toolResult.IsError, "call tool should fail")
 86 | 			s.Nilf(err, "call tool should not return error object")
 87 | 		})
 88 | 		s.Run("describes denial", func() {
 89 | 			s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "failed to install helm chart"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
 90 | 			expectedMessage := ": resource not allowed: /v1, Kind=Secret"
 91 | 			s.Truef(strings.HasSuffix(toolResult.Content[0].(mcp.TextContent).Text, expectedMessage), "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
 92 | 		})
 93 | 	})
 94 | }
 95 | 
 96 | func (s *HelmSuite) TestHelmListNoReleases() {
 97 | 	s.InitMcpClient()
 98 | 	s.Run("helm_list() with no releases", func() {
 99 | 		toolResult, err := s.CallTool("helm_list", map[string]interface{}{})
100 | 		s.Run("no error", func() {
101 | 			s.Nilf(err, "call tool failed %v", err)
102 | 			s.Falsef(toolResult.IsError, "call tool failed")
103 | 		})
104 | 		s.Run("returns not found", func() {
105 | 			s.Equalf("No Helm releases found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
106 | 		})
107 | 	})
108 | }
109 | 
110 | func (s *HelmSuite) TestHelmList() {
111 | 	kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
112 | 	_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
113 | 		ObjectMeta: metav1.ObjectMeta{
114 | 			Name:   "sh.helm.release.v1.release-to-list",
115 | 			Labels: map[string]string{"owner": "helm", "name": "release-to-list"},
116 | 		},
117 | 		Data: map[string][]byte{
118 | 			"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
119 | 				"\"name\":\"release-to-list\"," +
120 | 				"\"info\":{\"status\":\"deployed\"}" +
121 | 				"}"))),
122 | 		},
123 | 	}, metav1.CreateOptions{})
124 | 	s.Require().NoError(err)
125 | 	s.InitMcpClient()
126 | 	s.Run("helm_list() with deployed release", func() {
127 | 		toolResult, err := s.CallTool("helm_list", map[string]interface{}{})
128 | 		s.Run("no error", func() {
129 | 			s.Nilf(err, "call tool failed %v", err)
130 | 			s.Falsef(toolResult.IsError, "call tool failed")
131 | 		})
132 | 		s.Run("returns release", func() {
133 | 			var decoded []map[string]interface{}
134 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
135 | 			s.Run("has yaml content", func() {
136 | 				s.Nilf(err, "invalid tool result content %v", err)
137 | 			})
138 | 			s.Run("has 1 item", func() {
139 | 				s.Lenf(decoded, 1, "invalid helm list count, expected 1, got %v", len(decoded))
140 | 			})
141 | 			s.Run("has valid name", func() {
142 | 				s.Equalf("release-to-list", decoded[0]["name"], "invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
143 | 			})
144 | 			s.Run("has valid status", func() {
145 | 				s.Equalf("deployed", decoded[0]["status"], "invalid helm list status, expected deployed, got %v", decoded[0]["status"])
146 | 			})
147 | 		})
148 | 	})
149 | 	s.Run("helm_list(namespace=ns-1) with deployed release in other namespaces", func() {
150 | 		toolResult, err := s.CallTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
151 | 		s.Run("no error", func() {
152 | 			s.Nilf(err, "call tool failed %v", err)
153 | 			s.Falsef(toolResult.IsError, "call tool failed")
154 | 		})
155 | 		s.Run("returns not found", func() {
156 | 			s.Equalf("No Helm releases found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
157 | 		})
158 | 	})
159 | 	s.Run("helm_list(namespace=ns-1, all_namespaces=true) with deployed release in all namespaces", func() {
160 | 		toolResult, err := s.CallTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true})
161 | 		s.Run("no error", func() {
162 | 			s.Nilf(err, "call tool failed %v", err)
163 | 			s.Falsef(toolResult.IsError, "call tool failed")
164 | 		})
165 | 		s.Run("returns release", func() {
166 | 			var decoded []map[string]interface{}
167 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
168 | 			s.Run("has yaml content", func() {
169 | 				s.Nilf(err, "invalid tool result content %v", err)
170 | 			})
171 | 			s.Run("has 1 item", func() {
172 | 				s.Lenf(decoded, 1, "invalid helm list count, expected 1, got %v", len(decoded))
173 | 			})
174 | 			s.Run("has valid name", func() {
175 | 				s.Equalf("release-to-list", decoded[0]["name"], "invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
176 | 			})
177 | 			s.Run("has valid status", func() {
178 | 				s.Equalf("deployed", decoded[0]["status"], "invalid helm list status, expected deployed, got %v", decoded[0]["status"])
179 | 			})
180 | 		})
181 | 	})
182 | }
183 | 
184 | func (s *HelmSuite) TestHelmUninstallNoReleases() {
185 | 	s.InitMcpClient()
186 | 	s.Run("helm_uninstall(name=release-to-uninstall) with no releases", func() {
187 | 		toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
188 | 			"name": "release-to-uninstall",
189 | 		})
190 | 		s.Run("no error", func() {
191 | 			s.Nilf(err, "call tool failed %v", err)
192 | 			s.Falsef(toolResult.IsError, "call tool failed")
193 | 		})
194 | 		s.Run("returns not found", func() {
195 | 			s.Equalf("Release release-to-uninstall not found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
196 | 		})
197 | 	})
198 | }
199 | 
200 | func (s *HelmSuite) TestHelmUninstall() {
201 | 	kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
202 | 	_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
203 | 		ObjectMeta: metav1.ObjectMeta{
204 | 			Name:   "sh.helm.release.v1.existent-release-to-uninstall.v0",
205 | 			Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
206 | 		},
207 | 		Data: map[string][]byte{
208 | 			"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
209 | 				"\"name\":\"existent-release-to-uninstall\"," +
210 | 				"\"info\":{\"status\":\"deployed\"}" +
211 | 				"}"))),
212 | 		},
213 | 	}, metav1.CreateOptions{})
214 | 	s.Require().NoError(err)
215 | 	s.InitMcpClient()
216 | 	s.Run("helm_uninstall(name=existent-release-to-uninstall) with deployed release", func() {
217 | 		toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
218 | 			"name": "existent-release-to-uninstall",
219 | 		})
220 | 		s.Run("no error", func() {
221 | 			s.Nilf(err, "call tool failed %v", err)
222 | 			s.Falsef(toolResult.IsError, "call tool failed")
223 | 		})
224 | 		s.Run("returns uninstalled", func() {
225 | 			s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "Uninstalled release existent-release-to-uninstall"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
226 | 			_, err = kc.CoreV1().Secrets("default").Get(s.T().Context(), "sh.helm.release.v1.existent-release-to-uninstall.v0", metav1.GetOptions{})
227 | 			s.Truef(errors.IsNotFound(err), "expected release to be deleted, but it still exists")
228 | 		})
229 | 
230 | 	})
231 | }
232 | 
233 | func (s *HelmSuite) TestHelmUninstallDenied() {
234 | 	s.Require().NoError(toml.Unmarshal([]byte(`
235 | 		denied_resources = [ { version = "v1", kind = "Secret" } ]
236 | 	`), s.Cfg), "Expected to parse denied resources config")
237 | 	kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
238 | 	_, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
239 | 		ObjectMeta: metav1.ObjectMeta{
240 | 			Name:   "sh.helm.release.v1.existent-release-to-uninstall.v0",
241 | 			Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
242 | 		},
243 | 		Data: map[string][]byte{
244 | 			"release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
245 | 				"\"name\":\"existent-release-to-uninstall\"," +
246 | 				"\"info\":{\"status\":\"deployed\"}," +
247 | 				"\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n  name: secret-to-deny\\n  namespace: default\\n\"" +
248 | 				"}"))),
249 | 		},
250 | 	}, metav1.CreateOptions{})
251 | 	s.Require().NoError(err)
252 | 	s.InitMcpClient()
253 | 	s.Run("helm_uninstall(name=existent-release-to-uninstall) with deployed release (denied)", func() {
254 | 		toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
255 | 			"name": "existent-release-to-uninstall",
256 | 		})
257 | 		s.Run("has error", func() {
258 | 			s.Truef(toolResult.IsError, "call tool should fail")
259 | 			s.Nilf(err, "call tool should not return error object")
260 | 		})
261 | 		s.Run("describes denial", func() {
262 | 			s.T().Skipf("Helm won't report what underlying resource caused the failure, so we can't assert on it")
263 | 			expectedMessage := "failed to uninstall release: resource not allowed: /v1, Kind=Secret"
264 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
265 | 		})
266 | 	})
267 | }
268 | 
269 | func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {
270 | 	secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{})
271 | 	for _, secret := range secrets.Items {
272 | 		if strings.HasPrefix(secret.Name, "sh.helm.release.v1.") {
273 | 			_ = kc.CoreV1().Secrets("default").Delete(ctx, secret.Name, metav1.DeleteOptions{})
274 | 		}
275 | 	}
276 | }
277 | 
278 | func TestHelm(t *testing.T) {
279 | 	suite.Run(t, new(HelmSuite))
280 | }
281 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes-mcp-server/cmd/root_test.go:
--------------------------------------------------------------------------------

```go
  1 | package cmd
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"io"
  6 | 	"os"
  7 | 	"path/filepath"
  8 | 	"regexp"
  9 | 	"runtime"
 10 | 	"strings"
 11 | 	"testing"
 12 | 
 13 | 	"github.com/stretchr/testify/assert"
 14 | 	"github.com/stretchr/testify/require"
 15 | 	"k8s.io/cli-runtime/pkg/genericiooptions"
 16 | )
 17 | 
 18 | func captureOutput(f func() error) (string, error) {
 19 | 	originalOut := os.Stdout
 20 | 	defer func() {
 21 | 		os.Stdout = originalOut
 22 | 	}()
 23 | 	r, w, _ := os.Pipe()
 24 | 	os.Stdout = w
 25 | 	err := f()
 26 | 	_ = w.Close()
 27 | 	out, _ := io.ReadAll(r)
 28 | 	return string(out), err
 29 | }
 30 | 
 31 | func testStream() (genericiooptions.IOStreams, *bytes.Buffer) {
 32 | 	out := &bytes.Buffer{}
 33 | 	return genericiooptions.IOStreams{
 34 | 		In:     &bytes.Buffer{},
 35 | 		Out:    out,
 36 | 		ErrOut: io.Discard,
 37 | 	}, out
 38 | }
 39 | 
 40 | func TestVersion(t *testing.T) {
 41 | 	ioStreams, out := testStream()
 42 | 	rootCmd := NewMCPServer(ioStreams)
 43 | 	rootCmd.SetArgs([]string{"--version"})
 44 | 	if err := rootCmd.Execute(); out.String() != "0.0.0\n" {
 45 | 		t.Fatalf("Expected version 0.0.0, got %s %v", out.String(), err)
 46 | 	}
 47 | }
 48 | 
 49 | func TestConfig(t *testing.T) {
 50 | 	t.Run("defaults to none", func(t *testing.T) {
 51 | 		ioStreams, out := testStream()
 52 | 		rootCmd := NewMCPServer(ioStreams)
 53 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
 54 | 		expectedConfig := `" - Config: "`
 55 | 		if err := rootCmd.Execute(); !strings.Contains(out.String(), expectedConfig) {
 56 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err)
 57 | 		}
 58 | 	})
 59 | 	t.Run("set with --config", func(t *testing.T) {
 60 | 		ioStreams, out := testStream()
 61 | 		rootCmd := NewMCPServer(ioStreams)
 62 | 		_, file, _, _ := runtime.Caller(0)
 63 | 		emptyConfigPath := filepath.Join(filepath.Dir(file), "testdata", "empty-config.toml")
 64 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", emptyConfigPath})
 65 | 		_ = rootCmd.Execute()
 66 | 		expected := `(?m)\" - Config\:[^\"]+empty-config\.toml\"`
 67 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
 68 | 			t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err)
 69 | 		}
 70 | 	})
 71 | 	t.Run("invalid path throws error", func(t *testing.T) {
 72 | 		ioStreams, _ := testStream()
 73 | 		rootCmd := NewMCPServer(ioStreams)
 74 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", "invalid-path-to-config.toml"})
 75 | 		err := rootCmd.Execute()
 76 | 		if err == nil {
 77 | 			t.Fatal("Expected error for invalid config path, got nil")
 78 | 		}
 79 | 		expected := "open invalid-path-to-config.toml: "
 80 | 		if !strings.HasPrefix(err.Error(), expected) {
 81 | 			t.Fatalf("Expected error to be %s, got %s", expected, err.Error())
 82 | 		}
 83 | 	})
 84 | 	t.Run("set with valid --config", func(t *testing.T) {
 85 | 		ioStreams, out := testStream()
 86 | 		rootCmd := NewMCPServer(ioStreams)
 87 | 		_, file, _, _ := runtime.Caller(0)
 88 | 		validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml")
 89 | 		rootCmd.SetArgs([]string{"--version", "--config", validConfigPath})
 90 | 		_ = rootCmd.Execute()
 91 | 		expectedConfig := `(?m)\" - Config\:[^\"]+valid-config\.toml\"`
 92 | 		if m, err := regexp.MatchString(expectedConfig, out.String()); !m || err != nil {
 93 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err)
 94 | 		}
 95 | 		expectedListOutput := `(?m)\" - ListOutput\: yaml"`
 96 | 		if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil {
 97 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err)
 98 | 		}
 99 | 		expectedReadOnly := `(?m)\" - Read-only mode: true"`
100 | 		if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil {
101 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err)
102 | 		}
103 | 		expectedDisableDestruction := `(?m)\" - Disable destructive tools: true"`
104 | 		if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil {
105 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err)
106 | 		}
107 | 	})
108 | 	t.Run("set with valid --config, flags take precedence", func(t *testing.T) {
109 | 		ioStreams, out := testStream()
110 | 		rootCmd := NewMCPServer(ioStreams)
111 | 		_, file, _, _ := runtime.Caller(0)
112 | 		validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml")
113 | 		rootCmd.SetArgs([]string{"--version", "--list-output=table", "--disable-destructive=false", "--read-only=false", "--config", validConfigPath})
114 | 		_ = rootCmd.Execute()
115 | 		expected := `(?m)\" - Config\:[^\"]+valid-config\.toml\"`
116 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
117 | 			t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err)
118 | 		}
119 | 		expectedListOutput := `(?m)\" - ListOutput\: table"`
120 | 		if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil {
121 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err)
122 | 		}
123 | 		expectedReadOnly := `(?m)\" - Read-only mode: false"`
124 | 		if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil {
125 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err)
126 | 		}
127 | 		expectedDisableDestruction := `(?m)\" - Disable destructive tools: false"`
128 | 		if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil {
129 | 			t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err)
130 | 		}
131 | 	})
132 | }
133 | 
134 | func TestToolsets(t *testing.T) {
135 | 	t.Run("available", func(t *testing.T) {
136 | 		ioStreams, _ := testStream()
137 | 		rootCmd := NewMCPServer(ioStreams)
138 | 		rootCmd.SetArgs([]string{"--help"})
139 | 		o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140 | 		if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
141 | 			t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142 | 		}
143 | 	})
144 | 	t.Run("default", func(t *testing.T) {
145 | 		ioStreams, out := testStream()
146 | 		rootCmd := NewMCPServer(ioStreams)
147 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
148 | 		if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
149 | 			t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
150 | 		}
151 | 	})
152 | 	t.Run("set with --toolsets", func(t *testing.T) {
153 | 		ioStreams, out := testStream()
154 | 		rootCmd := NewMCPServer(ioStreams)
155 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--toolsets", "helm,config"})
156 | 		_ = rootCmd.Execute()
157 | 		expected := `(?m)\" - Toolsets\: helm, config\"`
158 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
159 | 			t.Fatalf("Expected toolset to be %s, got %s %v", expected, out.String(), err)
160 | 		}
161 | 	})
162 | }
163 | 
164 | func TestListOutput(t *testing.T) {
165 | 	t.Run("available", func(t *testing.T) {
166 | 		ioStreams, _ := testStream()
167 | 		rootCmd := NewMCPServer(ioStreams)
168 | 		rootCmd.SetArgs([]string{"--help"})
169 | 		o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
170 | 		if !strings.Contains(o, "Output format for resource list operations (one of: yaml, table)") {
171 | 			t.Fatalf("Expected all available outputs, got %s %v", o, err)
172 | 		}
173 | 	})
174 | 	t.Run("defaults to table", func(t *testing.T) {
175 | 		ioStreams, out := testStream()
176 | 		rootCmd := NewMCPServer(ioStreams)
177 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
178 | 		if err := rootCmd.Execute(); !strings.Contains(out.String(), "- ListOutput: table") {
179 | 			t.Fatalf("Expected list-output 'table', got %s %v", out, err)
180 | 		}
181 | 	})
182 | 	t.Run("set with --list-output", func(t *testing.T) {
183 | 		ioStreams, out := testStream()
184 | 		rootCmd := NewMCPServer(ioStreams)
185 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--list-output", "yaml"})
186 | 		_ = rootCmd.Execute()
187 | 		expected := `(?m)\" - ListOutput\: yaml\"`
188 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
189 | 			t.Fatalf("Expected list-output to be %s, got %s %v", expected, out.String(), err)
190 | 		}
191 | 	})
192 | }
193 | 
194 | func TestReadOnly(t *testing.T) {
195 | 	t.Run("defaults to false", func(t *testing.T) {
196 | 		ioStreams, out := testStream()
197 | 		rootCmd := NewMCPServer(ioStreams)
198 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
199 | 		if err := rootCmd.Execute(); !strings.Contains(out.String(), " - Read-only mode: false") {
200 | 			t.Fatalf("Expected read-only mode false, got %s %v", out, err)
201 | 		}
202 | 	})
203 | 	t.Run("set with --read-only", func(t *testing.T) {
204 | 		ioStreams, out := testStream()
205 | 		rootCmd := NewMCPServer(ioStreams)
206 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--read-only"})
207 | 		_ = rootCmd.Execute()
208 | 		expected := `(?m)\" - Read-only mode\: true\"`
209 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
210 | 			t.Fatalf("Expected read-only mode to be %s, got %s %v", expected, out.String(), err)
211 | 		}
212 | 	})
213 | }
214 | 
215 | func TestDisableDestructive(t *testing.T) {
216 | 	t.Run("defaults to false", func(t *testing.T) {
217 | 		ioStreams, out := testStream()
218 | 		rootCmd := NewMCPServer(ioStreams)
219 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
220 | 		if err := rootCmd.Execute(); !strings.Contains(out.String(), " - Disable destructive tools: false") {
221 | 			t.Fatalf("Expected disable destructive false, got %s %v", out, err)
222 | 		}
223 | 	})
224 | 	t.Run("set with --disable-destructive", func(t *testing.T) {
225 | 		ioStreams, out := testStream()
226 | 		rootCmd := NewMCPServer(ioStreams)
227 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--disable-destructive"})
228 | 		_ = rootCmd.Execute()
229 | 		expected := `(?m)\" - Disable destructive tools\: true\"`
230 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
231 | 			t.Fatalf("Expected disable-destructive mode to be %s, got %s %v", expected, out.String(), err)
232 | 		}
233 | 	})
234 | }
235 | 
236 | func TestAuthorizationURL(t *testing.T) {
237 | 	t.Run("invalid authorization-url without protocol", func(t *testing.T) {
238 | 		ioStreams, _ := testStream()
239 | 		rootCmd := NewMCPServer(ioStreams)
240 | 		rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--authorization-url", "example.com/auth", "--server-url", "https://example.com:8080"})
241 | 		err := rootCmd.Execute()
242 | 		if err == nil {
243 | 			t.Fatal("Expected error for invalid authorization-url without protocol, got nil")
244 | 		}
245 | 		expected := "--authorization-url must be a valid URL"
246 | 		if !strings.Contains(err.Error(), expected) {
247 | 			t.Fatalf("Expected error to contain %s, got %s", expected, err.Error())
248 | 		}
249 | 	})
250 | 	t.Run("valid authorization-url with https", func(t *testing.T) {
251 | 		ioStreams, _ := testStream()
252 | 		rootCmd := NewMCPServer(ioStreams)
253 | 		rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--authorization-url", "https://example.com/auth", "--server-url", "https://example.com:8080"})
254 | 		err := rootCmd.Execute()
255 | 		if err != nil {
256 | 			t.Fatalf("Expected no error for valid https authorization-url, got %s", err.Error())
257 | 		}
258 | 	})
259 | }
260 | 
261 | func TestStdioLogging(t *testing.T) {
262 | 	t.Run("stdio disables klog", func(t *testing.T) {
263 | 		ioStreams, out := testStream()
264 | 		rootCmd := NewMCPServer(ioStreams)
265 | 		rootCmd.SetArgs([]string{"--version", "--log-level=1"})
266 | 		err := rootCmd.Execute()
267 | 		require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
268 | 		assert.Equalf(t, "0.0.0\n", out.String(), "Expected only version output, got %s", out.String())
269 | 	})
270 | 	t.Run("http mode enables klog", func(t *testing.T) {
271 | 		ioStreams, out := testStream()
272 | 		rootCmd := NewMCPServer(ioStreams)
273 | 		rootCmd.SetArgs([]string{"--version", "--log-level=1", "--port=1337"})
274 | 		err := rootCmd.Execute()
275 | 		require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
276 | 		assert.Containsf(t, out.String(), "Starting kubernetes-mcp-server", "Expected klog output, got %s", out.String())
277 | 	})
278 | }
279 | 
280 | func TestDisableMultiCluster(t *testing.T) {
281 | 	t.Run("defaults to false", func(t *testing.T) {
282 | 		ioStreams, out := testStream()
283 | 		rootCmd := NewMCPServer(ioStreams)
284 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
285 | 		if err := rootCmd.Execute(); !strings.Contains(out.String(), " - ClusterProviderStrategy: auto-detect (it is recommended to set this explicitly in your Config)") {
286 | 			t.Fatalf("Expected ClusterProviderStrategy kubeconfig, got %s %v", out, err)
287 | 		}
288 | 	})
289 | 	t.Run("set with --disable-multi-cluster", func(t *testing.T) {
290 | 		ioStreams, out := testStream()
291 | 		rootCmd := NewMCPServer(ioStreams)
292 | 		rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--disable-multi-cluster"})
293 | 		_ = rootCmd.Execute()
294 | 		expected := `(?m)\" - ClusterProviderStrategy\: disabled\"`
295 | 		if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
296 | 			t.Fatalf("Expected ClusterProviderStrategy %s, got %s %v", expected, out.String(), err)
297 | 		}
298 | 	})
299 | }
300 | 
```
Page 3/5FirstPrevNextLast