#
tokens: 48105/50000 21/144 files (page 2/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 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/helm/helm.go:
--------------------------------------------------------------------------------

```go
  1 | package helm
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"helm.sh/helm/v3/pkg/action"
  7 | 	"helm.sh/helm/v3/pkg/chart/loader"
  8 | 	"helm.sh/helm/v3/pkg/cli"
  9 | 	"helm.sh/helm/v3/pkg/registry"
 10 | 	"helm.sh/helm/v3/pkg/release"
 11 | 	"k8s.io/cli-runtime/pkg/genericclioptions"
 12 | 	"log"
 13 | 	"sigs.k8s.io/yaml"
 14 | 	"time"
 15 | )
 16 | 
 17 | type Kubernetes interface {
 18 | 	genericclioptions.RESTClientGetter
 19 | 	NamespaceOrDefault(namespace string) string
 20 | }
 21 | 
 22 | type Helm struct {
 23 | 	kubernetes Kubernetes
 24 | }
 25 | 
 26 | // NewHelm creates a new Helm instance
 27 | func NewHelm(kubernetes Kubernetes) *Helm {
 28 | 	return &Helm{kubernetes: kubernetes}
 29 | }
 30 | 
 31 | func (h *Helm) Install(ctx context.Context, chart string, values map[string]interface{}, name string, namespace string) (string, error) {
 32 | 	cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false)
 33 | 	if err != nil {
 34 | 		return "", err
 35 | 	}
 36 | 	install := action.NewInstall(cfg)
 37 | 	if name == "" {
 38 | 		install.GenerateName = true
 39 | 		install.ReleaseName, _, _ = install.NameAndChart([]string{chart})
 40 | 	} else {
 41 | 		install.ReleaseName = name
 42 | 	}
 43 | 	install.Namespace = h.kubernetes.NamespaceOrDefault(namespace)
 44 | 	install.Wait = true
 45 | 	install.Timeout = 5 * time.Minute
 46 | 	install.DryRun = false
 47 | 
 48 | 	chartRequested, err := install.LocateChart(chart, cli.New())
 49 | 	if err != nil {
 50 | 		return "", err
 51 | 	}
 52 | 	chartLoaded, err := loader.Load(chartRequested)
 53 | 	if err != nil {
 54 | 		return "", err
 55 | 	}
 56 | 
 57 | 	installedRelease, err := install.RunWithContext(ctx, chartLoaded, values)
 58 | 	if err != nil {
 59 | 		return "", err
 60 | 	}
 61 | 	ret, err := yaml.Marshal(simplify(installedRelease))
 62 | 	if err != nil {
 63 | 		return "", err
 64 | 	}
 65 | 	return string(ret), nil
 66 | }
 67 | 
 68 | // List lists all the releases for the specified namespace (or current namespace if). Or allNamespaces is true, it lists all releases across all namespaces.
 69 | func (h *Helm) List(namespace string, allNamespaces bool) (string, error) {
 70 | 	cfg, err := h.newAction(namespace, allNamespaces)
 71 | 	if err != nil {
 72 | 		return "", err
 73 | 	}
 74 | 	list := action.NewList(cfg)
 75 | 	list.AllNamespaces = allNamespaces
 76 | 	releases, err := list.Run()
 77 | 	if err != nil {
 78 | 		return "", err
 79 | 	} else if len(releases) == 0 {
 80 | 		return "No Helm releases found", nil
 81 | 	}
 82 | 	ret, err := yaml.Marshal(simplify(releases...))
 83 | 	if err != nil {
 84 | 		return "", err
 85 | 	}
 86 | 	return string(ret), nil
 87 | }
 88 | 
 89 | func (h *Helm) Uninstall(name string, namespace string) (string, error) {
 90 | 	cfg, err := h.newAction(h.kubernetes.NamespaceOrDefault(namespace), false)
 91 | 	if err != nil {
 92 | 		return "", err
 93 | 	}
 94 | 	uninstall := action.NewUninstall(cfg)
 95 | 	uninstall.IgnoreNotFound = true
 96 | 	uninstall.Wait = true
 97 | 	uninstall.Timeout = 5 * time.Minute
 98 | 	uninstalledRelease, err := uninstall.Run(name)
 99 | 	if uninstalledRelease == nil && err == nil {
100 | 		return fmt.Sprintf("Release %s not found", name), nil
101 | 	} else if err != nil {
102 | 		return "", err
103 | 	}
104 | 	return fmt.Sprintf("Uninstalled release %s %s", uninstalledRelease.Release.Name, uninstalledRelease.Info), nil
105 | }
106 | 
107 | func (h *Helm) newAction(namespace string, allNamespaces bool) (*action.Configuration, error) {
108 | 	cfg := new(action.Configuration)
109 | 	applicableNamespace := ""
110 | 	if !allNamespaces {
111 | 		applicableNamespace = h.kubernetes.NamespaceOrDefault(namespace)
112 | 	}
113 | 	registryClient, err := registry.NewClient()
114 | 	if err != nil {
115 | 		return nil, err
116 | 	}
117 | 	cfg.RegistryClient = registryClient
118 | 	return cfg, cfg.Init(h.kubernetes, applicableNamespace, "", log.Printf)
119 | }
120 | 
121 | func simplify(release ...*release.Release) []map[string]interface{} {
122 | 	ret := make([]map[string]interface{}, len(release))
123 | 	for i, r := range release {
124 | 		ret[i] = map[string]interface{}{
125 | 			"name":      r.Name,
126 | 			"namespace": r.Namespace,
127 | 			"revision":  r.Version,
128 | 		}
129 | 		if r.Chart != nil {
130 | 			ret[i]["chart"] = r.Chart.Metadata.Name
131 | 			ret[i]["chartVersion"] = r.Chart.Metadata.Version
132 | 			ret[i]["appVersion"] = r.Chart.Metadata.AppVersion
133 | 		}
134 | 		if r.Info != nil {
135 | 			ret[i]["status"] = r.Info.Status.String()
136 | 			if !r.Info.LastDeployed.IsZero() {
137 | 				ret[i]["lastDeployed"] = r.Info.LastDeployed.Format(time.RFC1123Z)
138 | 			}
139 | 		}
140 | 	}
141 | 	return ret
142 | }
143 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/mcp_tools_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 
  6 | 	"github.com/BurntSushi/toml"
  7 | 	"github.com/mark3labs/mcp-go/mcp"
  8 | 	"github.com/stretchr/testify/suite"
  9 | 	"k8s.io/utils/ptr"
 10 | )
 11 | 
 12 | // McpToolProcessingSuite tests MCP tool processing (isToolApplicable)
 13 | type McpToolProcessingSuite struct {
 14 | 	BaseMcpSuite
 15 | }
 16 | 
 17 | func (s *McpToolProcessingSuite) TestUnrestricted() {
 18 | 	s.InitMcpClient()
 19 | 
 20 | 	tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 21 | 	s.Require().NotNil(tools)
 22 | 
 23 | 	s.Run("ListTools returns tools", func() {
 24 | 		s.NoError(err, "call ListTools failed")
 25 | 		s.NotNilf(tools, "list tools failed")
 26 | 	})
 27 | 
 28 | 	s.Run("Destructive tools ARE NOT read only", func() {
 29 | 		for _, tool := range tools.Tools {
 30 | 			readOnly := ptr.Deref(tool.Annotations.ReadOnlyHint, false)
 31 | 			destructive := ptr.Deref(tool.Annotations.DestructiveHint, false)
 32 | 			s.Falsef(readOnly && destructive, "Tool %s is read-only and destructive, which is not allowed", tool.Name)
 33 | 		}
 34 | 	})
 35 | }
 36 | 
 37 | func (s *McpToolProcessingSuite) TestReadOnly() {
 38 | 	s.Require().NoError(toml.Unmarshal([]byte(`
 39 | 		read_only = true
 40 | 	`), s.Cfg), "Expected to parse read only server config")
 41 | 	s.InitMcpClient()
 42 | 
 43 | 	tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 44 | 	s.Require().NotNil(tools)
 45 | 
 46 | 	s.Run("ListTools returns tools", func() {
 47 | 		s.NoError(err, "call ListTools failed")
 48 | 		s.NotNilf(tools, "list tools failed")
 49 | 	})
 50 | 
 51 | 	s.Run("ListTools returns only read-only tools", func() {
 52 | 		for _, tool := range tools.Tools {
 53 | 			s.Falsef(tool.Annotations.ReadOnlyHint == nil || !*tool.Annotations.ReadOnlyHint,
 54 | 				"Tool %s is not read-only but should be", tool.Name)
 55 | 			s.Falsef(tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint,
 56 | 				"Tool %s is destructive but should not be in read-only mode", tool.Name)
 57 | 		}
 58 | 	})
 59 | }
 60 | 
 61 | func (s *McpToolProcessingSuite) TestDisableDestructive() {
 62 | 	s.Require().NoError(toml.Unmarshal([]byte(`
 63 | 		disable_destructive = true
 64 | 	`), s.Cfg), "Expected to parse disable destructive server config")
 65 | 	s.InitMcpClient()
 66 | 
 67 | 	tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 68 | 	s.Require().NotNil(tools)
 69 | 
 70 | 	s.Run("ListTools returns tools", func() {
 71 | 		s.NoError(err, "call ListTools failed")
 72 | 		s.NotNilf(tools, "list tools failed")
 73 | 	})
 74 | 
 75 | 	s.Run("ListTools does not return destructive tools", func() {
 76 | 		for _, tool := range tools.Tools {
 77 | 			s.Falsef(tool.Annotations.DestructiveHint != nil && *tool.Annotations.DestructiveHint,
 78 | 				"Tool %s is destructive but should not be in disable_destructive mode", tool.Name)
 79 | 		}
 80 | 	})
 81 | }
 82 | 
 83 | func (s *McpToolProcessingSuite) TestEnabledTools() {
 84 | 	s.Require().NoError(toml.Unmarshal([]byte(`
 85 | 		enabled_tools = [ "namespaces_list", "events_list" ]
 86 | 	`), s.Cfg), "Expected to parse enabled tools server config")
 87 | 	s.InitMcpClient()
 88 | 
 89 | 	tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 90 | 	s.Require().NotNil(tools)
 91 | 
 92 | 	s.Run("ListTools returns tools", func() {
 93 | 		s.NoError(err, "call ListTools failed")
 94 | 		s.NotNilf(tools, "list tools failed")
 95 | 	})
 96 | 
 97 | 	s.Run("ListTools returns only explicitly enabled tools", func() {
 98 | 		s.Len(tools.Tools, 2, "ListTools should return exactly 2 tools")
 99 | 		for _, tool := range tools.Tools {
100 | 			s.Falsef(tool.Name != "namespaces_list" && tool.Name != "events_list",
101 | 				"Tool %s is not enabled but should be", tool.Name)
102 | 		}
103 | 	})
104 | }
105 | 
106 | func (s *McpToolProcessingSuite) TestDisabledTools() {
107 | 	s.Require().NoError(toml.Unmarshal([]byte(`
108 | 		disabled_tools = [ "namespaces_list", "events_list" ]
109 | 	`), s.Cfg), "Expected to parse disabled tools server config")
110 | 	s.InitMcpClient()
111 | 
112 | 	tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
113 | 	s.Require().NotNil(tools)
114 | 
115 | 	s.Run("ListTools returns tools", func() {
116 | 		s.NoError(err, "call ListTools failed")
117 | 		s.NotNilf(tools, "list tools failed")
118 | 	})
119 | 
120 | 	s.Run("ListTools does not return disabled tools", func() {
121 | 		for _, tool := range tools.Tools {
122 | 			s.Falsef(tool.Name == "namespaces_list" || tool.Name == "events_list",
123 | 				"Tool %s is not disabled but should be", tool.Name)
124 | 		}
125 | 	})
126 | }
127 | 
128 | func TestMcpToolProcessing(t *testing.T) {
129 | 	suite.Run(t, new(McpToolProcessingSuite))
130 | }
131 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/pods_exec_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"io"
  6 | 	"net/http"
  7 | 	"strings"
  8 | 	"testing"
  9 | 
 10 | 	"github.com/BurntSushi/toml"
 11 | 	"github.com/mark3labs/mcp-go/mcp"
 12 | 	"github.com/stretchr/testify/suite"
 13 | 	v1 "k8s.io/api/core/v1"
 14 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 15 | 
 16 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 17 | )
 18 | 
 19 | type PodsExecSuite struct {
 20 | 	BaseMcpSuite
 21 | 	mockServer *test.MockServer
 22 | }
 23 | 
 24 | func (s *PodsExecSuite) SetupTest() {
 25 | 	s.BaseMcpSuite.SetupTest()
 26 | 	s.mockServer = test.NewMockServer()
 27 | 	s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
 28 | }
 29 | 
 30 | func (s *PodsExecSuite) TearDownTest() {
 31 | 	s.BaseMcpSuite.TearDownTest()
 32 | 	if s.mockServer != nil {
 33 | 		s.mockServer.Close()
 34 | 	}
 35 | }
 36 | 
 37 | func (s *PodsExecSuite) TestPodsExec() {
 38 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 39 | 		if req.URL.Path != "/api/v1/namespaces/default/pods/pod-to-exec/exec" {
 40 | 			return
 41 | 		}
 42 | 		var stdin, stdout bytes.Buffer
 43 | 		ctx, err := test.CreateHTTPStreams(w, req, &test.StreamOptions{
 44 | 			Stdin:  &stdin,
 45 | 			Stdout: &stdout,
 46 | 		})
 47 | 		if err != nil {
 48 | 			w.WriteHeader(http.StatusInternalServerError)
 49 | 			_, _ = w.Write([]byte(err.Error()))
 50 | 			return
 51 | 		}
 52 | 		defer func(conn io.Closer) { _ = conn.Close() }(ctx.Closer)
 53 | 		_, _ = io.WriteString(ctx.StdoutStream, "command:"+strings.Join(req.URL.Query()["command"], " ")+"\n")
 54 | 		_, _ = io.WriteString(ctx.StdoutStream, "container:"+strings.Join(req.URL.Query()["container"], " ")+"\n")
 55 | 	}))
 56 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 57 | 		if req.URL.Path != "/api/v1/namespaces/default/pods/pod-to-exec" {
 58 | 			return
 59 | 		}
 60 | 		test.WriteObject(w, &v1.Pod{
 61 | 			ObjectMeta: metav1.ObjectMeta{
 62 | 				Namespace: "default",
 63 | 				Name:      "pod-to-exec",
 64 | 			},
 65 | 			Spec: v1.PodSpec{Containers: []v1.Container{{Name: "container-to-exec"}}},
 66 | 		})
 67 | 	}))
 68 | 	s.InitMcpClient()
 69 | 
 70 | 	s.Run("pods_exec(name=pod-to-exec, namespace=nil, command=[ls -l]), uses configured namespace", func() {
 71 | 		result, err := s.CallTool("pods_exec", map[string]interface{}{
 72 | 			"name":    "pod-to-exec",
 73 | 			"command": []interface{}{"ls", "-l"},
 74 | 		})
 75 | 		s.Require().NotNil(result)
 76 | 		s.Run("returns command output", func() {
 77 | 			s.NoError(err, "call tool failed %v", err)
 78 | 			s.Falsef(result.IsError, "call tool failed: %v", result.Content)
 79 | 			s.Contains(result.Content[0].(mcp.TextContent).Text, "command:ls -l\n", "unexpected result %v", result.Content[0].(mcp.TextContent).Text)
 80 | 		})
 81 | 	})
 82 | 	s.Run("pods_exec(name=pod-to-exec, namespace=default, command=[ls -l])", func() {
 83 | 		result, err := s.CallTool("pods_exec", map[string]interface{}{
 84 | 			"namespace": "default",
 85 | 			"name":      "pod-to-exec",
 86 | 			"command":   []interface{}{"ls", "-l"},
 87 | 		})
 88 | 		s.Require().NotNil(result)
 89 | 		s.Run("returns command output", func() {
 90 | 			s.NoError(err, "call tool failed %v", err)
 91 | 			s.Falsef(result.IsError, "call tool failed: %v", result.Content)
 92 | 			s.Contains(result.Content[0].(mcp.TextContent).Text, "command:ls -l\n", "unexpected result %v", result.Content[0].(mcp.TextContent).Text)
 93 | 		})
 94 | 	})
 95 | 	s.Run("pods_exec(name=pod-to-exec, namespace=default, command=[ls -l], container=a-specific-container)", func() {
 96 | 		result, err := s.CallTool("pods_exec", map[string]interface{}{
 97 | 			"namespace": "default",
 98 | 			"name":      "pod-to-exec",
 99 | 			"command":   []interface{}{"ls", "-l"},
100 | 			"container": "a-specific-container",
101 | 		})
102 | 		s.Require().NotNil(result)
103 | 		s.Run("returns command output", func() {
104 | 			s.NoError(err, "call tool failed %v", err)
105 | 			s.Falsef(result.IsError, "call tool failed: %v", result.Content)
106 | 			s.Contains(result.Content[0].(mcp.TextContent).Text, "command:ls -l\n", "unexpected result %v", result.Content[0].(mcp.TextContent).Text)
107 | 		})
108 | 	})
109 | }
110 | 
111 | func (s *PodsExecSuite) TestPodsExecDenied() {
112 | 	s.Require().NoError(toml.Unmarshal([]byte(`
113 | 		denied_resources = [ { version = "v1", kind = "Pod" } ]
114 | 	`), s.Cfg), "Expected to parse denied resources config")
115 | 	s.InitMcpClient()
116 | 	s.Run("pods_exec (denied)", func() {
117 | 		toolResult, err := s.CallTool("pods_exec", map[string]interface{}{
118 | 			"namespace": "default",
119 | 			"name":      "pod-to-exec",
120 | 			"command":   []interface{}{"ls", "-l"},
121 | 			"container": "a-specific-container",
122 | 		})
123 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
124 | 		s.Run("has error", func() {
125 | 			s.Truef(toolResult.IsError, "call tool should fail")
126 | 			s.Nilf(err, "call tool should not return error object")
127 | 		})
128 | 		s.Run("describes denial", func() {
129 | 			expectedMessage := "failed to exec in pod pod-to-exec in namespace default: resource not allowed: /v1, Kind=Pod"
130 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
131 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
132 | 		})
133 | 	})
134 | }
135 | 
136 | func TestPodsExec(t *testing.T) {
137 | 	suite.Run(t, new(PodsExecSuite))
138 | }
139 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/events_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"strings"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/BurntSushi/toml"
  8 | 	"github.com/mark3labs/mcp-go/mcp"
  9 | 	"github.com/stretchr/testify/suite"
 10 | 	v1 "k8s.io/api/core/v1"
 11 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 12 | 	"k8s.io/client-go/kubernetes"
 13 | 	"sigs.k8s.io/yaml"
 14 | )
 15 | 
 16 | type EventsSuite struct {
 17 | 	BaseMcpSuite
 18 | }
 19 | 
 20 | func (s *EventsSuite) TestEventsList() {
 21 | 	s.InitMcpClient()
 22 | 	s.Run("events_list (no events)", func() {
 23 | 		toolResult, err := s.CallTool("events_list", map[string]interface{}{})
 24 | 		s.Run("no error", func() {
 25 | 			s.Nilf(err, "call tool failed %v", err)
 26 | 			s.Falsef(toolResult.IsError, "call tool failed")
 27 | 		})
 28 | 		s.Run("returns no events message", func() {
 29 | 			s.Equal("# No events found", toolResult.Content[0].(mcp.TextContent).Text)
 30 | 		})
 31 | 	})
 32 | 	s.Run("events_list (with events)", func() {
 33 | 		client := kubernetes.NewForConfigOrDie(envTestRestConfig)
 34 | 		for _, ns := range []string{"default", "ns-1"} {
 35 | 			_, _ = client.CoreV1().Events(ns).Create(s.T().Context(), &v1.Event{
 36 | 				ObjectMeta: metav1.ObjectMeta{
 37 | 					Name: "an-event-in-" + ns,
 38 | 				},
 39 | 				InvolvedObject: v1.ObjectReference{
 40 | 					APIVersion: "v1",
 41 | 					Kind:       "Pod",
 42 | 					Name:       "a-pod",
 43 | 					Namespace:  ns,
 44 | 				},
 45 | 				Type:    "Normal",
 46 | 				Message: "The event message",
 47 | 			}, metav1.CreateOptions{})
 48 | 		}
 49 | 		s.Run("events_list()", func() {
 50 | 			toolResult, err := s.CallTool("events_list", map[string]interface{}{})
 51 | 			s.Run("no error", func() {
 52 | 				s.Nilf(err, "call tool failed %v", err)
 53 | 				s.Falsef(toolResult.IsError, "call tool failed")
 54 | 			})
 55 | 			s.Run("has yaml comment indicating output format", func() {
 56 | 				s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "# The following events (YAML format) were found:\n"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
 57 | 			})
 58 | 			var decoded []v1.Event
 59 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
 60 | 			s.Run("has yaml content", func() {
 61 | 				s.Nilf(err, "unmarshal failed %v", err)
 62 | 			})
 63 | 			s.Run("returns all events", func() {
 64 | 				s.YAMLEqf(""+
 65 | 					"- InvolvedObject:\n"+
 66 | 					"    Kind: Pod\n"+
 67 | 					"    Name: a-pod\n"+
 68 | 					"    apiVersion: v1\n"+
 69 | 					"  Message: The event message\n"+
 70 | 					"  Namespace: default\n"+
 71 | 					"  Reason: \"\"\n"+
 72 | 					"  Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
 73 | 					"  Type: Normal\n"+
 74 | 					"- InvolvedObject:\n"+
 75 | 					"    Kind: Pod\n"+
 76 | 					"    Name: a-pod\n"+
 77 | 					"    apiVersion: v1\n"+
 78 | 					"  Message: The event message\n"+
 79 | 					"  Namespace: ns-1\n"+
 80 | 					"  Reason: \"\"\n"+
 81 | 					"  Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
 82 | 					"  Type: Normal\n",
 83 | 					toolResult.Content[0].(mcp.TextContent).Text,
 84 | 					"unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
 85 | 
 86 | 			})
 87 | 		})
 88 | 		s.Run("events_list(namespace=ns-1)", func() {
 89 | 			toolResult, err := s.CallTool("events_list", map[string]interface{}{
 90 | 				"namespace": "ns-1",
 91 | 			})
 92 | 			s.Run("no error", func() {
 93 | 				s.Nilf(err, "call tool failed %v", err)
 94 | 				s.Falsef(toolResult.IsError, "call tool failed")
 95 | 			})
 96 | 			s.Run("has yaml comment indicating output format", func() {
 97 | 				s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "# The following events (YAML format) were found:\n"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
 98 | 			})
 99 | 			var decoded []v1.Event
100 | 			err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
101 | 			s.Run("has yaml content", func() {
102 | 				s.Nilf(err, "unmarshal failed %v", err)
103 | 			})
104 | 			s.Run("returns events from namespace", func() {
105 | 				s.YAMLEqf(""+
106 | 					"- InvolvedObject:\n"+
107 | 					"    Kind: Pod\n"+
108 | 					"    Name: a-pod\n"+
109 | 					"    apiVersion: v1\n"+
110 | 					"  Message: The event message\n"+
111 | 					"  Namespace: ns-1\n"+
112 | 					"  Reason: \"\"\n"+
113 | 					"  Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
114 | 					"  Type: Normal\n",
115 | 					toolResult.Content[0].(mcp.TextContent).Text,
116 | 					"unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
117 | 			})
118 | 		})
119 | 	})
120 | }
121 | 
122 | func (s *EventsSuite) TestEventsListDenied() {
123 | 	s.Require().NoError(toml.Unmarshal([]byte(`
124 | 		denied_resources = [ { version = "v1", kind = "Event" } ]
125 | 	`), s.Cfg), "Expected to parse denied resources config")
126 | 	s.InitMcpClient()
127 | 	s.Run("events_list (denied)", func() {
128 | 		toolResult, err := s.CallTool("events_list", map[string]interface{}{})
129 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
130 | 		s.Run("has error", func() {
131 | 			s.Truef(toolResult.IsError, "call tool should fail")
132 | 			s.Nilf(err, "call tool should not return error object")
133 | 		})
134 | 		s.Run("describes denial", func() {
135 | 			expectedMessage := "failed to list events in all namespaces: resource not allowed: /v1, Kind=Event"
136 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
137 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
138 | 		})
139 | 	})
140 | }
141 | 
142 | func TestEvents(t *testing.T) {
143 | 	suite.Run(t, new(EventsSuite))
144 | }
145 | 
```

--------------------------------------------------------------------------------
/pkg/config/config.go:
--------------------------------------------------------------------------------

```go
  1 | package config
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"fmt"
  6 | 	"os"
  7 | 
  8 | 	"github.com/BurntSushi/toml"
  9 | )
 10 | 
 11 | const (
 12 | 	ClusterProviderKubeConfig = "kubeconfig"
 13 | 	ClusterProviderInCluster  = "in-cluster"
 14 | 	ClusterProviderDisabled   = "disabled"
 15 | )
 16 | 
 17 | // StaticConfig is the configuration for the server.
 18 | // It allows to configure server specific settings and tools to be enabled or disabled.
 19 | type StaticConfig struct {
 20 | 	DeniedResources []GroupVersionKind `toml:"denied_resources"`
 21 | 
 22 | 	LogLevel   int    `toml:"log_level,omitzero"`
 23 | 	Port       string `toml:"port,omitempty"`
 24 | 	SSEBaseURL string `toml:"sse_base_url,omitempty"`
 25 | 	KubeConfig string `toml:"kubeconfig,omitempty"`
 26 | 	ListOutput string `toml:"list_output,omitempty"`
 27 | 	// When true, expose only tools annotated with readOnlyHint=true
 28 | 	ReadOnly bool `toml:"read_only,omitempty"`
 29 | 	// When true, disable tools annotated with destructiveHint=true
 30 | 	DisableDestructive bool     `toml:"disable_destructive,omitempty"`
 31 | 	Toolsets           []string `toml:"toolsets,omitempty"`
 32 | 	EnabledTools       []string `toml:"enabled_tools,omitempty"`
 33 | 	DisabledTools      []string `toml:"disabled_tools,omitempty"`
 34 | 
 35 | 	// Authorization-related fields
 36 | 	// RequireOAuth indicates whether the server requires OAuth for authentication.
 37 | 	RequireOAuth bool `toml:"require_oauth,omitempty"`
 38 | 	// OAuthAudience is the valid audience for the OAuth tokens, used for offline JWT claim validation.
 39 | 	OAuthAudience string `toml:"oauth_audience,omitempty"`
 40 | 	// ValidateToken indicates whether the server should validate the token against the Kubernetes API Server using TokenReview.
 41 | 	ValidateToken bool `toml:"validate_token,omitempty"`
 42 | 	// AuthorizationURL is the URL of the OIDC authorization server.
 43 | 	// It is used for token validation and for STS token exchange.
 44 | 	AuthorizationURL string `toml:"authorization_url,omitempty"`
 45 | 	// DisableDynamicClientRegistration indicates whether dynamic client registration is disabled.
 46 | 	// If true, the .well-known endpoints will not expose the registration endpoint.
 47 | 	DisableDynamicClientRegistration bool `toml:"disable_dynamic_client_registration,omitempty"`
 48 | 	// OAuthScopes are the supported **client** scopes requested during the **client/frontend** OAuth flow.
 49 | 	OAuthScopes []string `toml:"oauth_scopes,omitempty"`
 50 | 	// StsClientId is the OAuth client ID used for backend token exchange
 51 | 	StsClientId string `toml:"sts_client_id,omitempty"`
 52 | 	// StsClientSecret is the OAuth client secret used for backend token exchange
 53 | 	StsClientSecret string `toml:"sts_client_secret,omitempty"`
 54 | 	// StsAudience is the audience for the STS token exchange.
 55 | 	StsAudience string `toml:"sts_audience,omitempty"`
 56 | 	// StsScopes is the scopes for the STS token exchange.
 57 | 	StsScopes            []string `toml:"sts_scopes,omitempty"`
 58 | 	CertificateAuthority string   `toml:"certificate_authority,omitempty"`
 59 | 	ServerURL            string   `toml:"server_url,omitempty"`
 60 | 	// ClusterProviderStrategy is how the server finds clusters.
 61 | 	// If set to "kubeconfig", the clusters will be loaded from those in the kubeconfig.
 62 | 	// If set to "in-cluster", the server will use the in cluster config
 63 | 	ClusterProviderStrategy string `toml:"cluster_provider_strategy,omitempty"`
 64 | 
 65 | 	// ClusterProvider-specific configurations
 66 | 	// This map holds raw TOML primitives that will be parsed by registered provider parsers
 67 | 	ClusterProviderConfigs map[string]toml.Primitive `toml:"cluster_provider_configs,omitempty"`
 68 | 
 69 | 	// Internal: parsed provider configs (not exposed to TOML package)
 70 | 	parsedClusterProviderConfigs map[string]ProviderConfig
 71 | }
 72 | 
 73 | type GroupVersionKind struct {
 74 | 	Group   string `toml:"group"`
 75 | 	Version string `toml:"version"`
 76 | 	Kind    string `toml:"kind,omitempty"`
 77 | }
 78 | 
 79 | // Read reads the toml file and returns the StaticConfig.
 80 | func Read(configPath string) (*StaticConfig, error) {
 81 | 	configData, err := os.ReadFile(configPath)
 82 | 	if err != nil {
 83 | 		return nil, err
 84 | 	}
 85 | 	return ReadToml(configData)
 86 | }
 87 | 
 88 | // ReadToml reads the toml data and returns the StaticConfig.
 89 | func ReadToml(configData []byte) (*StaticConfig, error) {
 90 | 	config := Default()
 91 | 	md, err := toml.NewDecoder(bytes.NewReader(configData)).Decode(config)
 92 | 	if err != nil {
 93 | 		return nil, err
 94 | 	}
 95 | 
 96 | 	if err := config.parseClusterProviderConfigs(md); err != nil {
 97 | 		return nil, err
 98 | 	}
 99 | 
100 | 	return config, nil
101 | }
102 | 
103 | func (c *StaticConfig) GetProviderConfig(strategy string) (ProviderConfig, bool) {
104 | 	config, ok := c.parsedClusterProviderConfigs[strategy]
105 | 
106 | 	return config, ok
107 | }
108 | 
109 | func (c *StaticConfig) parseClusterProviderConfigs(md toml.MetaData) error {
110 | 	if c.parsedClusterProviderConfigs == nil {
111 | 		c.parsedClusterProviderConfigs = make(map[string]ProviderConfig, len(c.ClusterProviderConfigs))
112 | 	}
113 | 
114 | 	for strategy, primitive := range c.ClusterProviderConfigs {
115 | 		parser, ok := getProviderConfigParser(strategy)
116 | 		if !ok {
117 | 			continue
118 | 		}
119 | 
120 | 		providerConfig, err := parser(primitive, md)
121 | 		if err != nil {
122 | 			return fmt.Errorf("failed to parse config for ClusterProvider '%s': %w", strategy, err)
123 | 		}
124 | 
125 | 		if err := providerConfig.Validate(); err != nil {
126 | 			return fmt.Errorf("invalid config file for ClusterProvider '%s': %w", strategy, err)
127 | 		}
128 | 
129 | 		c.parsedClusterProviderConfigs[strategy] = providerConfig
130 | 	}
131 | 
132 | 	return nil
133 | }
134 | 
```

--------------------------------------------------------------------------------
/pkg/toolsets/helm/helm.go:
--------------------------------------------------------------------------------

```go
  1 | package helm
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 
  6 | 	"github.com/google/jsonschema-go/jsonschema"
  7 | 	"k8s.io/utils/ptr"
  8 | 
  9 | 	"github.com/containers/kubernetes-mcp-server/pkg/api"
 10 | )
 11 | 
 12 | func initHelm() []api.ServerTool {
 13 | 	return []api.ServerTool{
 14 | 		{Tool: api.Tool{
 15 | 			Name:        "helm_install",
 16 | 			Description: "Install a Helm chart in the current or provided namespace",
 17 | 			InputSchema: &jsonschema.Schema{
 18 | 				Type: "object",
 19 | 				Properties: map[string]*jsonschema.Schema{
 20 | 					"chart": {
 21 | 						Type:        "string",
 22 | 						Description: "Chart reference to install (for example: stable/grafana, oci://ghcr.io/nginxinc/charts/nginx-ingress)",
 23 | 					},
 24 | 					"values": {
 25 | 						Type:        "object",
 26 | 						Description: "Values to pass to the Helm chart (Optional)",
 27 | 						Properties:  make(map[string]*jsonschema.Schema),
 28 | 					},
 29 | 					"name": {
 30 | 						Type:        "string",
 31 | 						Description: "Name of the Helm release (Optional, random name if not provided)",
 32 | 					},
 33 | 					"namespace": {
 34 | 						Type:        "string",
 35 | 						Description: "Namespace to install the Helm chart in (Optional, current namespace if not provided)",
 36 | 					},
 37 | 				},
 38 | 				Required: []string{"chart"},
 39 | 			},
 40 | 			Annotations: api.ToolAnnotations{
 41 | 				Title:           "Helm: Install",
 42 | 				ReadOnlyHint:    ptr.To(false),
 43 | 				DestructiveHint: ptr.To(false),
 44 | 				IdempotentHint:  ptr.To(false), // TODO: consider replacing implementation with equivalent to: helm upgrade --install
 45 | 				OpenWorldHint:   ptr.To(true),
 46 | 			},
 47 | 		}, Handler: helmInstall},
 48 | 		{Tool: api.Tool{
 49 | 			Name:        "helm_list",
 50 | 			Description: "List all the Helm releases in the current or provided namespace (or in all namespaces if specified)",
 51 | 			InputSchema: &jsonschema.Schema{
 52 | 				Type: "object",
 53 | 				Properties: map[string]*jsonschema.Schema{
 54 | 					"namespace": {
 55 | 						Type:        "string",
 56 | 						Description: "Namespace to list Helm releases from (Optional, all namespaces if not provided)",
 57 | 					},
 58 | 					"all_namespaces": {
 59 | 						Type:        "boolean",
 60 | 						Description: "If true, lists all Helm releases in all namespaces ignoring the namespace argument (Optional)",
 61 | 					},
 62 | 				},
 63 | 			},
 64 | 			Annotations: api.ToolAnnotations{
 65 | 				Title:           "Helm: List",
 66 | 				ReadOnlyHint:    ptr.To(true),
 67 | 				DestructiveHint: ptr.To(false),
 68 | 				IdempotentHint:  ptr.To(false),
 69 | 				OpenWorldHint:   ptr.To(true),
 70 | 			},
 71 | 		}, Handler: helmList},
 72 | 		{Tool: api.Tool{
 73 | 			Name:        "helm_uninstall",
 74 | 			Description: "Uninstall a Helm release in the current or provided namespace",
 75 | 			InputSchema: &jsonschema.Schema{
 76 | 				Type: "object",
 77 | 				Properties: map[string]*jsonschema.Schema{
 78 | 					"name": {
 79 | 						Type:        "string",
 80 | 						Description: "Name of the Helm release to uninstall",
 81 | 					},
 82 | 					"namespace": {
 83 | 						Type:        "string",
 84 | 						Description: "Namespace to uninstall the Helm release from (Optional, current namespace if not provided)",
 85 | 					},
 86 | 				},
 87 | 				Required: []string{"name"},
 88 | 			},
 89 | 			Annotations: api.ToolAnnotations{
 90 | 				Title:           "Helm: Uninstall",
 91 | 				ReadOnlyHint:    ptr.To(false),
 92 | 				DestructiveHint: ptr.To(true),
 93 | 				IdempotentHint:  ptr.To(true),
 94 | 				OpenWorldHint:   ptr.To(true),
 95 | 			},
 96 | 		}, Handler: helmUninstall},
 97 | 	}
 98 | }
 99 | 
100 | func helmInstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
101 | 	var chart string
102 | 	ok := false
103 | 	if chart, ok = params.GetArguments()["chart"].(string); !ok {
104 | 		return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart, missing argument chart")), nil
105 | 	}
106 | 	values := map[string]interface{}{}
107 | 	if v, ok := params.GetArguments()["values"].(map[string]interface{}); ok {
108 | 		values = v
109 | 	}
110 | 	name := ""
111 | 	if v, ok := params.GetArguments()["name"].(string); ok {
112 | 		name = v
113 | 	}
114 | 	namespace := ""
115 | 	if v, ok := params.GetArguments()["namespace"].(string); ok {
116 | 		namespace = v
117 | 	}
118 | 	ret, err := params.NewHelm().Install(params, chart, values, name, namespace)
119 | 	if err != nil {
120 | 		return api.NewToolCallResult("", fmt.Errorf("failed to install helm chart '%s': %w", chart, err)), nil
121 | 	}
122 | 	return api.NewToolCallResult(ret, err), nil
123 | }
124 | 
125 | func helmList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
126 | 	allNamespaces := false
127 | 	if v, ok := params.GetArguments()["all_namespaces"].(bool); ok {
128 | 		allNamespaces = v
129 | 	}
130 | 	namespace := ""
131 | 	if v, ok := params.GetArguments()["namespace"].(string); ok {
132 | 		namespace = v
133 | 	}
134 | 	ret, err := params.NewHelm().List(namespace, allNamespaces)
135 | 	if err != nil {
136 | 		return api.NewToolCallResult("", fmt.Errorf("failed to list helm releases in namespace '%s': %w", namespace, err)), nil
137 | 	}
138 | 	return api.NewToolCallResult(ret, err), nil
139 | }
140 | 
141 | func helmUninstall(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
142 | 	var name string
143 | 	ok := false
144 | 	if name, ok = params.GetArguments()["name"].(string); !ok {
145 | 		return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart, missing argument name")), nil
146 | 	}
147 | 	namespace := ""
148 | 	if v, ok := params.GetArguments()["namespace"].(string); ok {
149 | 		namespace = v
150 | 	}
151 | 	ret, err := params.NewHelm().Uninstall(name, namespace)
152 | 	if err != nil {
153 | 		return api.NewToolCallResult("", fmt.Errorf("failed to uninstall helm chart '%s': %w", name, err)), nil
154 | 	}
155 | 	return api.NewToolCallResult(ret, err), nil
156 | }
157 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/mcp_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"net/http"
  6 | 	"os"
  7 | 	"path/filepath"
  8 | 	"runtime"
  9 | 	"testing"
 10 | 	"time"
 11 | 
 12 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 13 | 	"github.com/mark3labs/mcp-go/client"
 14 | 	"github.com/mark3labs/mcp-go/mcp"
 15 | )
 16 | 
 17 | func TestWatchKubeConfig(t *testing.T) {
 18 | 	if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
 19 | 		t.Skip("Skipping test on non-Unix-like platforms")
 20 | 	}
 21 | 	testCase(t, func(c *mcpContext) {
 22 | 		// Given
 23 | 		withTimeout, cancel := context.WithTimeout(c.ctx, 5*time.Second)
 24 | 		defer cancel()
 25 | 		var notification *mcp.JSONRPCNotification
 26 | 		c.mcpClient.OnNotification(func(n mcp.JSONRPCNotification) {
 27 | 			notification = &n
 28 | 		})
 29 | 		// When
 30 | 		f, _ := os.OpenFile(filepath.Join(c.tempDir, "config"), os.O_APPEND|os.O_WRONLY, 0644)
 31 | 		_, _ = f.WriteString("\n")
 32 | 		for notification == nil {
 33 | 			select {
 34 | 			case <-withTimeout.Done():
 35 | 			default:
 36 | 				time.Sleep(100 * time.Millisecond)
 37 | 			}
 38 | 		}
 39 | 		// Then
 40 | 		t.Run("WatchKubeConfig notifies tools change", func(t *testing.T) {
 41 | 			if notification == nil {
 42 | 				t.Fatalf("WatchKubeConfig did not notify")
 43 | 			}
 44 | 			if notification.Method != "notifications/tools/list_changed" {
 45 | 				t.Fatalf("WatchKubeConfig did not notify tools change, got %s", notification.Method)
 46 | 			}
 47 | 		})
 48 | 	})
 49 | }
 50 | 
 51 | func TestSseHeaders(t *testing.T) {
 52 | 	mockServer := test.NewMockServer()
 53 | 	defer mockServer.Close()
 54 | 	before := func(c *mcpContext) {
 55 | 		c.withKubeConfig(mockServer.Config())
 56 | 		c.clientOptions = append(c.clientOptions, client.WithHeaders(map[string]string{"kubernetes-authorization": "Bearer a-token-from-mcp-client"}))
 57 | 	}
 58 | 	pathHeaders := make(map[string]http.Header, 0)
 59 | 	mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 60 | 		pathHeaders[req.URL.Path] = req.Header.Clone()
 61 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
 62 | 		if req.URL.Path == "/api" {
 63 | 			w.Header().Set("Content-Type", "application/json")
 64 | 			_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["v1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
 65 | 			return
 66 | 		}
 67 | 		// Request Performed by DiscoveryClient to Kube API (Get API Groups)
 68 | 		if req.URL.Path == "/apis" {
 69 | 			w.Header().Set("Content-Type", "application/json")
 70 | 			//w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[{"name":"apps","versions":[{"groupVersion":"apps/v1","version":"v1"}],"preferredVersion":{"groupVersion":"apps/v1","version":"v1"}}]}`))
 71 | 			_, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
 72 | 			return
 73 | 		}
 74 | 		// Request Performed by DiscoveryClient to Kube API (Get API Resources)
 75 | 		if req.URL.Path == "/api/v1" {
 76 | 			w.Header().Set("Content-Type", "application/json")
 77 | 			_, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"Pod","verbs":["get","list","watch","create","update","patch","delete"]}]}`))
 78 | 			return
 79 | 		}
 80 | 		// Request Performed by DynamicClient
 81 | 		if req.URL.Path == "/api/v1/namespaces/default/pods" {
 82 | 			w.Header().Set("Content-Type", "application/json")
 83 | 			_, _ = w.Write([]byte(`{"kind":"PodList","apiVersion":"v1","items":[]}`))
 84 | 			return
 85 | 		}
 86 | 		// Request Performed by kubernetes.Interface
 87 | 		if req.URL.Path == "/api/v1/namespaces/default/pods/a-pod-to-delete" {
 88 | 			w.WriteHeader(200)
 89 | 			return
 90 | 		}
 91 | 		w.WriteHeader(404)
 92 | 	}))
 93 | 	testCaseWithContext(t, &mcpContext{before: before}, func(c *mcpContext) {
 94 | 		_, _ = c.callTool("pods_list", map[string]interface{}{})
 95 | 		t.Run("DiscoveryClient propagates headers to Kube API", func(t *testing.T) {
 96 | 			if len(pathHeaders) == 0 {
 97 | 				t.Fatalf("No requests were made to Kube API")
 98 | 			}
 99 | 			if pathHeaders["/api"] == nil || pathHeaders["/api"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
100 | 				t.Fatalf("Overridden header Authorization not found in request to /api")
101 | 			}
102 | 			if pathHeaders["/apis"] == nil || pathHeaders["/apis"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
103 | 				t.Fatalf("Overridden header Authorization not found in request to /apis")
104 | 			}
105 | 			if pathHeaders["/api/v1"] == nil || pathHeaders["/api/v1"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
106 | 				t.Fatalf("Overridden header Authorization not found in request to /api/v1")
107 | 			}
108 | 		})
109 | 		t.Run("DynamicClient propagates headers to Kube API", func(t *testing.T) {
110 | 			if len(pathHeaders) == 0 {
111 | 				t.Fatalf("No requests were made to Kube API")
112 | 			}
113 | 			if pathHeaders["/api/v1/namespaces/default/pods"] == nil || pathHeaders["/api/v1/namespaces/default/pods"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
114 | 				t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods")
115 | 			}
116 | 		})
117 | 		_, _ = c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-to-delete"})
118 | 		t.Run("kubernetes.Interface propagates headers to Kube API", func(t *testing.T) {
119 | 			if len(pathHeaders) == 0 {
120 | 				t.Fatalf("No requests were made to Kube API")
121 | 			}
122 | 			if pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"] == nil || pathHeaders["/api/v1/namespaces/default/pods/a-pod-to-delete"].Get("Authorization") != "Bearer a-token-from-mcp-client" {
123 | 				t.Fatalf("Overridden header Authorization not found in request to /api/v1/namespaces/default/pods/a-pod-to-delete")
124 | 			}
125 | 		})
126 | 	})
127 | }
128 | 
```

--------------------------------------------------------------------------------
/pkg/config/provider_config_test.go:
--------------------------------------------------------------------------------

```go
  1 | package config
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/BurntSushi/toml"
  8 | 	"github.com/stretchr/testify/suite"
  9 | )
 10 | 
 11 | type ProviderConfigSuite struct {
 12 | 	BaseConfigSuite
 13 | 	originalProviderConfigParsers map[string]ProviderConfigParser
 14 | }
 15 | 
 16 | func (s *ProviderConfigSuite) SetupTest() {
 17 | 	s.originalProviderConfigParsers = make(map[string]ProviderConfigParser)
 18 | 	for k, v := range providerConfigParsers {
 19 | 		s.originalProviderConfigParsers[k] = v
 20 | 	}
 21 | }
 22 | 
 23 | func (s *ProviderConfigSuite) TearDownTest() {
 24 | 	providerConfigParsers = make(map[string]ProviderConfigParser)
 25 | 	for k, v := range s.originalProviderConfigParsers {
 26 | 		providerConfigParsers[k] = v
 27 | 	}
 28 | }
 29 | 
 30 | type ProviderConfigForTest struct {
 31 | 	BoolProp bool   `toml:"bool_prop"`
 32 | 	StrProp  string `toml:"str_prop"`
 33 | 	IntProp  int    `toml:"int_prop"`
 34 | }
 35 | 
 36 | var _ ProviderConfig = (*ProviderConfigForTest)(nil)
 37 | 
 38 | func (p *ProviderConfigForTest) Validate() error {
 39 | 	if p.StrProp == "force-error" {
 40 | 		return errors.New("validation error forced by test")
 41 | 	}
 42 | 	return nil
 43 | }
 44 | 
 45 | func providerConfigForTestParser(primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error) {
 46 | 	var providerConfigForTest ProviderConfigForTest
 47 | 	if err := md.PrimitiveDecode(primitive, &providerConfigForTest); err != nil {
 48 | 		return nil, err
 49 | 	}
 50 | 	return &providerConfigForTest, nil
 51 | }
 52 | 
 53 | func (s *ProviderConfigSuite) TestRegisterProviderConfig() {
 54 | 	s.Run("panics when registering duplicate provider config parser", func() {
 55 | 		s.Panics(func() {
 56 | 			RegisterProviderConfig("test", providerConfigForTestParser)
 57 | 			RegisterProviderConfig("test", providerConfigForTestParser)
 58 | 		}, "Expected panic when registering duplicate provider config parser")
 59 | 	})
 60 | }
 61 | 
 62 | func (s *ProviderConfigSuite) TestReadConfigValid() {
 63 | 	RegisterProviderConfig("test", providerConfigForTestParser)
 64 | 	validConfigPath := s.writeConfig(`
 65 | 		cluster_provider_strategy = "test"
 66 | 		[cluster_provider_configs.test]
 67 | 		bool_prop = true
 68 | 		str_prop = "a string"
 69 | 		int_prop = 42
 70 | 	`)
 71 | 
 72 | 	config, err := Read(validConfigPath)
 73 | 	s.Run("returns no error for valid file with registered provider config", func() {
 74 | 		s.Require().NoError(err, "Expected no error for valid file, got %v", err)
 75 | 	})
 76 | 	s.Run("returns config for valid file with registered provider config", func() {
 77 | 		s.Require().NotNil(config, "Expected non-nil config for valid file")
 78 | 	})
 79 | 	s.Run("parses provider config correctly", func() {
 80 | 		providerConfig, ok := config.GetProviderConfig("test")
 81 | 		s.Require().True(ok, "Expected to find provider config for strategy 'test'")
 82 | 		s.Require().NotNil(providerConfig, "Expected non-nil provider config for strategy 'test'")
 83 | 		testProviderConfig, ok := providerConfig.(*ProviderConfigForTest)
 84 | 		s.Require().True(ok, "Expected provider config to be of type *ProviderConfigForTest")
 85 | 		s.Equal(true, testProviderConfig.BoolProp, "Expected BoolProp to be true")
 86 | 		s.Equal("a string", testProviderConfig.StrProp, "Expected StrProp to be 'a string'")
 87 | 		s.Equal(42, testProviderConfig.IntProp, "Expected IntProp to be 42")
 88 | 	})
 89 | }
 90 | 
 91 | func (s *ProviderConfigSuite) TestReadConfigInvalidProviderConfig() {
 92 | 	RegisterProviderConfig("test", providerConfigForTestParser)
 93 | 	invalidConfigPath := s.writeConfig(`
 94 | 		cluster_provider_strategy = "test"
 95 | 		[cluster_provider_configs.test]
 96 | 		bool_prop = true
 97 | 		str_prop = "force-error"
 98 | 		int_prop = 42
 99 | 	`)
100 | 
101 | 	config, err := Read(invalidConfigPath)
102 | 	s.Run("returns error for invalid provider config", func() {
103 | 		s.Require().NotNil(err, "Expected error for invalid provider config, got nil")
104 | 		s.ErrorContains(err, "validation error forced by test", "Expected validation error from provider config")
105 | 	})
106 | 	s.Run("returns nil config for invalid provider config", func() {
107 | 		s.Nil(config, "Expected nil config for invalid provider config")
108 | 	})
109 | }
110 | 
111 | func (s *ProviderConfigSuite) TestReadConfigUnregisteredProviderConfig() {
112 | 	invalidConfigPath := s.writeConfig(`
113 | 		cluster_provider_strategy = "unregistered"
114 | 		[cluster_provider_configs.unregistered]
115 | 		bool_prop = true
116 | 		str_prop = "a string"
117 | 		int_prop = 42
118 | 	`)
119 | 
120 | 	config, err := Read(invalidConfigPath)
121 | 	s.Run("returns no error for unregistered provider config", func() {
122 | 		s.Require().NoError(err, "Expected no error for unregistered provider config, got %v", err)
123 | 	})
124 | 	s.Run("returns config for unregistered provider config", func() {
125 | 		s.Require().NotNil(config, "Expected non-nil config for unregistered provider config")
126 | 	})
127 | 	s.Run("does not parse unregistered provider config", func() {
128 | 		_, ok := config.GetProviderConfig("unregistered")
129 | 		s.Require().False(ok, "Expected no provider config for unregistered strategy")
130 | 	})
131 | }
132 | 
133 | func (s *ProviderConfigSuite) TestReadConfigParserError() {
134 | 	RegisterProviderConfig("test", func(primitive toml.Primitive, md toml.MetaData) (ProviderConfig, error) {
135 | 		return nil, errors.New("parser error forced by test")
136 | 	})
137 | 	invalidConfigPath := s.writeConfig(`
138 | 		cluster_provider_strategy = "test"
139 | 		[cluster_provider_configs.test]
140 | 		bool_prop = true
141 | 		str_prop = "a string"
142 | 		int_prop = 42
143 | 	`)
144 | 
145 | 	config, err := Read(invalidConfigPath)
146 | 	s.Run("returns error for provider config parser error", func() {
147 | 		s.Require().NotNil(err, "Expected error for provider config parser error, got nil")
148 | 		s.ErrorContains(err, "parser error forced by test", "Expected parser error from provider config")
149 | 	})
150 | 	s.Run("returns nil config for provider config parser error", func() {
151 | 		s.Nil(config, "Expected nil config for provider config parser error")
152 | 	})
153 | }
154 | 
155 | func TestProviderConfig(t *testing.T) {
156 | 	suite.Run(t, new(ProviderConfigSuite))
157 | }
158 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/provider_single_test.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"net/http"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
  8 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
  9 | 	"github.com/stretchr/testify/suite"
 10 | 	"k8s.io/client-go/rest"
 11 | )
 12 | 
 13 | type ProviderSingleTestSuite struct {
 14 | 	BaseProviderSuite
 15 | 	mockServer                *test.MockServer
 16 | 	originalIsInClusterConfig func() (*rest.Config, error)
 17 | 	provider                  Provider
 18 | }
 19 | 
 20 | func (s *ProviderSingleTestSuite) SetupTest() {
 21 | 	// Single cluster provider is used when in-cluster or when the multi-cluster feature is disabled.
 22 | 	// For this test suite we simulate an in-cluster deployment.
 23 | 	s.originalIsInClusterConfig = InClusterConfig
 24 | 	s.mockServer = test.NewMockServer()
 25 | 	InClusterConfig = func() (*rest.Config, error) {
 26 | 		return s.mockServer.Config(), nil
 27 | 	}
 28 | 	provider, err := NewProvider(&config.StaticConfig{})
 29 | 	s.Require().NoError(err, "Expected no error creating provider with kubeconfig")
 30 | 	s.provider = provider
 31 | }
 32 | 
 33 | func (s *ProviderSingleTestSuite) TearDownTest() {
 34 | 	InClusterConfig = s.originalIsInClusterConfig
 35 | 	if s.mockServer != nil {
 36 | 		s.mockServer.Close()
 37 | 	}
 38 | }
 39 | 
 40 | func (s *ProviderSingleTestSuite) TestType() {
 41 | 	s.IsType(&singleClusterProvider{}, s.provider)
 42 | }
 43 | 
 44 | func (s *ProviderSingleTestSuite) TestWithNonOpenShiftCluster() {
 45 | 	s.Run("IsOpenShift returns false", func() {
 46 | 		inOpenShift := s.provider.IsOpenShift(s.T().Context())
 47 | 		s.False(inOpenShift, "Expected InOpenShift to return false")
 48 | 	})
 49 | }
 50 | 
 51 | func (s *ProviderSingleTestSuite) TestWithOpenShiftCluster() {
 52 | 	s.mockServer.Handle(&test.InOpenShiftHandler{})
 53 | 	s.Run("IsOpenShift returns true", func() {
 54 | 		inOpenShift := s.provider.IsOpenShift(s.T().Context())
 55 | 		s.True(inOpenShift, "Expected InOpenShift to return true")
 56 | 	})
 57 | }
 58 | 
 59 | func (s *ProviderSingleTestSuite) TestVerifyToken() {
 60 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 61 | 		if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
 62 | 			w.Header().Set("Content-Type", "application/json")
 63 | 			_, _ = w.Write([]byte(`
 64 | 				{
 65 | 					"kind": "TokenReview",
 66 | 					"apiVersion": "authentication.k8s.io/v1",
 67 | 					"spec": {"token": "the-token"},
 68 | 					"status": {
 69 | 						"authenticated": true,
 70 | 						"user": {
 71 | 							"username": "test-user",
 72 | 							"groups": ["system:authenticated"]
 73 | 						},
 74 | 						"audiences": ["the-audience"]
 75 | 					}
 76 | 				}`))
 77 | 		}
 78 | 	}))
 79 | 	s.Run("VerifyToken returns UserInfo for empty target (default target)", func() {
 80 | 		userInfo, audiences, err := s.provider.VerifyToken(s.T().Context(), "", "the-token", "the-audience")
 81 | 		s.Require().NoError(err, "Expected no error from VerifyToken with empty target")
 82 | 		s.Require().NotNil(userInfo, "Expected UserInfo from VerifyToken with empty target")
 83 | 		s.Equalf(userInfo.Username, "test-user", "Expected username test-user, got: %s", userInfo.Username)
 84 | 		s.Containsf(userInfo.Groups, "system:authenticated", "Expected group system:authenticated in %v", userInfo.Groups)
 85 | 		s.Require().NotNil(audiences, "Expected audiences from VerifyToken with empty target")
 86 | 		s.Len(audiences, 1, "Expected audiences from VerifyToken with empty target")
 87 | 		s.Containsf(audiences, "the-audience", "Expected audience the-audience in %v", audiences)
 88 | 	})
 89 | 	s.Run("VerifyToken returns error for non-empty context", func() {
 90 | 		userInfo, audiences, err := s.provider.VerifyToken(s.T().Context(), "non-empty", "the-token", "the-audience")
 91 | 		s.Require().Error(err, "Expected error from VerifyToken with non-empty target")
 92 | 		s.ErrorContains(err, "unable to get manager for other context/cluster with in-cluster strategy", "Expected error about trying to get other cluster")
 93 | 		s.Nil(userInfo, "Expected no UserInfo from VerifyToken with non-empty target")
 94 | 		s.Nil(audiences, "Expected no audiences from VerifyToken with non-empty target")
 95 | 	})
 96 | }
 97 | 
 98 | func (s *ProviderSingleTestSuite) TestGetTargets() {
 99 | 	s.Run("GetTargets returns single empty target", func() {
100 | 		targets, err := s.provider.GetTargets(s.T().Context())
101 | 		s.Require().NoError(err, "Expected no error from GetTargets")
102 | 		s.Len(targets, 1, "Expected 1 targets from GetTargets")
103 | 		s.Contains(targets, "", "Expected empty target from GetTargets")
104 | 	})
105 | }
106 | 
107 | func (s *ProviderSingleTestSuite) TestGetDerivedKubernetes() {
108 | 	s.Run("GetDerivedKubernetes returns Kubernetes for empty target", func() {
109 | 		k8s, err := s.provider.GetDerivedKubernetes(s.T().Context(), "")
110 | 		s.Require().NoError(err, "Expected no error from GetDerivedKubernetes with empty target")
111 | 		s.NotNil(k8s, "Expected Kubernetes from GetDerivedKubernetes with empty target")
112 | 	})
113 | 	s.Run("GetDerivedKubernetes returns error for non-empty target", func() {
114 | 		k8s, err := s.provider.GetDerivedKubernetes(s.T().Context(), "non-empty-target")
115 | 		s.Require().Error(err, "Expected error from GetDerivedKubernetes with non-empty target")
116 | 		s.ErrorContains(err, "unable to get manager for other context/cluster with in-cluster strategy", "Expected error about trying to get other cluster")
117 | 		s.Nil(k8s, "Expected no Kubernetes from GetDerivedKubernetes with non-empty target")
118 | 	})
119 | }
120 | 
121 | func (s *ProviderSingleTestSuite) TestGetDefaultTarget() {
122 | 	s.Run("GetDefaultTarget returns empty string", func() {
123 | 		s.Empty(s.provider.GetDefaultTarget(), "Expected fake-context as default target")
124 | 	})
125 | }
126 | 
127 | func (s *ProviderSingleTestSuite) TestGetTargetParameterName() {
128 | 	s.Empty(s.provider.GetTargetParameterName(), "Expected empty string as target parameter name")
129 | }
130 | 
131 | func TestProviderSingle(t *testing.T) {
132 | 	suite.Run(t, new(ProviderSingleTestSuite))
133 | }
134 | 
```

--------------------------------------------------------------------------------
/internal/test/mock_server.go:
--------------------------------------------------------------------------------

```go
  1 | package test
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"errors"
  6 | 	"io"
  7 | 	"net/http"
  8 | 	"net/http/httptest"
  9 | 	"path/filepath"
 10 | 	"testing"
 11 | 
 12 | 	"github.com/stretchr/testify/require"
 13 | 	v1 "k8s.io/api/core/v1"
 14 | 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 15 | 	"k8s.io/apimachinery/pkg/runtime"
 16 | 	"k8s.io/apimachinery/pkg/runtime/serializer"
 17 | 	"k8s.io/apimachinery/pkg/util/httpstream"
 18 | 	"k8s.io/apimachinery/pkg/util/httpstream/spdy"
 19 | 	"k8s.io/client-go/rest"
 20 | 	"k8s.io/client-go/tools/clientcmd"
 21 | 	"k8s.io/client-go/tools/clientcmd/api"
 22 | )
 23 | 
 24 | type MockServer struct {
 25 | 	server       *httptest.Server
 26 | 	config       *rest.Config
 27 | 	restHandlers []http.HandlerFunc
 28 | }
 29 | 
 30 | func NewMockServer() *MockServer {
 31 | 	ms := &MockServer{}
 32 | 	scheme := runtime.NewScheme()
 33 | 	codecs := serializer.NewCodecFactory(scheme)
 34 | 	ms.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 35 | 		for _, handler := range ms.restHandlers {
 36 | 			handler(w, req)
 37 | 		}
 38 | 	}))
 39 | 	ms.config = &rest.Config{
 40 | 		Host:    ms.server.URL,
 41 | 		APIPath: "/api",
 42 | 		ContentConfig: rest.ContentConfig{
 43 | 			NegotiatedSerializer: codecs,
 44 | 			ContentType:          runtime.ContentTypeJSON,
 45 | 			GroupVersion:         &v1.SchemeGroupVersion,
 46 | 		},
 47 | 	}
 48 | 	ms.restHandlers = make([]http.HandlerFunc, 0)
 49 | 	return ms
 50 | }
 51 | 
 52 | func (m *MockServer) Close() {
 53 | 	if m.server != nil {
 54 | 		m.server.Close()
 55 | 	}
 56 | }
 57 | 
 58 | func (m *MockServer) Handle(handler http.Handler) {
 59 | 	m.restHandlers = append(m.restHandlers, handler.ServeHTTP)
 60 | }
 61 | 
 62 | func (m *MockServer) Config() *rest.Config {
 63 | 	return m.config
 64 | }
 65 | 
 66 | func (m *MockServer) Kubeconfig() *api.Config {
 67 | 	fakeConfig := KubeConfigFake()
 68 | 	fakeConfig.Clusters["fake"].Server = m.config.Host
 69 | 	fakeConfig.Clusters["fake"].CertificateAuthorityData = m.config.CAData
 70 | 	fakeConfig.AuthInfos["fake"].ClientKeyData = m.config.KeyData
 71 | 	fakeConfig.AuthInfos["fake"].ClientCertificateData = m.config.CertData
 72 | 	return fakeConfig
 73 | }
 74 | 
 75 | func (m *MockServer) KubeconfigFile(t *testing.T) string {
 76 | 	return KubeconfigFile(t, m.Kubeconfig())
 77 | }
 78 | 
 79 | func KubeconfigFile(t *testing.T, kubeconfig *api.Config) string {
 80 | 	kubeconfigFile := filepath.Join(t.TempDir(), "config")
 81 | 	err := clientcmd.WriteToFile(*kubeconfig, kubeconfigFile)
 82 | 	require.NoError(t, err, "Expected no error writing kubeconfig file")
 83 | 	return kubeconfigFile
 84 | }
 85 | 
 86 | func WriteObject(w http.ResponseWriter, obj runtime.Object) {
 87 | 	w.Header().Set("Content-Type", runtime.ContentTypeJSON)
 88 | 	if err := json.NewEncoder(w).Encode(obj); err != nil {
 89 | 		http.Error(w, err.Error(), http.StatusInternalServerError)
 90 | 	}
 91 | }
 92 | 
 93 | type streamAndReply struct {
 94 | 	httpstream.Stream
 95 | 	replySent <-chan struct{}
 96 | }
 97 | 
 98 | type StreamContext struct {
 99 | 	Closer       io.Closer
100 | 	StdinStream  io.ReadCloser
101 | 	StdoutStream io.WriteCloser
102 | 	StderrStream io.WriteCloser
103 | 	writeStatus  func(status *apierrors.StatusError) error
104 | }
105 | 
106 | type StreamOptions struct {
107 | 	Stdin  io.Reader
108 | 	Stdout io.Writer
109 | 	Stderr io.Writer
110 | }
111 | 
112 | func v4WriteStatusFunc(stream io.Writer) func(status *apierrors.StatusError) error {
113 | 	return func(status *apierrors.StatusError) error {
114 | 		bs, err := json.Marshal(status.Status())
115 | 		if err != nil {
116 | 			return err
117 | 		}
118 | 		_, err = stream.Write(bs)
119 | 		return err
120 | 	}
121 | }
122 | func CreateHTTPStreams(w http.ResponseWriter, req *http.Request, opts *StreamOptions) (*StreamContext, error) {
123 | 	_, err := httpstream.Handshake(req, w, []string{"v4.channel.k8s.io"})
124 | 	if err != nil {
125 | 		return nil, err
126 | 	}
127 | 
128 | 	upgrader := spdy.NewResponseUpgrader()
129 | 	streamCh := make(chan streamAndReply)
130 | 	connection := upgrader.UpgradeResponse(w, req, func(stream httpstream.Stream, replySent <-chan struct{}) error {
131 | 		streamCh <- streamAndReply{Stream: stream, replySent: replySent}
132 | 		return nil
133 | 	})
134 | 	ctx := &StreamContext{
135 | 		Closer: connection,
136 | 	}
137 | 
138 | 	// wait for stream
139 | 	replyChan := make(chan struct{}, 4)
140 | 	defer close(replyChan)
141 | 	receivedStreams := 0
142 | 	expectedStreams := 1
143 | 	if opts.Stdout != nil {
144 | 		expectedStreams++
145 | 	}
146 | 	if opts.Stdin != nil {
147 | 		expectedStreams++
148 | 	}
149 | 	if opts.Stderr != nil {
150 | 		expectedStreams++
151 | 	}
152 | WaitForStreams:
153 | 	for {
154 | 		select {
155 | 		case stream := <-streamCh:
156 | 			streamType := stream.Headers().Get(v1.StreamType)
157 | 			switch streamType {
158 | 			case v1.StreamTypeError:
159 | 				replyChan <- struct{}{}
160 | 				ctx.writeStatus = v4WriteStatusFunc(stream)
161 | 			case v1.StreamTypeStdout:
162 | 				replyChan <- struct{}{}
163 | 				ctx.StdoutStream = stream
164 | 			case v1.StreamTypeStdin:
165 | 				replyChan <- struct{}{}
166 | 				ctx.StdinStream = stream
167 | 			case v1.StreamTypeStderr:
168 | 				replyChan <- struct{}{}
169 | 				ctx.StderrStream = stream
170 | 			default:
171 | 				// add other stream ...
172 | 				return nil, errors.New("unimplemented stream type")
173 | 			}
174 | 		case <-replyChan:
175 | 			receivedStreams++
176 | 			if receivedStreams == expectedStreams {
177 | 				break WaitForStreams
178 | 			}
179 | 		}
180 | 	}
181 | 
182 | 	return ctx, nil
183 | }
184 | 
185 | type InOpenShiftHandler struct {
186 | }
187 | 
188 | var _ http.Handler = (*InOpenShiftHandler)(nil)
189 | 
190 | func (h *InOpenShiftHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
191 | 	w.Header().Set("Content-Type", "application/json")
192 | 	// Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
193 | 	if req.URL.Path == "/api" {
194 | 		_, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
195 | 		return
196 | 	}
197 | 	// Request Performed by DiscoveryClient to Kube API (Get API Groups)
198 | 	if req.URL.Path == "/apis" {
199 | 		_, _ = w.Write([]byte(`{
200 | 			"kind":"APIGroupList",
201 | 			"groups":[{
202 | 				"name":"project.openshift.io",
203 | 				"versions":[{"groupVersion":"project.openshift.io/v1","version":"v1"}],
204 | 				"preferredVersion":{"groupVersion":"project.openshift.io/v1","version":"v1"}
205 | 			}]}`))
206 | 		return
207 | 	}
208 | 	if req.URL.Path == "/apis/project.openshift.io/v1" {
209 | 		_, _ = w.Write([]byte(`{
210 | 			"kind":"APIResourceList",
211 | 			"apiVersion":"v1",
212 | 			"groupVersion":"project.openshift.io/v1",
213 | 			"resources":[
214 | 				{"name":"projects","singularName":"","namespaced":false,"kind":"Project","verbs":["create","delete","get","list","patch","update","watch"],"shortNames":["pr"]}
215 | 			]}`))
216 | 		return
217 | 	}
218 | }
219 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/accesscontrol_clientset.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 
  7 | 	authenticationv1api "k8s.io/api/authentication/v1"
  8 | 	authorizationv1api "k8s.io/api/authorization/v1"
  9 | 	v1 "k8s.io/api/core/v1"
 10 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 11 | 	"k8s.io/apimachinery/pkg/runtime/schema"
 12 | 	"k8s.io/apimachinery/pkg/util/httpstream"
 13 | 	"k8s.io/client-go/discovery"
 14 | 	"k8s.io/client-go/kubernetes"
 15 | 	authenticationv1 "k8s.io/client-go/kubernetes/typed/authentication/v1"
 16 | 	authorizationv1 "k8s.io/client-go/kubernetes/typed/authorization/v1"
 17 | 	corev1 "k8s.io/client-go/kubernetes/typed/core/v1"
 18 | 	"k8s.io/client-go/rest"
 19 | 	"k8s.io/client-go/tools/remotecommand"
 20 | 	"k8s.io/metrics/pkg/apis/metrics"
 21 | 	metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
 22 | 	metricsv1beta1 "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1"
 23 | 
 24 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 25 | )
 26 | 
 27 | // AccessControlClientset is a limited clientset delegating interface to the standard kubernetes.Clientset
 28 | // Only a limited set of functions are implemented with a single point of access to the kubernetes API where
 29 | // apiVersion and kinds are checked for allowed access
 30 | type AccessControlClientset struct {
 31 | 	cfg             *rest.Config
 32 | 	delegate        kubernetes.Interface
 33 | 	discoveryClient discovery.DiscoveryInterface
 34 | 	metricsV1beta1  *metricsv1beta1.MetricsV1beta1Client
 35 | 	staticConfig    *config.StaticConfig // TODO: maybe just store the denied resource slice
 36 | }
 37 | 
 38 | func (a *AccessControlClientset) DiscoveryClient() discovery.DiscoveryInterface {
 39 | 	return a.discoveryClient
 40 | }
 41 | 
 42 | func (a *AccessControlClientset) NodesLogs(ctx context.Context, name, logPath string) (*rest.Request, error) {
 43 | 	gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Node"}
 44 | 	if !isAllowed(a.staticConfig, gvk) {
 45 | 		return nil, isNotAllowedError(gvk)
 46 | 	}
 47 | 
 48 | 	if _, err := a.delegate.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}); err != nil {
 49 | 		return nil, fmt.Errorf("failed to get node %s: %w", name, err)
 50 | 	}
 51 | 
 52 | 	url := []string{"api", "v1", "nodes", name, "proxy", "logs", logPath}
 53 | 	return a.delegate.CoreV1().RESTClient().
 54 | 		Get().
 55 | 		AbsPath(url...), nil
 56 | }
 57 | 
 58 | func (a *AccessControlClientset) Pods(namespace string) (corev1.PodInterface, error) {
 59 | 	gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
 60 | 	if !isAllowed(a.staticConfig, gvk) {
 61 | 		return nil, isNotAllowedError(gvk)
 62 | 	}
 63 | 	return a.delegate.CoreV1().Pods(namespace), nil
 64 | }
 65 | 
 66 | func (a *AccessControlClientset) PodsExec(namespace, name string, podExecOptions *v1.PodExecOptions) (remotecommand.Executor, error) {
 67 | 	gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}
 68 | 	if !isAllowed(a.staticConfig, gvk) {
 69 | 		return nil, isNotAllowedError(gvk)
 70 | 	}
 71 | 	// Compute URL
 72 | 	// https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L382-L397
 73 | 	execRequest := a.delegate.CoreV1().RESTClient().
 74 | 		Post().
 75 | 		Resource("pods").
 76 | 		Namespace(namespace).
 77 | 		Name(name).
 78 | 		SubResource("exec")
 79 | 	execRequest.VersionedParams(podExecOptions, ParameterCodec)
 80 | 	spdyExec, err := remotecommand.NewSPDYExecutor(a.cfg, "POST", execRequest.URL())
 81 | 	if err != nil {
 82 | 		return nil, err
 83 | 	}
 84 | 	webSocketExec, err := remotecommand.NewWebSocketExecutor(a.cfg, "GET", execRequest.URL().String())
 85 | 	if err != nil {
 86 | 		return nil, err
 87 | 	}
 88 | 	return remotecommand.NewFallbackExecutor(webSocketExec, spdyExec, func(err error) bool {
 89 | 		return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err)
 90 | 	})
 91 | }
 92 | 
 93 | func (a *AccessControlClientset) PodsMetricses(ctx context.Context, namespace, name string, listOptions metav1.ListOptions) (*metrics.PodMetricsList, error) {
 94 | 	gvk := &schema.GroupVersionKind{Group: metrics.GroupName, Version: metricsv1beta1api.SchemeGroupVersion.Version, Kind: "PodMetrics"}
 95 | 	if !isAllowed(a.staticConfig, gvk) {
 96 | 		return nil, isNotAllowedError(gvk)
 97 | 	}
 98 | 	versionedMetrics := &metricsv1beta1api.PodMetricsList{}
 99 | 	var err error
100 | 	if name != "" {
101 | 		m, err := a.metricsV1beta1.PodMetricses(namespace).Get(ctx, name, metav1.GetOptions{})
102 | 		if err != nil {
103 | 			return nil, fmt.Errorf("failed to get metrics for pod %s/%s: %w", namespace, name, err)
104 | 		}
105 | 		versionedMetrics.Items = []metricsv1beta1api.PodMetrics{*m}
106 | 	} else {
107 | 		versionedMetrics, err = a.metricsV1beta1.PodMetricses(namespace).List(ctx, listOptions)
108 | 		if err != nil {
109 | 			return nil, fmt.Errorf("failed to list pod metrics in namespace %s: %w", namespace, err)
110 | 		}
111 | 	}
112 | 	convertedMetrics := &metrics.PodMetricsList{}
113 | 	return convertedMetrics, metricsv1beta1api.Convert_v1beta1_PodMetricsList_To_metrics_PodMetricsList(versionedMetrics, convertedMetrics, nil)
114 | }
115 | 
116 | func (a *AccessControlClientset) Services(namespace string) (corev1.ServiceInterface, error) {
117 | 	gvk := &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}
118 | 	if !isAllowed(a.staticConfig, gvk) {
119 | 		return nil, isNotAllowedError(gvk)
120 | 	}
121 | 	return a.delegate.CoreV1().Services(namespace), nil
122 | }
123 | 
124 | func (a *AccessControlClientset) SelfSubjectAccessReviews() (authorizationv1.SelfSubjectAccessReviewInterface, error) {
125 | 	gvk := &schema.GroupVersionKind{Group: authorizationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "SelfSubjectAccessReview"}
126 | 	if !isAllowed(a.staticConfig, gvk) {
127 | 		return nil, isNotAllowedError(gvk)
128 | 	}
129 | 	return a.delegate.AuthorizationV1().SelfSubjectAccessReviews(), nil
130 | }
131 | 
132 | // TokenReview returns TokenReviewInterface
133 | func (a *AccessControlClientset) TokenReview() (authenticationv1.TokenReviewInterface, error) {
134 | 	gvk := &schema.GroupVersionKind{Group: authenticationv1api.GroupName, Version: authorizationv1api.SchemeGroupVersion.Version, Kind: "TokenReview"}
135 | 	if !isAllowed(a.staticConfig, gvk) {
136 | 		return nil, isNotAllowedError(gvk)
137 | 	}
138 | 	return a.delegate.AuthenticationV1().TokenReviews(), nil
139 | }
140 | 
141 | func NewAccessControlClientset(cfg *rest.Config, staticConfig *config.StaticConfig) (*AccessControlClientset, error) {
142 | 	clientSet, err := kubernetes.NewForConfig(cfg)
143 | 	if err != nil {
144 | 		return nil, err
145 | 	}
146 | 	metricsClient, err := metricsv1beta1.NewForConfig(cfg)
147 | 	if err != nil {
148 | 		return nil, err
149 | 	}
150 | 	return &AccessControlClientset{
151 | 		cfg:             cfg,
152 | 		delegate:        clientSet,
153 | 		discoveryClient: clientSet.DiscoveryClient,
154 | 		metricsV1beta1:  metricsClient,
155 | 		staticConfig:    staticConfig,
156 | 	}, nil
157 | }
158 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/namespaces_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"regexp"
  5 | 	"slices"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/BurntSushi/toml"
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/stretchr/testify/suite"
 11 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 12 | 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 13 | 	"k8s.io/apimachinery/pkg/runtime/schema"
 14 | 	"k8s.io/client-go/dynamic"
 15 | 	"sigs.k8s.io/yaml"
 16 | 
 17 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 18 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 19 | )
 20 | 
 21 | type NamespacesSuite struct {
 22 | 	BaseMcpSuite
 23 | }
 24 | 
 25 | func (s *NamespacesSuite) TestNamespacesList() {
 26 | 	s.InitMcpClient()
 27 | 	s.Run("namespaces_list", func() {
 28 | 		toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{})
 29 | 		s.Run("no error", func() {
 30 | 			s.Nilf(err, "call tool failed %v", err)
 31 | 			s.Falsef(toolResult.IsError, "call tool failed")
 32 | 		})
 33 | 		s.Require().NotNil(toolResult, "Expected tool result from call")
 34 | 		var decoded []unstructured.Unstructured
 35 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
 36 | 		s.Run("has yaml content", func() {
 37 | 			s.Nilf(err, "invalid tool result content %v", err)
 38 | 		})
 39 | 		s.Run("returns at least 3 items", func() {
 40 | 			s.Truef(len(decoded) >= 3, "expected at least 3 items, got %v", len(decoded))
 41 | 			for _, expectedNamespace := range []string{"default", "ns-1", "ns-2"} {
 42 | 				s.Truef(slices.ContainsFunc(decoded, func(ns unstructured.Unstructured) bool {
 43 | 					return ns.GetName() == expectedNamespace
 44 | 				}), "namespace %s not found in the list", expectedNamespace)
 45 | 			}
 46 | 		})
 47 | 	})
 48 | }
 49 | 
 50 | func (s *NamespacesSuite) TestNamespacesListDenied() {
 51 | 	s.Require().NoError(toml.Unmarshal([]byte(`
 52 | 		denied_resources = [ { version = "v1", kind = "Namespace" } ]
 53 | 	`), s.Cfg), "Expected to parse denied resources config")
 54 | 	s.InitMcpClient()
 55 | 	s.Run("namespaces_list (denied)", func() {
 56 | 		toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{})
 57 | 		s.Run("has error", func() {
 58 | 			s.Truef(toolResult.IsError, "call tool should fail")
 59 | 			s.Nilf(err, "call tool should not return error object")
 60 | 		})
 61 | 		s.Run("describes denial", func() {
 62 | 			expectedMessage := "failed to list namespaces: resource not allowed: /v1, Kind=Namespace"
 63 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
 64 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
 65 | 		})
 66 | 	})
 67 | }
 68 | 
 69 | func (s *NamespacesSuite) TestNamespacesListAsTable() {
 70 | 	s.Cfg.ListOutput = "table"
 71 | 	s.InitMcpClient()
 72 | 	s.Run("namespaces_list (list_output=table)", func() {
 73 | 		toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{})
 74 | 		s.Run("no error", func() {
 75 | 			s.Nilf(err, "call tool failed %v", err)
 76 | 			s.Falsef(toolResult.IsError, "call tool failed")
 77 | 		})
 78 | 		s.Require().NotNil(toolResult, "Expected tool result from call")
 79 | 		out := toolResult.Content[0].(mcp.TextContent).Text
 80 | 		s.Run("returns column headers", func() {
 81 | 			expectedHeaders := "APIVERSION\\s+KIND\\s+NAME\\s+STATUS\\s+AGE\\s+LABELS"
 82 | 			m, e := regexp.MatchString(expectedHeaders, out)
 83 | 			s.Truef(m, "Expected headers '%s' not found in output:\n%s", expectedHeaders, out)
 84 | 			s.NoErrorf(e, "Error matching headers regex: %v", e)
 85 | 		})
 86 | 		s.Run("returns formatted row for ns-1", func() {
 87 | 			expectedRow := "(?<apiVersion>v1)\\s+" +
 88 | 				"(?<kind>Namespace)\\s+" +
 89 | 				"(?<name>ns-1)\\s+" +
 90 | 				"(?<status>Active)\\s+" +
 91 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
 92 | 				"(?<labels>kubernetes.io/metadata.name=ns-1)"
 93 | 			m, e := regexp.MatchString(expectedRow, out)
 94 | 			s.Truef(m, "Expected row '%s' not found in output:\n%s", expectedRow, out)
 95 | 			s.NoErrorf(e, "Error matching ns-1 regex: %v", e)
 96 | 		})
 97 | 		s.Run("returns formatted row for ns-2", func() {
 98 | 			expectedRow := "(?<apiVersion>v1)\\s+" +
 99 | 				"(?<kind>Namespace)\\s+" +
100 | 				"(?<name>ns-2)\\s+" +
101 | 				"(?<status>Active)\\s+" +
102 | 				"(?<age>(\\d+m)?(\\d+s)?)\\s+" +
103 | 				"(?<labels>kubernetes.io/metadata.name=ns-2)"
104 | 			m, e := regexp.MatchString(expectedRow, out)
105 | 			s.Truef(m, "Expected row '%s' not found in output:\n%s", expectedRow, out)
106 | 			s.NoErrorf(e, "Error matching ns-2 regex: %v", e)
107 | 		})
108 | 	})
109 | }
110 | 
111 | func TestNamespaces(t *testing.T) {
112 | 	suite.Run(t, new(NamespacesSuite))
113 | }
114 | 
115 | func TestProjectsListInOpenShift(t *testing.T) {
116 | 	testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
117 | 		dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
118 | 		_, _ = dynamicClient.Resource(schema.GroupVersionResource{Group: "project.openshift.io", Version: "v1", Resource: "projects"}).
119 | 			Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
120 | 				"apiVersion": "project.openshift.io/v1",
121 | 				"kind":       "Project",
122 | 				"metadata": map[string]interface{}{
123 | 					"name": "an-openshift-project",
124 | 				},
125 | 			}}, metav1.CreateOptions{})
126 | 		toolResult, err := c.callTool("projects_list", map[string]interface{}{})
127 | 		t.Run("projects_list returns project list", func(t *testing.T) {
128 | 			if err != nil {
129 | 				t.Fatalf("call tool failed %v", err)
130 | 			}
131 | 			if toolResult.IsError {
132 | 				t.Fatalf("call tool failed")
133 | 			}
134 | 		})
135 | 		var decoded []unstructured.Unstructured
136 | 		err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
137 | 		t.Run("projects_list has yaml content", func(t *testing.T) {
138 | 			if err != nil {
139 | 				t.Fatalf("invalid tool result content %v", err)
140 | 			}
141 | 		})
142 | 		t.Run("projects_list returns at least 1 items", func(t *testing.T) {
143 | 			if len(decoded) < 1 {
144 | 				t.Errorf("invalid project count, expected at least 1, got %v", len(decoded))
145 | 			}
146 | 			idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
147 | 				return ns.GetName() == "an-openshift-project"
148 | 			})
149 | 			if idx == -1 {
150 | 				t.Errorf("namespace %s not found in the list", "an-openshift-project")
151 | 			}
152 | 		})
153 | 	})
154 | }
155 | 
156 | func TestProjectsListInOpenShiftDenied(t *testing.T) {
157 | 	deniedResourcesServer := test.Must(config.ReadToml([]byte(`
158 | 		denied_resources = [ { group = "project.openshift.io", version = "v1" } ]
159 | 	`)))
160 | 	testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer, before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
161 | 		c.withEnvTest()
162 | 		projectsList, _ := c.callTool("projects_list", map[string]interface{}{})
163 | 		t.Run("projects_list has error", func(t *testing.T) {
164 | 			if !projectsList.IsError {
165 | 				t.Fatalf("call tool should fail")
166 | 			}
167 | 		})
168 | 		t.Run("projects_list describes denial", func(t *testing.T) {
169 | 			expectedMessage := "failed to list projects: resource not allowed: project.openshift.io/v1, Kind=Project"
170 | 			if projectsList.Content[0].(mcp.TextContent).Text != expectedMessage {
171 | 				t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, projectsList.Content[0].(mcp.TextContent).Text)
172 | 			}
173 | 		})
174 | 	})
175 | }
176 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/provider_test.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"os"
  5 | 	"strings"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
  9 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 10 | 	"github.com/stretchr/testify/suite"
 11 | 	"k8s.io/client-go/rest"
 12 | )
 13 | 
 14 | type BaseProviderSuite struct {
 15 | 	suite.Suite
 16 | 	originalProviderFactories map[string]ProviderFactory
 17 | }
 18 | 
 19 | func (s *BaseProviderSuite) SetupTest() {
 20 | 	s.originalProviderFactories = make(map[string]ProviderFactory)
 21 | 	for k, v := range providerFactories {
 22 | 		s.originalProviderFactories[k] = v
 23 | 	}
 24 | }
 25 | 
 26 | func (s *BaseProviderSuite) TearDownTest() {
 27 | 	providerFactories = make(map[string]ProviderFactory)
 28 | 	for k, v := range s.originalProviderFactories {
 29 | 		providerFactories[k] = v
 30 | 	}
 31 | }
 32 | 
 33 | type ProviderTestSuite struct {
 34 | 	BaseProviderSuite
 35 | 	originalEnv             []string
 36 | 	originalInClusterConfig func() (*rest.Config, error)
 37 | 	mockServer              *test.MockServer
 38 | 	kubeconfigPath          string
 39 | }
 40 | 
 41 | func (s *ProviderTestSuite) SetupTest() {
 42 | 	s.BaseProviderSuite.SetupTest()
 43 | 	s.originalEnv = os.Environ()
 44 | 	s.originalInClusterConfig = InClusterConfig
 45 | 	s.mockServer = test.NewMockServer()
 46 | 	s.kubeconfigPath = strings.ReplaceAll(s.mockServer.KubeconfigFile(s.T()), `\`, `\\`)
 47 | }
 48 | 
 49 | func (s *ProviderTestSuite) TearDownTest() {
 50 | 	s.BaseProviderSuite.TearDownTest()
 51 | 	test.RestoreEnv(s.originalEnv)
 52 | 	InClusterConfig = s.originalInClusterConfig
 53 | 	if s.mockServer != nil {
 54 | 		s.mockServer.Close()
 55 | 	}
 56 | }
 57 | 
 58 | func (s *ProviderTestSuite) TestNewProviderInCluster() {
 59 | 	InClusterConfig = func() (*rest.Config, error) {
 60 | 		return &rest.Config{}, nil
 61 | 	}
 62 | 	s.Run("With no cluster_provider_strategy, returns single-cluster provider", func() {
 63 | 		cfg := test.Must(config.ReadToml([]byte{}))
 64 | 		provider, err := NewProvider(cfg)
 65 | 		s.Require().NoError(err, "Expected no error for in-cluster provider")
 66 | 		s.NotNil(provider, "Expected provider instance")
 67 | 		s.IsType(&singleClusterProvider{}, provider, "Expected singleClusterProvider type")
 68 | 	})
 69 | 	s.Run("With cluster_provider_strategy=in-cluster, returns single-cluster provider", func() {
 70 | 		cfg := test.Must(config.ReadToml([]byte(`
 71 | 			cluster_provider_strategy = "in-cluster"
 72 | 		`)))
 73 | 		provider, err := NewProvider(cfg)
 74 | 		s.Require().NoError(err, "Expected no error for single-cluster strategy")
 75 | 		s.NotNil(provider, "Expected provider instance")
 76 | 		s.IsType(&singleClusterProvider{}, provider, "Expected singleClusterProvider type")
 77 | 	})
 78 | 	s.Run("With cluster_provider_strategy=kubeconfig, returns error", func() {
 79 | 		cfg := test.Must(config.ReadToml([]byte(`
 80 | 			cluster_provider_strategy = "kubeconfig"
 81 | 		`)))
 82 | 		provider, err := NewProvider(cfg)
 83 | 		s.Require().Error(err, "Expected error for kubeconfig strategy")
 84 | 		s.ErrorContains(err, "kubeconfig ClusterProviderStrategy is invalid for in-cluster deployments")
 85 | 		s.Nilf(provider, "Expected no provider instance, got %v", provider)
 86 | 	})
 87 | 	s.Run("With cluster_provider_strategy=kubeconfig and kubeconfig set to valid path, returns kubeconfig provider", func() {
 88 | 		cfg := test.Must(config.ReadToml([]byte(`
 89 | 			cluster_provider_strategy = "kubeconfig"
 90 | 			kubeconfig = "` + s.kubeconfigPath + `"
 91 | 		`)))
 92 | 		provider, err := NewProvider(cfg)
 93 | 		s.Require().NoError(err, "Expected no error for kubeconfig strategy")
 94 | 		s.NotNil(provider, "Expected provider instance")
 95 | 		s.IsType(&kubeConfigClusterProvider{}, provider, "Expected kubeConfigClusterProvider type")
 96 | 	})
 97 | 	s.Run("With cluster_provider_strategy=non-existent, returns error", func() {
 98 | 		cfg := test.Must(config.ReadToml([]byte(`
 99 | 			cluster_provider_strategy = "i-do-not-exist"
100 | 		`)))
101 | 		provider, err := NewProvider(cfg)
102 | 		s.Require().Error(err, "Expected error for non-existent strategy")
103 | 		s.ErrorContains(err, "no provider registered for strategy 'i-do-not-exist'")
104 | 		s.Nilf(provider, "Expected no provider instance, got %v", provider)
105 | 	})
106 | }
107 | 
108 | func (s *ProviderTestSuite) TestNewProviderLocal() {
109 | 	InClusterConfig = func() (*rest.Config, error) {
110 | 		return nil, rest.ErrNotInCluster
111 | 	}
112 | 	s.Require().NoError(os.Setenv("KUBECONFIG", s.kubeconfigPath))
113 | 	s.Run("With no cluster_provider_strategy, returns kubeconfig provider", func() {
114 | 		cfg := test.Must(config.ReadToml([]byte{}))
115 | 		provider, err := NewProvider(cfg)
116 | 		s.Require().NoError(err, "Expected no error for kubeconfig provider")
117 | 		s.NotNil(provider, "Expected provider instance")
118 | 		s.IsType(&kubeConfigClusterProvider{}, provider, "Expected kubeConfigClusterProvider type")
119 | 	})
120 | 	s.Run("With cluster_provider_strategy=kubeconfig, returns kubeconfig provider", func() {
121 | 		cfg := test.Must(config.ReadToml([]byte(`
122 | 			cluster_provider_strategy = "kubeconfig"
123 | 		`)))
124 | 		provider, err := NewProvider(cfg)
125 | 		s.Require().NoError(err, "Expected no error for kubeconfig provider")
126 | 		s.NotNil(provider, "Expected provider instance")
127 | 		s.IsType(&kubeConfigClusterProvider{}, provider, "Expected kubeConfigClusterProvider type")
128 | 	})
129 | 	s.Run("With cluster_provider_strategy=disabled, returns single-cluster provider", func() {
130 | 		cfg := test.Must(config.ReadToml([]byte(`
131 | 			cluster_provider_strategy = "disabled"
132 | 		`)))
133 | 		provider, err := NewProvider(cfg)
134 | 		s.Require().NoError(err, "Expected no error for disabled strategy")
135 | 		s.NotNil(provider, "Expected provider instance")
136 | 		s.IsType(&singleClusterProvider{}, provider, "Expected singleClusterProvider type")
137 | 	})
138 | 	s.Run("With cluster_provider_strategy=in-cluster, returns error", func() {
139 | 		cfg := test.Must(config.ReadToml([]byte(`
140 | 			cluster_provider_strategy = "in-cluster"
141 | 		`)))
142 | 		provider, err := NewProvider(cfg)
143 | 		s.Require().Error(err, "Expected error for in-cluster strategy")
144 | 		s.ErrorContains(err, "server must be deployed in cluster for the in-cluster ClusterProviderStrategy")
145 | 		s.Nilf(provider, "Expected no provider instance, got %v", provider)
146 | 	})
147 | 	s.Run("With cluster_provider_strategy=in-cluster and kubeconfig set to valid path, returns error", func() {
148 | 		cfg := test.Must(config.ReadToml([]byte(`
149 | 			kubeconfig = "` + s.kubeconfigPath + `"
150 | 			cluster_provider_strategy = "in-cluster"
151 | 		`)))
152 | 		provider, err := NewProvider(cfg)
153 | 		s.Require().Error(err, "Expected error for in-cluster strategy")
154 | 		s.Regexp("kubeconfig file .+ cannot be used with the in-cluster ClusterProviderStrategy", err.Error())
155 | 		s.Nilf(provider, "Expected no provider instance, got %v", provider)
156 | 	})
157 | 	s.Run("With cluster_provider_strategy=non-existent, returns error", func() {
158 | 		cfg := test.Must(config.ReadToml([]byte(`
159 | 			cluster_provider_strategy = "i-do-not-exist"
160 | 		`)))
161 | 		provider, err := NewProvider(cfg)
162 | 		s.Require().Error(err, "Expected error for non-existent strategy")
163 | 		s.ErrorContains(err, "no provider registered for strategy 'i-do-not-exist'")
164 | 		s.Nilf(provider, "Expected no provider instance, got %v", provider)
165 | 	})
166 | }
167 | 
168 | func TestProvider(t *testing.T) {
169 | 	suite.Run(t, new(ProviderTestSuite))
170 | }
171 | 
```

--------------------------------------------------------------------------------
/pkg/http/sts_test.go:
--------------------------------------------------------------------------------

```go
  1 | package http
  2 | 
  3 | import (
  4 | 	"encoding/base64"
  5 | 	"fmt"
  6 | 	"net/http"
  7 | 	"strings"
  8 | 	"testing"
  9 | 
 10 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 11 | 	"github.com/coreos/go-oidc/v3/oidc"
 12 | 	"golang.org/x/oauth2"
 13 | )
 14 | 
 15 | func TestIsEnabled(t *testing.T) {
 16 | 	disabledCases := []SecurityTokenService{
 17 | 		{},
 18 | 		{Provider: nil},
 19 | 		{Provider: &oidc.Provider{}},
 20 | 		{Provider: &oidc.Provider{}, ClientId: "test-client-id", ClientSecret: "test-client-secret"},
 21 | 		{ClientId: "test-client-id", ClientSecret: "test-client-secret", ExternalAccountAudience: "test-audience"},
 22 | 		{Provider: &oidc.Provider{}, ClientSecret: "test-client-secret", ExternalAccountAudience: "test-audience"},
 23 | 	}
 24 | 	for _, sts := range disabledCases {
 25 | 		t.Run(fmt.Sprintf("SecurityTokenService{%+v}.IsEnabled() = false", sts), func(t *testing.T) {
 26 | 			if sts.IsEnabled() {
 27 | 				t.Errorf("SecurityTokenService{%+v}.IsEnabled() = true; want false", sts)
 28 | 			}
 29 | 		})
 30 | 	}
 31 | 	enabledCases := []SecurityTokenService{
 32 | 		{Provider: &oidc.Provider{}, ClientId: "test-client-id", ExternalAccountAudience: "test-audience"},
 33 | 		{Provider: &oidc.Provider{}, ClientId: "test-client-id", ExternalAccountAudience: "test-audience", ClientSecret: "test-client-secret"},
 34 | 		{Provider: &oidc.Provider{}, ClientId: "test-client-id", ExternalAccountAudience: "test-audience", ClientSecret: "test-client-secret", ExternalAccountScopes: []string{"test-scope"}},
 35 | 	}
 36 | 	for _, sts := range enabledCases {
 37 | 		t.Run(fmt.Sprintf("SecurityTokenService{%+v}.IsEnabled() = true", sts), func(t *testing.T) {
 38 | 			if !sts.IsEnabled() {
 39 | 				t.Errorf("SecurityTokenService{%+v}.IsEnabled() = false; want true", sts)
 40 | 			}
 41 | 		})
 42 | 	}
 43 | }
 44 | 
 45 | func TestExternalAccountTokenExchange(t *testing.T) {
 46 | 	mockServer := test.NewMockServer()
 47 | 	authServer := mockServer.Config().Host
 48 | 	var tokenExchangeRequest *http.Request
 49 | 	mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 50 | 		if req.URL.Path == "/.well-known/openid-configuration" {
 51 | 			w.Header().Set("Content-Type", "application/json")
 52 | 			_, _ = fmt.Fprintf(w, `{
 53 | 				"issuer": "%s",
 54 | 				"authorization_endpoint": "https://mock-oidc-provider/authorize",
 55 | 				"token_endpoint": "%s/token"
 56 | 			}`, authServer, authServer)
 57 | 			return
 58 | 		}
 59 | 		if req.URL.Path == "/token" {
 60 | 			tokenExchangeRequest = req
 61 | 			_ = tokenExchangeRequest.ParseForm()
 62 | 			if tokenExchangeRequest.PostForm.Get("subject_token") != "the-original-access-token" {
 63 | 				http.Error(w, "Invalid subject_token", http.StatusUnauthorized)
 64 | 				return
 65 | 			}
 66 | 			w.Header().Set("Content-Type", "application/json")
 67 | 			_, _ = w.Write([]byte(`{"access_token":"exchanged-access-token","token_type":"Bearer","expires_in":253402297199}`))
 68 | 			return
 69 | 		}
 70 | 	}))
 71 | 	t.Cleanup(mockServer.Close)
 72 | 	provider, err := oidc.NewProvider(t.Context(), authServer)
 73 | 	if err != nil {
 74 | 		t.Fatalf("oidc.NewProvider() error = %v; want nil", err)
 75 | 	}
 76 | 	// With missing Token Source information
 77 | 	_, err = (&SecurityTokenService{Provider: provider}).ExternalAccountTokenExchange(t.Context(), &oauth2.Token{})
 78 | 	t.Run("ExternalAccountTokenExchange with missing token source returns error", func(t *testing.T) {
 79 | 		if err == nil {
 80 | 			t.Fatalf("ExternalAccountTokenExchange() error = nil; want error")
 81 | 		}
 82 | 		if !strings.Contains(err.Error(), "must be set") {
 83 | 			t.Errorf("ExternalAccountTokenExchange() error = %v; want missing required field", err)
 84 | 		}
 85 | 	})
 86 | 	// With valid Token Source information
 87 | 	sts := SecurityTokenService{
 88 | 		Provider:                provider,
 89 | 		ClientId:                "test-client-id",
 90 | 		ClientSecret:            "test-client-secret",
 91 | 		ExternalAccountAudience: "test-audience",
 92 | 		ExternalAccountScopes:   []string{"test-scope"},
 93 | 	}
 94 | 	// With Invalid token
 95 | 	_, err = sts.ExternalAccountTokenExchange(t.Context(), &oauth2.Token{
 96 | 		AccessToken: "invalid-access-token",
 97 | 		TokenType:   "Bearer",
 98 | 	})
 99 | 	t.Run("ExternalAccountTokenExchange with invalid token returns error", func(t *testing.T) {
100 | 		if err == nil {
101 | 			t.Fatalf("ExternalAccountTokenExchange() error = nil; want error")
102 | 		}
103 | 		if !strings.Contains(err.Error(), "status code 401: Invalid subject_token") {
104 | 			t.Errorf("ExternalAccountTokenExchange() error = %v; want invalid_grant: Invalid subject_token", err)
105 | 		}
106 | 	})
107 | 	// With Valid token
108 | 	exchangeToken, err := sts.ExternalAccountTokenExchange(t.Context(), &oauth2.Token{
109 | 		AccessToken: "the-original-access-token",
110 | 		TokenType:   "Bearer",
111 | 	})
112 | 	t.Run("ExternalAccountTokenExchange with valid token returns new token", func(t *testing.T) {
113 | 		if err != nil {
114 | 			t.Errorf("ExternalAccountTokenExchange() error = %v; want nil", err)
115 | 		}
116 | 		if exchangeToken == nil {
117 | 			t.Fatal("ExternalAccountTokenExchange() = nil; want token")
118 | 		}
119 | 		if exchangeToken.AccessToken != "exchanged-access-token" {
120 | 			t.Errorf("exchangeToken.AccessToken = %s; want exchanged-access-token", exchangeToken.AccessToken)
121 | 		}
122 | 	})
123 | 	t.Run("ExternalAccountTokenExchange with valid token sends POST request", func(t *testing.T) {
124 | 		if tokenExchangeRequest == nil {
125 | 			t.Fatal("tokenExchangeRequest is nil; want request")
126 | 		}
127 | 		if tokenExchangeRequest.Method != "POST" {
128 | 			t.Errorf("tokenExchangeRequest.Method = %s; want POST", tokenExchangeRequest.Method)
129 | 		}
130 | 	})
131 | 	t.Run("ExternalAccountTokenExchange with valid token has correct form data", func(t *testing.T) {
132 | 		if tokenExchangeRequest.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
133 | 			t.Errorf("tokenExchangeRequest.Content-Type = %s; want application/x-www-form-urlencoded", tokenExchangeRequest.Header.Get("Content-Type"))
134 | 		}
135 | 		if tokenExchangeRequest.PostForm.Get("audience") != "test-audience" {
136 | 			t.Errorf("tokenExchangeRequest.PostForm[audience] = %s; want test-audience", tokenExchangeRequest.PostForm.Get("audience"))
137 | 		}
138 | 		if tokenExchangeRequest.PostForm.Get("subject_token_type") != "urn:ietf:params:oauth:token-type:access_token" {
139 | 			t.Errorf("tokenExchangeRequest.PostForm[subject_token_type] = %s; want urn:ietf:params:oauth:token-type:access_token", tokenExchangeRequest.PostForm.Get("subject_token_type"))
140 | 		}
141 | 		if tokenExchangeRequest.PostForm.Get("subject_token") != "the-original-access-token" {
142 | 			t.Errorf("tokenExchangeRequest.PostForm[subject_token] = %s; want the-original-access-token", tokenExchangeRequest.PostForm.Get("subject_token"))
143 | 		}
144 | 		if len(tokenExchangeRequest.PostForm["scope"]) == 0 || tokenExchangeRequest.PostForm["scope"][0] != "test-scope" {
145 | 			t.Errorf("tokenExchangeRequest.PostForm[scope] = %v; want [test-scope]", tokenExchangeRequest.PostForm["scope"])
146 | 		}
147 | 	})
148 | 	t.Run("ExternalAccountTokenExchange with valid token sends correct client credentials header", func(t *testing.T) {
149 | 		if tokenExchangeRequest.Header.Get("Authorization") != "Basic "+base64.StdEncoding.EncodeToString([]byte("test-client-id:test-client-secret")) {
150 | 			t.Errorf("tokenExchangeRequest.Header[Authorization] = %s; want Basic base64(test-client-id:test-client-secret)", tokenExchangeRequest.Header.Get("Authorization"))
151 | 		}
152 | 	})
153 | }
154 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/provider_kubeconfig_test.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"net/http"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
  9 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 10 | 	"github.com/stretchr/testify/suite"
 11 | 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
 12 | )
 13 | 
 14 | type ProviderKubeconfigTestSuite struct {
 15 | 	BaseProviderSuite
 16 | 	mockServer *test.MockServer
 17 | 	provider   Provider
 18 | }
 19 | 
 20 | func (s *ProviderKubeconfigTestSuite) SetupTest() {
 21 | 	// Kubeconfig provider is used when the multi-cluster feature is enabled with the kubeconfig strategy.
 22 | 	// For this test suite we simulate a kubeconfig with multiple contexts.
 23 | 	s.mockServer = test.NewMockServer()
 24 | 	kubeconfig := s.mockServer.Kubeconfig()
 25 | 	for i := 0; i < 10; i++ {
 26 | 		// Add multiple fake contexts to force multi-cluster behavior
 27 | 		kubeconfig.Contexts[fmt.Sprintf("context-%d", i)] = clientcmdapi.NewContext()
 28 | 	}
 29 | 	provider, err := NewProvider(&config.StaticConfig{KubeConfig: test.KubeconfigFile(s.T(), kubeconfig)})
 30 | 	s.Require().NoError(err, "Expected no error creating provider with kubeconfig")
 31 | 	s.provider = provider
 32 | }
 33 | 
 34 | func (s *ProviderKubeconfigTestSuite) TearDownTest() {
 35 | 	if s.mockServer != nil {
 36 | 		s.mockServer.Close()
 37 | 	}
 38 | }
 39 | 
 40 | func (s *ProviderKubeconfigTestSuite) TestType() {
 41 | 	s.IsType(&kubeConfigClusterProvider{}, s.provider)
 42 | }
 43 | 
 44 | func (s *ProviderKubeconfigTestSuite) TestWithNonOpenShiftCluster() {
 45 | 	s.Run("IsOpenShift returns false", func() {
 46 | 		inOpenShift := s.provider.IsOpenShift(s.T().Context())
 47 | 		s.False(inOpenShift, "Expected InOpenShift to return false")
 48 | 	})
 49 | }
 50 | 
 51 | func (s *ProviderKubeconfigTestSuite) TestWithOpenShiftCluster() {
 52 | 	s.mockServer.Handle(&test.InOpenShiftHandler{})
 53 | 	s.Run("IsOpenShift returns true", func() {
 54 | 		inOpenShift := s.provider.IsOpenShift(s.T().Context())
 55 | 		s.True(inOpenShift, "Expected InOpenShift to return true")
 56 | 	})
 57 | }
 58 | 
 59 | func (s *ProviderKubeconfigTestSuite) TestVerifyToken() {
 60 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 61 | 		if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
 62 | 			w.Header().Set("Content-Type", "application/json")
 63 | 			_, _ = w.Write([]byte(`
 64 | 				{
 65 | 					"kind": "TokenReview",
 66 | 					"apiVersion": "authentication.k8s.io/v1",
 67 | 					"spec": {"token": "the-token"},
 68 | 					"status": {
 69 | 						"authenticated": true,
 70 | 						"user": {
 71 | 							"username": "test-user",
 72 | 							"groups": ["system:authenticated"]
 73 | 						},
 74 | 						"audiences": ["the-audience"]
 75 | 					}
 76 | 				}`))
 77 | 		}
 78 | 	}))
 79 | 	s.Run("VerifyToken returns UserInfo for non-empty context", func() {
 80 | 		userInfo, audiences, err := s.provider.VerifyToken(s.T().Context(), "fake-context", "some-token", "the-audience")
 81 | 		s.Require().NoError(err, "Expected no error from VerifyToken with empty target")
 82 | 		s.Require().NotNil(userInfo, "Expected UserInfo from VerifyToken with empty target")
 83 | 		s.Equalf(userInfo.Username, "test-user", "Expected username test-user, got: %s", userInfo.Username)
 84 | 		s.Containsf(userInfo.Groups, "system:authenticated", "Expected group system:authenticated in %v", userInfo.Groups)
 85 | 		s.Require().NotNil(audiences, "Expected audiences from VerifyToken with empty target")
 86 | 		s.Len(audiences, 1, "Expected audiences from VerifyToken with empty target")
 87 | 		s.Containsf(audiences, "the-audience", "Expected audience the-audience in %v", audiences)
 88 | 	})
 89 | 	s.Run("VerifyToken returns UserInfo for empty context (default context)", func() {
 90 | 		userInfo, audiences, err := s.provider.VerifyToken(s.T().Context(), "", "the-token", "the-audience")
 91 | 		s.Require().NoError(err, "Expected no error from VerifyToken with empty target")
 92 | 		s.Require().NotNil(userInfo, "Expected UserInfo from VerifyToken with empty target")
 93 | 		s.Equalf(userInfo.Username, "test-user", "Expected username test-user, got: %s", userInfo.Username)
 94 | 		s.Containsf(userInfo.Groups, "system:authenticated", "Expected group system:authenticated in %v", userInfo.Groups)
 95 | 		s.Require().NotNil(audiences, "Expected audiences from VerifyToken with empty target")
 96 | 		s.Len(audiences, 1, "Expected audiences from VerifyToken with empty target")
 97 | 		s.Containsf(audiences, "the-audience", "Expected audience the-audience in %v", audiences)
 98 | 	})
 99 | 	s.Run("VerifyToken returns error for invalid context", func() {
100 | 		userInfo, audiences, err := s.provider.VerifyToken(s.T().Context(), "invalid-context", "some-token", "the-audience")
101 | 		s.Require().Error(err, "Expected error from VerifyToken with invalid target")
102 | 		s.ErrorContainsf(err, `context "invalid-context" does not exist`, "Expected context does not exist error, got: %v", err)
103 | 		s.Nil(userInfo, "Expected no UserInfo from VerifyToken with invalid target")
104 | 		s.Nil(audiences, "Expected no audiences from VerifyToken with invalid target")
105 | 	})
106 | }
107 | 
108 | func (s *ProviderKubeconfigTestSuite) TestGetTargets() {
109 | 	s.Run("GetTargets returns all contexts defined in kubeconfig", func() {
110 | 		targets, err := s.provider.GetTargets(s.T().Context())
111 | 		s.Require().NoError(err, "Expected no error from GetTargets")
112 | 		s.Len(targets, 11, "Expected 11 targets from GetTargets")
113 | 		s.Contains(targets, "fake-context", "Expected fake-context in targets from GetTargets")
114 | 		for i := 0; i < 10; i++ {
115 | 			s.Contains(targets, fmt.Sprintf("context-%d", i), "Expected context-%d in targets from GetTargets", i)
116 | 		}
117 | 	})
118 | }
119 | 
120 | func (s *ProviderKubeconfigTestSuite) TestGetDerivedKubernetes() {
121 | 	s.Run("GetDerivedKubernetes returns Kubernetes for valid context", func() {
122 | 		k8s, err := s.provider.GetDerivedKubernetes(s.T().Context(), "fake-context")
123 | 		s.Require().NoError(err, "Expected no error from GetDerivedKubernetes with valid context")
124 | 		s.NotNil(k8s, "Expected Kubernetes from GetDerivedKubernetes with valid context")
125 | 	})
126 | 	s.Run("GetDerivedKubernetes returns Kubernetes for empty context (default)", func() {
127 | 		k8s, err := s.provider.GetDerivedKubernetes(s.T().Context(), "")
128 | 		s.Require().NoError(err, "Expected no error from GetDerivedKubernetes with empty context")
129 | 		s.NotNil(k8s, "Expected Kubernetes from GetDerivedKubernetes with empty context")
130 | 	})
131 | 	s.Run("GetDerivedKubernetes returns error for invalid context", func() {
132 | 		k8s, err := s.provider.GetDerivedKubernetes(s.T().Context(), "invalid-context")
133 | 		s.Require().Error(err, "Expected error from GetDerivedKubernetes with invalid context")
134 | 		s.ErrorContainsf(err, `context "invalid-context" does not exist`, "Expected context does not exist error, got: %v", err)
135 | 		s.Nil(k8s, "Expected no Kubernetes from GetDerivedKubernetes with invalid context")
136 | 	})
137 | }
138 | 
139 | func (s *ProviderKubeconfigTestSuite) TestGetDefaultTarget() {
140 | 	s.Run("GetDefaultTarget returns current-context defined in kubeconfig", func() {
141 | 		s.Equal("fake-context", s.provider.GetDefaultTarget(), "Expected fake-context as default target")
142 | 	})
143 | }
144 | 
145 | func (s *ProviderKubeconfigTestSuite) TestGetTargetParameterName() {
146 | 	s.Equal("context", s.provider.GetTargetParameterName(), "Expected context as target parameter name")
147 | }
148 | 
149 | func TestProviderKubeconfig(t *testing.T) {
150 | 	suite.Run(t, new(ProviderKubeconfigTestSuite))
151 | }
152 | 
```

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

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"context"
  6 | 	"fmt"
  7 | 	"net/http"
  8 | 	"slices"
  9 | 
 10 | 	"github.com/mark3labs/mcp-go/mcp"
 11 | 	"github.com/mark3labs/mcp-go/server"
 12 | 	authenticationapiv1 "k8s.io/api/authentication/v1"
 13 | 	"k8s.io/klog/v2"
 14 | 	"k8s.io/utils/ptr"
 15 | 
 16 | 	"github.com/containers/kubernetes-mcp-server/pkg/api"
 17 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 18 | 	internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
 19 | 	"github.com/containers/kubernetes-mcp-server/pkg/output"
 20 | 	"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
 21 | 	"github.com/containers/kubernetes-mcp-server/pkg/version"
 22 | )
 23 | 
 24 | type ContextKey string
 25 | 
 26 | const TokenScopesContextKey = ContextKey("TokenScopesContextKey")
 27 | 
 28 | type Configuration struct {
 29 | 	*config.StaticConfig
 30 | 	listOutput output.Output
 31 | 	toolsets   []api.Toolset
 32 | }
 33 | 
 34 | func (c *Configuration) Toolsets() []api.Toolset {
 35 | 	if c.toolsets == nil {
 36 | 		for _, toolset := range c.StaticConfig.Toolsets {
 37 | 			c.toolsets = append(c.toolsets, toolsets.ToolsetFromString(toolset))
 38 | 		}
 39 | 	}
 40 | 	return c.toolsets
 41 | }
 42 | 
 43 | func (c *Configuration) ListOutput() output.Output {
 44 | 	if c.listOutput == nil {
 45 | 		c.listOutput = output.FromString(c.StaticConfig.ListOutput)
 46 | 	}
 47 | 	return c.listOutput
 48 | }
 49 | 
 50 | func (c *Configuration) isToolApplicable(tool api.ServerTool) bool {
 51 | 	if c.ReadOnly && !ptr.Deref(tool.Tool.Annotations.ReadOnlyHint, false) {
 52 | 		return false
 53 | 	}
 54 | 	if c.DisableDestructive && ptr.Deref(tool.Tool.Annotations.DestructiveHint, false) {
 55 | 		return false
 56 | 	}
 57 | 	if c.EnabledTools != nil && !slices.Contains(c.EnabledTools, tool.Tool.Name) {
 58 | 		return false
 59 | 	}
 60 | 	if c.DisabledTools != nil && slices.Contains(c.DisabledTools, tool.Tool.Name) {
 61 | 		return false
 62 | 	}
 63 | 	return true
 64 | }
 65 | 
 66 | type Server struct {
 67 | 	configuration *Configuration
 68 | 	server        *server.MCPServer
 69 | 	enabledTools  []string
 70 | 	p             internalk8s.Provider
 71 | }
 72 | 
 73 | func NewServer(configuration Configuration) (*Server, error) {
 74 | 	var serverOptions []server.ServerOption
 75 | 	serverOptions = append(serverOptions,
 76 | 		server.WithResourceCapabilities(true, true),
 77 | 		server.WithPromptCapabilities(true),
 78 | 		server.WithToolCapabilities(true),
 79 | 		server.WithLogging(),
 80 | 		server.WithToolHandlerMiddleware(toolCallLoggingMiddleware),
 81 | 	)
 82 | 	if configuration.RequireOAuth && false { // TODO: Disabled scope auth validation for now
 83 | 		serverOptions = append(serverOptions, server.WithToolHandlerMiddleware(toolScopedAuthorizationMiddleware))
 84 | 	}
 85 | 
 86 | 	s := &Server{
 87 | 		configuration: &configuration,
 88 | 		server: server.NewMCPServer(
 89 | 			version.BinaryName,
 90 | 			version.Version,
 91 | 			serverOptions...,
 92 | 		),
 93 | 	}
 94 | 	if err := s.reloadKubernetesClusterProvider(); err != nil {
 95 | 		return nil, err
 96 | 	}
 97 | 	s.p.WatchTargets(s.reloadKubernetesClusterProvider)
 98 | 
 99 | 	return s, nil
100 | }
101 | 
102 | func (s *Server) reloadKubernetesClusterProvider() error {
103 | 	ctx := context.Background()
104 | 	p, err := internalk8s.NewProvider(s.configuration.StaticConfig)
105 | 	if err != nil {
106 | 		return err
107 | 	}
108 | 
109 | 	// close the old provider
110 | 	if s.p != nil {
111 | 		s.p.Close()
112 | 	}
113 | 
114 | 	s.p = p
115 | 
116 | 	targets, err := p.GetTargets(ctx)
117 | 	if err != nil {
118 | 		return err
119 | 	}
120 | 
121 | 	filter := CompositeFilter(
122 | 		s.configuration.isToolApplicable,
123 | 		ShouldIncludeTargetListTool(p.GetTargetParameterName(), targets),
124 | 	)
125 | 
126 | 	mutator := WithTargetParameter(
127 | 		p.GetDefaultTarget(),
128 | 		p.GetTargetParameterName(),
129 | 		targets,
130 | 	)
131 | 
132 | 	applicableTools := make([]api.ServerTool, 0)
133 | 	for _, toolset := range s.configuration.Toolsets() {
134 | 		for _, tool := range toolset.GetTools(p) {
135 | 			tool := mutator(tool)
136 | 			if !filter(tool) {
137 | 				continue
138 | 			}
139 | 
140 | 			applicableTools = append(applicableTools, tool)
141 | 			s.enabledTools = append(s.enabledTools, tool.Tool.Name)
142 | 		}
143 | 	}
144 | 	m3labsServerTools, err := ServerToolToM3LabsServerTool(s, applicableTools)
145 | 	if err != nil {
146 | 		return fmt.Errorf("failed to convert tools: %v", err)
147 | 	}
148 | 
149 | 	s.server.SetTools(m3labsServerTools...)
150 | 
151 | 	// start new watch
152 | 	s.p.WatchTargets(s.reloadKubernetesClusterProvider)
153 | 	return nil
154 | }
155 | 
156 | func (s *Server) ServeStdio() error {
157 | 	return server.ServeStdio(s.server)
158 | }
159 | 
160 | func (s *Server) ServeSse(baseUrl string, httpServer *http.Server) *server.SSEServer {
161 | 	options := make([]server.SSEOption, 0)
162 | 	options = append(options, server.WithSSEContextFunc(contextFunc), server.WithHTTPServer(httpServer))
163 | 	if baseUrl != "" {
164 | 		options = append(options, server.WithBaseURL(baseUrl))
165 | 	}
166 | 	return server.NewSSEServer(s.server, options...)
167 | }
168 | 
169 | func (s *Server) ServeHTTP(httpServer *http.Server) *server.StreamableHTTPServer {
170 | 	options := []server.StreamableHTTPOption{
171 | 		server.WithHTTPContextFunc(contextFunc),
172 | 		server.WithStreamableHTTPServer(httpServer),
173 | 		server.WithStateLess(true),
174 | 	}
175 | 	return server.NewStreamableHTTPServer(s.server, options...)
176 | }
177 | 
178 | // KubernetesApiVerifyToken verifies the given token with the audience by
179 | // sending an TokenReview request to API Server for the specified cluster.
180 | func (s *Server) KubernetesApiVerifyToken(ctx context.Context, cluster, token, audience string) (*authenticationapiv1.UserInfo, []string, error) {
181 | 	if s.p == nil {
182 | 		return nil, nil, fmt.Errorf("kubernetes cluster provider is not initialized")
183 | 	}
184 | 	return s.p.VerifyToken(ctx, cluster, token, audience)
185 | }
186 | 
187 | // GetTargetParameterName returns the parameter name used for target identification in MCP requests
188 | func (s *Server) GetTargetParameterName() string {
189 | 	if s.p == nil {
190 | 		return "" // fallback for uninitialized provider
191 | 	}
192 | 	return s.p.GetTargetParameterName()
193 | }
194 | 
195 | func (s *Server) GetEnabledTools() []string {
196 | 	return s.enabledTools
197 | }
198 | 
199 | func (s *Server) Close() {
200 | 	if s.p != nil {
201 | 		s.p.Close()
202 | 	}
203 | }
204 | 
205 | func NewTextResult(content string, err error) *mcp.CallToolResult {
206 | 	if err != nil {
207 | 		return &mcp.CallToolResult{
208 | 			IsError: true,
209 | 			Content: []mcp.Content{
210 | 				mcp.TextContent{
211 | 					Type: "text",
212 | 					Text: err.Error(),
213 | 				},
214 | 			},
215 | 		}
216 | 	}
217 | 	return &mcp.CallToolResult{
218 | 		Content: []mcp.Content{
219 | 			mcp.TextContent{
220 | 				Type: "text",
221 | 				Text: content,
222 | 			},
223 | 		},
224 | 	}
225 | }
226 | 
227 | func contextFunc(ctx context.Context, r *http.Request) context.Context {
228 | 	// Get the standard Authorization header (OAuth compliant)
229 | 	authHeader := r.Header.Get(string(internalk8s.OAuthAuthorizationHeader))
230 | 	if authHeader != "" {
231 | 		return context.WithValue(ctx, internalk8s.OAuthAuthorizationHeader, authHeader)
232 | 	}
233 | 
234 | 	// Fallback to custom header for backward compatibility
235 | 	customAuthHeader := r.Header.Get(string(internalk8s.CustomAuthorizationHeader))
236 | 	if customAuthHeader != "" {
237 | 		return context.WithValue(ctx, internalk8s.OAuthAuthorizationHeader, customAuthHeader)
238 | 	}
239 | 
240 | 	return ctx
241 | }
242 | 
243 | func toolCallLoggingMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc {
244 | 	return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
245 | 		klog.V(5).Infof("mcp tool call: %s(%v)", ctr.Params.Name, ctr.Params.Arguments)
246 | 		if ctr.Header != nil {
247 | 			buffer := bytes.NewBuffer(make([]byte, 0))
248 | 			if err := ctr.Header.WriteSubset(buffer, map[string]bool{"Authorization": true, "authorization": true}); err == nil {
249 | 				klog.V(7).Infof("mcp tool call headers: %s", buffer)
250 | 			}
251 | 		}
252 | 		return next(ctx, ctr)
253 | 	}
254 | }
255 | 
256 | func toolScopedAuthorizationMiddleware(next server.ToolHandlerFunc) server.ToolHandlerFunc {
257 | 	return func(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
258 | 		scopes, ok := ctx.Value(TokenScopesContextKey).([]string)
259 | 		if !ok {
260 | 			return NewTextResult("", fmt.Errorf("authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but no scope is available", ctr.Params.Name, ctr.Params.Name)), nil
261 | 		}
262 | 		if !slices.Contains(scopes, "mcp:"+ctr.Params.Name) && !slices.Contains(scopes, ctr.Params.Name) {
263 | 			return NewTextResult("", fmt.Errorf("authorization failed: Access denied: Tool '%s' requires scope 'mcp:%s' but only scopes %s are available", ctr.Params.Name, ctr.Params.Name, scopes)), nil
264 | 		}
265 | 		return next(ctx, ctr)
266 | 	}
267 | }
268 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/resources.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"k8s.io/apimachinery/pkg/runtime"
  7 | 	"regexp"
  8 | 	"strings"
  9 | 
 10 | 	"github.com/containers/kubernetes-mcp-server/pkg/version"
 11 | 	authv1 "k8s.io/api/authorization/v1"
 12 | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 13 | 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 14 | 	metav1beta1 "k8s.io/apimachinery/pkg/apis/meta/v1beta1"
 15 | 	"k8s.io/apimachinery/pkg/runtime/schema"
 16 | 	"k8s.io/apimachinery/pkg/util/yaml"
 17 | )
 18 | 
 19 | const (
 20 | 	AppKubernetesComponent = "app.kubernetes.io/component"
 21 | 	AppKubernetesManagedBy = "app.kubernetes.io/managed-by"
 22 | 	AppKubernetesName      = "app.kubernetes.io/name"
 23 | 	AppKubernetesPartOf    = "app.kubernetes.io/part-of"
 24 | )
 25 | 
 26 | type ResourceListOptions struct {
 27 | 	metav1.ListOptions
 28 | 	AsTable bool
 29 | }
 30 | 
 31 | func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
 32 | 	gvr, err := k.resourceFor(gvk)
 33 | 	if err != nil {
 34 | 		return nil, err
 35 | 	}
 36 | 
 37 | 	// Check if operation is allowed for all namespaces (applicable for namespaced resources)
 38 | 	isNamespaced, _ := k.isNamespaced(gvk)
 39 | 	if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
 40 | 		namespace = k.manager.configuredNamespace()
 41 | 	}
 42 | 	if options.AsTable {
 43 | 		return k.resourcesListAsTable(ctx, gvk, gvr, namespace, options)
 44 | 	}
 45 | 	return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, options.ListOptions)
 46 | }
 47 | 
 48 | func (k *Kubernetes) ResourcesGet(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) (*unstructured.Unstructured, error) {
 49 | 	gvr, err := k.resourceFor(gvk)
 50 | 	if err != nil {
 51 | 		return nil, err
 52 | 	}
 53 | 
 54 | 	// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
 55 | 	if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
 56 | 		namespace = k.NamespaceOrDefault(namespace)
 57 | 	}
 58 | 	return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
 59 | }
 60 | 
 61 | func (k *Kubernetes) ResourcesCreateOrUpdate(ctx context.Context, resource string) ([]*unstructured.Unstructured, error) {
 62 | 	separator := regexp.MustCompile(`\r?\n---\r?\n`)
 63 | 	resources := separator.Split(resource, -1)
 64 | 	var parsedResources []*unstructured.Unstructured
 65 | 	for _, r := range resources {
 66 | 		var obj unstructured.Unstructured
 67 | 		if err := yaml.NewYAMLToJSONDecoder(strings.NewReader(r)).Decode(&obj); err != nil {
 68 | 			return nil, err
 69 | 		}
 70 | 		parsedResources = append(parsedResources, &obj)
 71 | 	}
 72 | 	return k.resourcesCreateOrUpdate(ctx, parsedResources)
 73 | }
 74 | 
 75 | func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersionKind, namespace, name string) error {
 76 | 	gvr, err := k.resourceFor(gvk)
 77 | 	if err != nil {
 78 | 		return err
 79 | 	}
 80 | 
 81 | 	// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
 82 | 	if namespaced, nsErr := k.isNamespaced(gvk); nsErr == nil && namespaced {
 83 | 		namespace = k.NamespaceOrDefault(namespace)
 84 | 	}
 85 | 	return k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
 86 | }
 87 | 
 88 | // resourcesListAsTable retrieves a list of resources in a table format.
 89 | // It's almost identical to the dynamic.DynamicClient implementation, but it uses a specific Accept header to request the table format.
 90 | // dynamic.DynamicClient does not provide a way to set the HTTP header (TODO: create an issue to request this feature)
 91 | func (k *Kubernetes) resourcesListAsTable(ctx context.Context, gvk *schema.GroupVersionKind, gvr *schema.GroupVersionResource, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
 92 | 	var url []string
 93 | 	if len(gvr.Group) == 0 {
 94 | 		url = append(url, "api")
 95 | 	} else {
 96 | 		url = append(url, "apis", gvr.Group)
 97 | 	}
 98 | 	url = append(url, gvr.Version)
 99 | 	if len(namespace) > 0 {
100 | 		url = append(url, "namespaces", namespace)
101 | 	}
102 | 	url = append(url, gvr.Resource)
103 | 	var table metav1.Table
104 | 	err := k.manager.discoveryClient.RESTClient().
105 | 		Get().
106 | 		SetHeader("Accept", strings.Join([]string{
107 | 			fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
108 | 			fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1beta1.SchemeGroupVersion.Version, metav1beta1.GroupName),
109 | 			"application/json",
110 | 		}, ",")).
111 | 		AbsPath(url...).
112 | 		SpecificallyVersionedParams(&options.ListOptions, ParameterCodec, schema.GroupVersion{Version: "v1"}).
113 | 		Do(ctx).Into(&table)
114 | 	if err != nil {
115 | 		return nil, err
116 | 	}
117 | 	// Add metav1.Table apiVersion and kind to the unstructured object (server may not return these fields)
118 | 	table.SetGroupVersionKind(metav1.SchemeGroupVersion.WithKind("Table"))
119 | 	// Add additional columns for fields that aren't returned by the server
120 | 	table.ColumnDefinitions = append([]metav1.TableColumnDefinition{
121 | 		{Name: "apiVersion", Type: "string"},
122 | 		{Name: "kind", Type: "string"},
123 | 	}, table.ColumnDefinitions...)
124 | 	for i := range table.Rows {
125 | 		row := &table.Rows[i]
126 | 		row.Cells = append([]interface{}{
127 | 			gvr.GroupVersion().String(),
128 | 			gvk.Kind,
129 | 		}, row.Cells...)
130 | 	}
131 | 	unstructuredObject, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&table)
132 | 	return &unstructured.Unstructured{Object: unstructuredObject}, err
133 | }
134 | 
135 | func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) ([]*unstructured.Unstructured, error) {
136 | 	for i, obj := range resources {
137 | 		gvk := obj.GroupVersionKind()
138 | 		gvr, rErr := k.resourceFor(&gvk)
139 | 		if rErr != nil {
140 | 			return nil, rErr
141 | 		}
142 | 
143 | 		namespace := obj.GetNamespace()
144 | 		// If it's a namespaced resource and namespace wasn't provided, try to use the default configured one
145 | 		if namespaced, nsErr := k.isNamespaced(&gvk); nsErr == nil && namespaced {
146 | 			namespace = k.NamespaceOrDefault(namespace)
147 | 		}
148 | 		resources[i], rErr = k.manager.dynamicClient.Resource(*gvr).Namespace(namespace).Apply(ctx, obj.GetName(), obj, metav1.ApplyOptions{
149 | 			FieldManager: version.BinaryName,
150 | 		})
151 | 		if rErr != nil {
152 | 			return nil, rErr
153 | 		}
154 | 		// Clear the cache to ensure the next operation is performed on the latest exposed APIs (will change after the CRD creation)
155 | 		if gvk.Kind == "CustomResourceDefinition" {
156 | 			k.manager.accessControlRESTMapper.Reset()
157 | 		}
158 | 	}
159 | 	return resources, nil
160 | }
161 | 
162 | func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {
163 | 	m, err := k.manager.accessControlRESTMapper.RESTMapping(schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind}, gvk.Version)
164 | 	if err != nil {
165 | 		return nil, err
166 | 	}
167 | 	return &m.Resource, nil
168 | }
169 | 
170 | func (k *Kubernetes) isNamespaced(gvk *schema.GroupVersionKind) (bool, error) {
171 | 	apiResourceList, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String())
172 | 	if err != nil {
173 | 		return false, err
174 | 	}
175 | 	for _, apiResource := range apiResourceList.APIResources {
176 | 		if apiResource.Kind == gvk.Kind {
177 | 			return apiResource.Namespaced, nil
178 | 		}
179 | 	}
180 | 	return false, nil
181 | }
182 | 
183 | func (k *Kubernetes) supportsGroupVersion(groupVersion string) bool {
184 | 	if _, err := k.manager.discoveryClient.ServerResourcesForGroupVersion(groupVersion); err != nil {
185 | 		return false
186 | 	}
187 | 	return true
188 | }
189 | 
190 | func (k *Kubernetes) canIUse(ctx context.Context, gvr *schema.GroupVersionResource, namespace, verb string) bool {
191 | 	accessReviews, err := k.manager.accessControlClientSet.SelfSubjectAccessReviews()
192 | 	if err != nil {
193 | 		return false
194 | 	}
195 | 	response, err := accessReviews.Create(ctx, &authv1.SelfSubjectAccessReview{
196 | 		Spec: authv1.SelfSubjectAccessReviewSpec{ResourceAttributes: &authv1.ResourceAttributes{
197 | 			Namespace: namespace,
198 | 			Verb:      verb,
199 | 			Group:     gvr.Group,
200 | 			Version:   gvr.Version,
201 | 			Resource:  gvr.Resource,
202 | 		}},
203 | 	}, metav1.CreateOptions{})
204 | 	if err != nil {
205 | 		// TODO: maybe return the error too
206 | 		return false
207 | 	}
208 | 	return response.Status.Allowed
209 | }
210 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/nodes_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"net/http"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/BurntSushi/toml"
  8 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/stretchr/testify/suite"
 11 | )
 12 | 
 13 | type NodesSuite struct {
 14 | 	BaseMcpSuite
 15 | 	mockServer *test.MockServer
 16 | }
 17 | 
 18 | func (s *NodesSuite) SetupTest() {
 19 | 	s.BaseMcpSuite.SetupTest()
 20 | 	s.mockServer = test.NewMockServer()
 21 | 	s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
 22 | }
 23 | 
 24 | func (s *NodesSuite) TearDownTest() {
 25 | 	s.BaseMcpSuite.TearDownTest()
 26 | 	if s.mockServer != nil {
 27 | 		s.mockServer.Close()
 28 | 	}
 29 | }
 30 | 
 31 | func (s *NodesSuite) TestNodesLog() {
 32 | 	s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
 33 | 		// Get Node response
 34 | 		if req.URL.Path == "/api/v1/nodes/existing-node" {
 35 | 			w.Header().Set("Content-Type", "application/json")
 36 | 			w.WriteHeader(http.StatusOK)
 37 | 			_, _ = w.Write([]byte(`{
 38 | 				"apiVersion": "v1",
 39 | 				"kind": "Node",
 40 | 				"metadata": {
 41 | 					"name": "existing-node"
 42 | 				}
 43 | 			}`))
 44 | 			return
 45 | 		}
 46 | 		// Get Empty Log response
 47 | 		if req.URL.Path == "/api/v1/nodes/existing-node/proxy/logs/empty.log" {
 48 | 			w.Header().Set("Content-Type", "text/plain")
 49 | 			w.WriteHeader(http.StatusOK)
 50 | 			_, _ = w.Write([]byte(``))
 51 | 			return
 52 | 		}
 53 | 		// Get Kubelet Log response
 54 | 		if req.URL.Path == "/api/v1/nodes/existing-node/proxy/logs/kubelet.log" {
 55 | 			w.Header().Set("Content-Type", "text/plain")
 56 | 			w.WriteHeader(http.StatusOK)
 57 | 			logContent := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
 58 | 			if req.URL.Query().Get("tailLines") != "" {
 59 | 				logContent = "Line 4\nLine 5\n"
 60 | 			}
 61 | 			_, _ = w.Write([]byte(logContent))
 62 | 			return
 63 | 		}
 64 | 		w.WriteHeader(http.StatusNotFound)
 65 | 	}))
 66 | 	s.InitMcpClient()
 67 | 	s.Run("nodes_log(name=nil)", func() {
 68 | 		toolResult, err := s.CallTool("nodes_log", map[string]interface{}{})
 69 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
 70 | 		s.Run("has error", func() {
 71 | 			s.Truef(toolResult.IsError, "call tool should fail")
 72 | 			s.Nilf(err, "call tool should not return error object")
 73 | 		})
 74 | 		s.Run("describes missing name", func() {
 75 | 			expectedMessage := "failed to get node log, missing argument name"
 76 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
 77 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
 78 | 		})
 79 | 	})
 80 | 	s.Run("nodes_log(name=inexistent-node)", func() {
 81 | 		toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
 82 | 			"name": "inexistent-node",
 83 | 		})
 84 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
 85 | 		s.Run("has error", func() {
 86 | 			s.Truef(toolResult.IsError, "call tool should fail")
 87 | 			s.Nilf(err, "call tool should not return error object")
 88 | 		})
 89 | 		s.Run("describes missing node", func() {
 90 | 			expectedMessage := "failed to get node log for inexistent-node: failed to get node inexistent-node: the server could not find the requested resource (get nodes inexistent-node)"
 91 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
 92 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
 93 | 		})
 94 | 	})
 95 | 	s.Run("nodes_log(name=existing-node, log_path=missing.log)", func() {
 96 | 		toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
 97 | 			"name":     "existing-node",
 98 | 			"log_path": "missing.log",
 99 | 		})
100 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
101 | 		s.Run("has error", func() {
102 | 			s.Truef(toolResult.IsError, "call tool should fail")
103 | 			s.Nilf(err, "call tool should not return error object")
104 | 		})
105 | 		s.Run("describes missing log file", func() {
106 | 			expectedMessage := "failed to get node log for existing-node: failed to get node logs: the server could not find the requested resource"
107 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
108 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
109 | 		})
110 | 	})
111 | 	s.Run("nodes_log(name=existing-node, log_path=empty.log)", func() {
112 | 		toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
113 | 			"name":     "existing-node",
114 | 			"log_path": "empty.log",
115 | 		})
116 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
117 | 		s.Run("no error", func() {
118 | 			s.Falsef(toolResult.IsError, "call tool should succeed")
119 | 			s.Nilf(err, "call tool should not return error object")
120 | 		})
121 | 		s.Run("describes empty log", func() {
122 | 			expectedMessage := "The node existing-node has not logged any message yet or the log file is empty"
123 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
124 | 				"expected descriptive message '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
125 | 		})
126 | 	})
127 | 	s.Run("nodes_log(name=existing-node, log_path=kubelet.log)", func() {
128 | 		toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
129 | 			"name":     "existing-node",
130 | 			"log_path": "kubelet.log",
131 | 		})
132 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
133 | 		s.Run("no error", func() {
134 | 			s.Falsef(toolResult.IsError, "call tool should succeed")
135 | 			s.Nilf(err, "call tool should not return error object")
136 | 		})
137 | 		s.Run("returns full log", func() {
138 | 			expectedMessage := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
139 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
140 | 				"expected log content '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
141 | 		})
142 | 	})
143 | 	for _, tailCase := range []interface{}{2, int64(2), float64(2)} {
144 | 		s.Run("nodes_log(name=existing-node, log_path=kubelet.log, tail=2)", func() {
145 | 			toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
146 | 				"name":     "existing-node",
147 | 				"log_path": "kubelet.log",
148 | 				"tail":     tailCase,
149 | 			})
150 | 			s.Require().NotNil(toolResult, "toolResult should not be nil")
151 | 			s.Run("no error", func() {
152 | 				s.Falsef(toolResult.IsError, "call tool should succeed")
153 | 				s.Nilf(err, "call tool should not return error object")
154 | 			})
155 | 			s.Run("returns tail log", func() {
156 | 				expectedMessage := "Line 4\nLine 5\n"
157 | 				s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
158 | 					"expected log content '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
159 | 			})
160 | 		})
161 | 		s.Run("nodes_log(name=existing-node, log_path=kubelet.log, tail=-1)", func() {
162 | 			toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
163 | 				"name":     "existing-node",
164 | 				"log_path": "kubelet.log",
165 | 				"tail":     -1,
166 | 			})
167 | 			s.Require().NotNil(toolResult, "toolResult should not be nil")
168 | 			s.Run("no error", func() {
169 | 				s.Falsef(toolResult.IsError, "call tool should succeed")
170 | 				s.Nilf(err, "call tool should not return error object")
171 | 			})
172 | 			s.Run("returns full log", func() {
173 | 				expectedMessage := "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
174 | 				s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
175 | 					"expected log content '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
176 | 			})
177 | 		})
178 | 	}
179 | }
180 | 
181 | func (s *NodesSuite) TestNodesLogDenied() {
182 | 	s.Require().NoError(toml.Unmarshal([]byte(`
183 | 		denied_resources = [ { version = "v1", kind = "Node" } ]
184 | 	`), s.Cfg), "Expected to parse denied resources config")
185 | 	s.InitMcpClient()
186 | 	s.Run("nodes_log (denied)", func() {
187 | 		toolResult, err := s.CallTool("nodes_log", map[string]interface{}{
188 | 			"name": "does-not-matter",
189 | 		})
190 | 		s.Require().NotNil(toolResult, "toolResult should not be nil")
191 | 		s.Run("has error", func() {
192 | 			s.Truef(toolResult.IsError, "call tool should fail")
193 | 			s.Nilf(err, "call tool should not return error object")
194 | 		})
195 | 		s.Run("describes denial", func() {
196 | 			expectedMessage := "failed to get node log for does-not-matter: resource not allowed: /v1, Kind=Node"
197 | 			s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text,
198 | 				"expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
199 | 		})
200 | 	})
201 | }
202 | 
203 | func TestNodes(t *testing.T) {
204 | 	suite.Run(t, new(NodesSuite))
205 | }
206 | 
```

--------------------------------------------------------------------------------
/pkg/config/config_test.go:
--------------------------------------------------------------------------------

```go
  1 | package config
  2 | 
  3 | import (
  4 | 	"errors"
  5 | 	"io/fs"
  6 | 	"os"
  7 | 	"path/filepath"
  8 | 	"strings"
  9 | 	"testing"
 10 | 
 11 | 	"github.com/stretchr/testify/suite"
 12 | )
 13 | 
 14 | type BaseConfigSuite struct {
 15 | 	suite.Suite
 16 | }
 17 | 
 18 | func (s *BaseConfigSuite) writeConfig(content string) string {
 19 | 	s.T().Helper()
 20 | 	tempDir := s.T().TempDir()
 21 | 	path := filepath.Join(tempDir, "config.toml")
 22 | 	err := os.WriteFile(path, []byte(content), 0644)
 23 | 	if err != nil {
 24 | 		s.T().Fatalf("Failed to write config file %s: %v", path, err)
 25 | 	}
 26 | 	return path
 27 | }
 28 | 
 29 | type ConfigSuite struct {
 30 | 	BaseConfigSuite
 31 | }
 32 | 
 33 | func (s *ConfigSuite) TestReadConfigMissingFile() {
 34 | 	config, err := Read("non-existent-config.toml")
 35 | 	s.Run("returns error for missing file", func() {
 36 | 		s.Require().NotNil(err, "Expected error for missing file, got nil")
 37 | 		s.True(errors.Is(err, fs.ErrNotExist), "Expected ErrNotExist, got %v", err)
 38 | 	})
 39 | 	s.Run("returns nil config for missing file", func() {
 40 | 		s.Nil(config, "Expected nil config for missing file")
 41 | 	})
 42 | }
 43 | 
 44 | func (s *ConfigSuite) TestReadConfigInvalid() {
 45 | 	invalidConfigPath := s.writeConfig(`
 46 | 		[[denied_resources]]
 47 | 		group = "apps"
 48 | 		version = "v1"
 49 | 		kind = "Deployment"
 50 | 		[[denied_resources]]
 51 | 		group = "rbac.authorization.k8s.io"
 52 | 		version = "v1"
 53 | 		kind = "Role
 54 | 	`)
 55 | 
 56 | 	config, err := Read(invalidConfigPath)
 57 | 	s.Run("returns error for invalid file", func() {
 58 | 		s.Require().NotNil(err, "Expected error for invalid file, got nil")
 59 | 	})
 60 | 	s.Run("error message contains toml error with line number", func() {
 61 | 		expectedError := "toml: line 9"
 62 | 		s.Truef(strings.HasPrefix(err.Error(), expectedError), "Expected error message to contain line number, got %v", err)
 63 | 	})
 64 | 	s.Run("returns nil config for invalid file", func() {
 65 | 		s.Nil(config, "Expected nil config for missing file")
 66 | 	})
 67 | }
 68 | 
 69 | func (s *ConfigSuite) TestReadConfigValid() {
 70 | 	validConfigPath := s.writeConfig(`
 71 | 		log_level = 1
 72 | 		port = "9999"
 73 | 		sse_base_url = "https://example.com"
 74 | 		kubeconfig = "./path/to/config"
 75 | 		list_output = "yaml"
 76 | 		read_only = true
 77 | 		disable_destructive = true
 78 | 
 79 | 		toolsets = ["core", "config", "helm", "metrics"]
 80 | 		
 81 | 		enabled_tools = ["configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"]
 82 | 		disabled_tools = ["pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"]
 83 | 
 84 | 		denied_resources = [
 85 | 			{group = "apps", version = "v1", kind = "Deployment"},
 86 | 			{group = "rbac.authorization.k8s.io", version = "v1", kind = "Role"}
 87 | 		]
 88 | 		
 89 | 	`)
 90 | 
 91 | 	config, err := Read(validConfigPath)
 92 | 	s.Require().NotNil(config)
 93 | 	s.Run("reads and unmarshalls file", func() {
 94 | 		s.Nil(err, "Expected nil error for valid file")
 95 | 		s.Require().NotNil(config, "Expected non-nil config for valid file")
 96 | 	})
 97 | 	s.Run("log_level parsed correctly", func() {
 98 | 		s.Equalf(1, config.LogLevel, "Expected LogLevel to be 1, got %d", config.LogLevel)
 99 | 	})
100 | 	s.Run("port parsed correctly", func() {
101 | 		s.Equalf("9999", config.Port, "Expected Port to be 9999, got %s", config.Port)
102 | 	})
103 | 	s.Run("sse_base_url parsed correctly", func() {
104 | 		s.Equalf("https://example.com", config.SSEBaseURL, "Expected SSEBaseURL to be https://example.com, got %s", config.SSEBaseURL)
105 | 	})
106 | 	s.Run("kubeconfig parsed correctly", func() {
107 | 		s.Equalf("./path/to/config", config.KubeConfig, "Expected KubeConfig to be ./path/to/config, got %s", config.KubeConfig)
108 | 	})
109 | 	s.Run("list_output parsed correctly", func() {
110 | 		s.Equalf("yaml", config.ListOutput, "Expected ListOutput to be yaml, got %s", config.ListOutput)
111 | 	})
112 | 	s.Run("read_only parsed correctly", func() {
113 | 		s.Truef(config.ReadOnly, "Expected ReadOnly to be true, got %v", config.ReadOnly)
114 | 	})
115 | 	s.Run("disable_destructive parsed correctly", func() {
116 | 		s.Truef(config.DisableDestructive, "Expected DisableDestructive to be true, got %v", config.DisableDestructive)
117 | 	})
118 | 	s.Run("toolsets", func() {
119 | 		s.Require().Lenf(config.Toolsets, 4, "Expected 4 toolsets, got %d", len(config.Toolsets))
120 | 		for _, toolset := range []string{"core", "config", "helm", "metrics"} {
121 | 			s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
122 | 		}
123 | 	})
124 | 	s.Run("enabled_tools", func() {
125 | 		s.Require().Lenf(config.EnabledTools, 8, "Expected 8 enabled tools, got %d", len(config.EnabledTools))
126 | 		for _, tool := range []string{"configuration_view", "events_list", "namespaces_list", "pods_list", "resources_list", "resources_get", "resources_create_or_update", "resources_delete"} {
127 | 			s.Containsf(config.EnabledTools, tool, "Expected enabled tools to contain %s", tool)
128 | 		}
129 | 	})
130 | 	s.Run("disabled_tools", func() {
131 | 		s.Require().Lenf(config.DisabledTools, 5, "Expected 5 disabled tools, got %d", len(config.DisabledTools))
132 | 		for _, tool := range []string{"pods_delete", "pods_top", "pods_log", "pods_run", "pods_exec"} {
133 | 			s.Containsf(config.DisabledTools, tool, "Expected disabled tools to contain %s", tool)
134 | 		}
135 | 	})
136 | 	s.Run("denied_resources", func() {
137 | 		s.Require().Lenf(config.DeniedResources, 2, "Expected 2 denied resources, got %d", len(config.DeniedResources))
138 | 		s.Run("contains apps/v1/Deployment", func() {
139 | 			s.Contains(config.DeniedResources, GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"},
140 | 				"Expected denied resources to contain apps/v1/Deployment")
141 | 		})
142 | 		s.Run("contains rbac.authorization.k8s.io/v1/Role", func() {
143 | 			s.Contains(config.DeniedResources, GroupVersionKind{Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "Role"},
144 | 				"Expected denied resources to contain rbac.authorization.k8s.io/v1/Role")
145 | 		})
146 | 	})
147 | }
148 | 
149 | func (s *ConfigSuite) TestReadConfigValidPreservesDefaultsForMissingFields() {
150 | 	validConfigPath := s.writeConfig(`
151 | 		port = "1337"
152 | 	`)
153 | 
154 | 	config, err := Read(validConfigPath)
155 | 	s.Require().NotNil(config)
156 | 	s.Run("reads and unmarshalls file", func() {
157 | 		s.Nil(err, "Expected nil error for valid file")
158 | 		s.Require().NotNil(config, "Expected non-nil config for valid file")
159 | 	})
160 | 	s.Run("log_level defaulted correctly", func() {
161 | 		s.Equalf(0, config.LogLevel, "Expected LogLevel to be 0, got %d", config.LogLevel)
162 | 	})
163 | 	s.Run("port parsed correctly", func() {
164 | 		s.Equalf("1337", config.Port, "Expected Port to be 9999, got %s", config.Port)
165 | 	})
166 | 	s.Run("list_output defaulted correctly", func() {
167 | 		s.Equalf("table", config.ListOutput, "Expected ListOutput to be table, got %s", config.ListOutput)
168 | 	})
169 | 	s.Run("toolsets defaulted correctly", func() {
170 | 		s.Require().Lenf(config.Toolsets, 3, "Expected 3 toolsets, got %d", len(config.Toolsets))
171 | 		for _, toolset := range []string{"core", "config", "helm"} {
172 | 			s.Containsf(config.Toolsets, toolset, "Expected toolsets to contain %s", toolset)
173 | 		}
174 | 	})
175 | }
176 | 
177 | func (s *ConfigSuite) TestMergeConfig() {
178 | 	base := StaticConfig{
179 | 		ListOutput: "table",
180 | 		Toolsets:   []string{"core", "config", "helm"},
181 | 		Port:       "8080",
182 | 	}
183 | 	s.Run("merges override values on top of base", func() {
184 | 		override := StaticConfig{
185 | 			ListOutput: "json",
186 | 			Port:       "9090",
187 | 		}
188 | 
189 | 		result := mergeConfig(base, override)
190 | 
191 | 		s.Equal("json", result.ListOutput, "ListOutput should be overridden")
192 | 		s.Equal("9090", result.Port, "Port should be overridden")
193 | 	})
194 | 
195 | 	s.Run("preserves base values when override is empty", func() {
196 | 		override := StaticConfig{}
197 | 
198 | 		result := mergeConfig(base, override)
199 | 
200 | 		s.Equal("table", result.ListOutput, "ListOutput should be preserved from base")
201 | 		s.Equal([]string{"core", "config", "helm"}, result.Toolsets, "Toolsets should be preserved from base")
202 | 		s.Equal("8080", result.Port, "Port should be preserved from base")
203 | 	})
204 | 
205 | 	s.Run("handles partial overrides", func() {
206 | 		override := StaticConfig{
207 | 			Toolsets: []string{"custom"},
208 | 			ReadOnly: true,
209 | 		}
210 | 
211 | 		result := mergeConfig(base, override)
212 | 
213 | 		s.Equal("table", result.ListOutput, "ListOutput should be preserved from base")
214 | 		s.Equal([]string{"custom"}, result.Toolsets, "Toolsets should be overridden")
215 | 		s.Equal("8080", result.Port, "Port should be preserved from base since override doesn't specify it")
216 | 		s.True(result.ReadOnly, "ReadOnly should be overridden to true")
217 | 	})
218 | }
219 | 
220 | func TestConfig(t *testing.T) {
221 | 	suite.Run(t, new(ConfigSuite))
222 | }
223 | 
```

--------------------------------------------------------------------------------
/pkg/mcp/toolsets_test.go:
--------------------------------------------------------------------------------

```go
  1 | package mcp
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"strconv"
  6 | 	"testing"
  7 | 
  8 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
  9 | 	"github.com/containers/kubernetes-mcp-server/pkg/api"
 10 | 	configuration "github.com/containers/kubernetes-mcp-server/pkg/config"
 11 | 	"github.com/containers/kubernetes-mcp-server/pkg/toolsets"
 12 | 	"github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
 13 | 	"github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
 14 | 	"github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
 15 | 	"github.com/mark3labs/mcp-go/mcp"
 16 | 	"github.com/stretchr/testify/suite"
 17 | 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
 18 | )
 19 | 
 20 | type ToolsetsSuite struct {
 21 | 	suite.Suite
 22 | 	originalToolsets []api.Toolset
 23 | 	*test.MockServer
 24 | 	*test.McpClient
 25 | 	Cfg       *configuration.StaticConfig
 26 | 	mcpServer *Server
 27 | }
 28 | 
 29 | func (s *ToolsetsSuite) SetupTest() {
 30 | 	s.originalToolsets = toolsets.Toolsets()
 31 | 	s.MockServer = test.NewMockServer()
 32 | 	s.Cfg = configuration.Default()
 33 | 	s.Cfg.KubeConfig = s.KubeconfigFile(s.T())
 34 | }
 35 | 
 36 | func (s *ToolsetsSuite) TearDownTest() {
 37 | 	toolsets.Clear()
 38 | 	for _, toolset := range s.originalToolsets {
 39 | 		toolsets.Register(toolset)
 40 | 	}
 41 | 	s.MockServer.Close()
 42 | }
 43 | 
 44 | func (s *ToolsetsSuite) TearDownSubTest() {
 45 | 	if s.McpClient != nil {
 46 | 		s.McpClient.Close()
 47 | 	}
 48 | 	if s.mcpServer != nil {
 49 | 		s.mcpServer.Close()
 50 | 	}
 51 | }
 52 | 
 53 | func (s *ToolsetsSuite) TestNoToolsets() {
 54 | 	s.Run("No toolsets registered", func() {
 55 | 		toolsets.Clear()
 56 | 		s.Cfg.Toolsets = []string{}
 57 | 		s.InitMcpClient()
 58 | 		tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 59 | 		s.Run("ListTools returns no tools", func() {
 60 | 			s.NotNil(tools, "Expected tools from ListTools")
 61 | 			s.NoError(err, "Expected no error from ListTools")
 62 | 			s.Empty(tools.Tools, "Expected no tools from ListTools")
 63 | 		})
 64 | 	})
 65 | }
 66 | 
 67 | func (s *ToolsetsSuite) TestDefaultToolsetsTools() {
 68 | 	if configuration.HasDefaultOverrides() {
 69 | 		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
 70 | 	}
 71 | 	s.Run("Default configuration toolsets", func() {
 72 | 		s.InitMcpClient()
 73 | 		tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 74 | 		s.Run("ListTools returns tools", func() {
 75 | 			s.NotNil(tools, "Expected tools from ListTools")
 76 | 			s.NoError(err, "Expected no error from ListTools")
 77 | 		})
 78 | 		s.Run("ListTools returns correct Tool metadata", func() {
 79 | 			expectedMetadata := test.ReadFile("testdata", "toolsets-full-tools.json")
 80 | 			metadata, err := json.MarshalIndent(tools.Tools, "", "  ")
 81 | 			s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
 82 | 			s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
 83 | 		})
 84 | 	})
 85 | }
 86 | 
 87 | func (s *ToolsetsSuite) TestDefaultToolsetsToolsInOpenShift() {
 88 | 	if configuration.HasDefaultOverrides() {
 89 | 		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
 90 | 	}
 91 | 	s.Run("Default configuration toolsets in OpenShift", func() {
 92 | 		s.Handle(&test.InOpenShiftHandler{})
 93 | 		s.InitMcpClient()
 94 | 		tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
 95 | 		s.Run("ListTools returns tools", func() {
 96 | 			s.NotNil(tools, "Expected tools from ListTools")
 97 | 			s.NoError(err, "Expected no error from ListTools")
 98 | 		})
 99 | 		s.Run("ListTools returns correct Tool metadata", func() {
100 | 			expectedMetadata := test.ReadFile("testdata", "toolsets-full-tools-openshift.json")
101 | 			metadata, err := json.MarshalIndent(tools.Tools, "", "  ")
102 | 			s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
103 | 			s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
104 | 		})
105 | 	})
106 | }
107 | 
108 | func (s *ToolsetsSuite) TestDefaultToolsetsToolsInMultiCluster() {
109 | 	if configuration.HasDefaultOverrides() {
110 | 		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
111 | 	}
112 | 	s.Run("Default configuration toolsets in multi-cluster (with 11 clusters)", func() {
113 | 		kubeconfig := s.Kubeconfig()
114 | 		for i := 0; i < 10; i++ {
115 | 			// Add multiple fake contexts to force multi-cluster behavior
116 | 			kubeconfig.Contexts[strconv.Itoa(i)] = clientcmdapi.NewContext()
117 | 		}
118 | 		s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
119 | 		s.InitMcpClient()
120 | 		tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
121 | 		s.Run("ListTools returns tools", func() {
122 | 			s.NotNil(tools, "Expected tools from ListTools")
123 | 			s.NoError(err, "Expected no error from ListTools")
124 | 		})
125 | 		s.Run("ListTools returns correct Tool metadata", func() {
126 | 			expectedMetadata := test.ReadFile("testdata", "toolsets-full-tools-multicluster.json")
127 | 			metadata, err := json.MarshalIndent(tools.Tools, "", "  ")
128 | 			s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
129 | 			s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
130 | 		})
131 | 	})
132 | }
133 | 
134 | func (s *ToolsetsSuite) TestDefaultToolsetsToolsInMultiClusterEnum() {
135 | 	if configuration.HasDefaultOverrides() {
136 | 		s.T().Skip("Skipping test because default configuration overrides are present (this is a downstream fork)")
137 | 	}
138 | 	s.Run("Default configuration toolsets in multi-cluster (with 2 clusters)", func() {
139 | 		kubeconfig := s.Kubeconfig()
140 | 		// Add additional cluster to force multi-cluster behavior with enum parameter
141 | 		kubeconfig.Contexts["extra-cluster"] = clientcmdapi.NewContext()
142 | 		s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
143 | 		s.InitMcpClient()
144 | 		tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
145 | 		s.Run("ListTools returns tools", func() {
146 | 			s.NotNil(tools, "Expected tools from ListTools")
147 | 			s.NoError(err, "Expected no error from ListTools")
148 | 		})
149 | 		s.Run("ListTools returns correct Tool metadata", func() {
150 | 			expectedMetadata := test.ReadFile("testdata", "toolsets-full-tools-multicluster-enum.json")
151 | 			metadata, err := json.MarshalIndent(tools.Tools, "", "  ")
152 | 			s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
153 | 			s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
154 | 		})
155 | 	})
156 | }
157 | 
158 | func (s *ToolsetsSuite) TestGranularToolsetsTools() {
159 | 	testCases := []api.Toolset{
160 | 		&core.Toolset{},
161 | 		&config.Toolset{},
162 | 		&helm.Toolset{},
163 | 	}
164 | 	for _, testCase := range testCases {
165 | 		s.Run("Toolset "+testCase.GetName(), func() {
166 | 			toolsets.Clear()
167 | 			toolsets.Register(testCase)
168 | 			s.Cfg.Toolsets = []string{testCase.GetName()}
169 | 			s.InitMcpClient()
170 | 			tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
171 | 			s.Run("ListTools returns tools", func() {
172 | 				s.NotNil(tools, "Expected tools from ListTools")
173 | 				s.NoError(err, "Expected no error from ListTools")
174 | 			})
175 | 			s.Run("ListTools returns correct Tool metadata", func() {
176 | 				expectedMetadata := test.ReadFile("testdata", "toolsets-"+testCase.GetName()+"-tools.json")
177 | 				metadata, err := json.MarshalIndent(tools.Tools, "", "  ")
178 | 				s.Require().NoErrorf(err, "failed to marshal tools metadata: %v", err)
179 | 				s.JSONEq(expectedMetadata, string(metadata), "tools metadata does not match expected")
180 | 			})
181 | 		})
182 | 	}
183 | }
184 | 
185 | func (s *ToolsetsSuite) TestInputSchemaEdgeCases() {
186 | 	//https://github.com/containers/kubernetes-mcp-server/issues/340
187 | 	s.Run("InputSchema for no-arg tool is object with empty properties", func() {
188 | 		s.InitMcpClient()
189 | 		tools, err := s.ListTools(s.T().Context(), mcp.ListToolsRequest{})
190 | 		s.Run("ListTools returns tools", func() {
191 | 			s.NotNil(tools, "Expected tools from ListTools")
192 | 			s.NoError(err, "Expected no error from ListTools")
193 | 		})
194 | 		var namespacesList *mcp.Tool
195 | 		for _, tool := range tools.Tools {
196 | 			if tool.Name == "namespaces_list" {
197 | 				namespacesList = &tool
198 | 				break
199 | 			}
200 | 		}
201 | 		s.Require().NotNil(namespacesList, "Expected namespaces_list from ListTools")
202 | 		s.NotNil(namespacesList.InputSchema.Properties, "Expected namespaces_list.InputSchema.Properties not to be nil")
203 | 		s.Empty(namespacesList.InputSchema.Properties, "Expected namespaces_list.InputSchema.Properties to be empty")
204 | 	})
205 | }
206 | 
207 | func (s *ToolsetsSuite) InitMcpClient() {
208 | 	var err error
209 | 	s.mcpServer, err = NewServer(Configuration{StaticConfig: s.Cfg})
210 | 	s.Require().NoError(err, "Expected no error creating MCP server")
211 | 	s.McpClient = test.NewMcpClient(s.T(), s.mcpServer.ServeHTTP(nil))
212 | }
213 | 
214 | func TestToolsets(t *testing.T) {
215 | 	suite.Run(t, new(ToolsetsSuite))
216 | }
217 | 
```

--------------------------------------------------------------------------------
/pkg/kubernetes/manager_test.go:
--------------------------------------------------------------------------------

```go
  1 | package kubernetes
  2 | 
  3 | import (
  4 | 	"os"
  5 | 	"path/filepath"
  6 | 	"runtime"
  7 | 	"testing"
  8 | 
  9 | 	"github.com/containers/kubernetes-mcp-server/internal/test"
 10 | 	"github.com/containers/kubernetes-mcp-server/pkg/config"
 11 | 	"github.com/stretchr/testify/suite"
 12 | 	"k8s.io/client-go/rest"
 13 | 	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
 14 | )
 15 | 
 16 | type ManagerTestSuite struct {
 17 | 	suite.Suite
 18 | 	originalEnv             []string
 19 | 	originalInClusterConfig func() (*rest.Config, error)
 20 | 	mockServer              *test.MockServer
 21 | }
 22 | 
 23 | func (s *ManagerTestSuite) SetupTest() {
 24 | 	s.originalEnv = os.Environ()
 25 | 	s.originalInClusterConfig = InClusterConfig
 26 | 	s.mockServer = test.NewMockServer()
 27 | }
 28 | 
 29 | func (s *ManagerTestSuite) TearDownTest() {
 30 | 	test.RestoreEnv(s.originalEnv)
 31 | 	InClusterConfig = s.originalInClusterConfig
 32 | 	if s.mockServer != nil {
 33 | 		s.mockServer.Close()
 34 | 	}
 35 | }
 36 | 
 37 | func (s *ManagerTestSuite) TestNewInClusterManager() {
 38 | 	s.Run("In cluster", func() {
 39 | 		InClusterConfig = func() (*rest.Config, error) {
 40 | 			return &rest.Config{}, nil
 41 | 		}
 42 | 		s.Run("with default StaticConfig (empty kubeconfig)", func() {
 43 | 			manager, err := NewInClusterManager(&config.StaticConfig{})
 44 | 			s.Require().NoError(err)
 45 | 			s.Require().NotNil(manager)
 46 | 			s.Run("behaves as in cluster", func() {
 47 | 				rawConfig, err := manager.clientCmdConfig.RawConfig()
 48 | 				s.Require().NoError(err)
 49 | 				s.Equal("in-cluster", rawConfig.CurrentContext, "expected current context to be 'in-cluster'")
 50 | 			})
 51 | 			s.Run("sets default user-agent", func() {
 52 | 				s.Contains(manager.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")")
 53 | 			})
 54 | 		})
 55 | 		s.Run("with explicit kubeconfig", func() {
 56 | 			manager, err := NewInClusterManager(&config.StaticConfig{
 57 | 				KubeConfig: s.mockServer.KubeconfigFile(s.T()),
 58 | 			})
 59 | 			s.Run("returns error", func() {
 60 | 				s.Error(err)
 61 | 				s.Nil(manager)
 62 | 				s.Regexp("kubeconfig file .+ cannot be used with the in-cluster deployments", err.Error())
 63 | 			})
 64 | 		})
 65 | 	})
 66 | 	s.Run("Out of cluster", func() {
 67 | 		InClusterConfig = func() (*rest.Config, error) {
 68 | 			return nil, rest.ErrNotInCluster
 69 | 		}
 70 | 		manager, err := NewInClusterManager(&config.StaticConfig{})
 71 | 		s.Run("returns error", func() {
 72 | 			s.Error(err)
 73 | 			s.Nil(manager)
 74 | 			s.ErrorIs(err, ErrorInClusterNotInCluster)
 75 | 			s.ErrorContains(err, "in-cluster manager cannot be used outside of a cluster")
 76 | 		})
 77 | 	})
 78 | }
 79 | 
 80 | func (s *ManagerTestSuite) TestNewKubeconfigManager() {
 81 | 	s.Run("Out of cluster", func() {
 82 | 		InClusterConfig = func() (*rest.Config, error) {
 83 | 			return nil, rest.ErrNotInCluster
 84 | 		}
 85 | 		s.Run("with valid kubeconfig in env", func() {
 86 | 			kubeconfig := s.mockServer.KubeconfigFile(s.T())
 87 | 			s.Require().NoError(os.Setenv("KUBECONFIG", kubeconfig))
 88 | 			manager, err := NewKubeconfigManager(&config.StaticConfig{}, "")
 89 | 			s.Require().NoError(err)
 90 | 			s.Require().NotNil(manager)
 91 | 			s.Run("behaves as NOT in cluster", func() {
 92 | 				rawConfig, err := manager.clientCmdConfig.RawConfig()
 93 | 				s.Require().NoError(err)
 94 | 				s.NotEqual("in-cluster", rawConfig.CurrentContext, "expected current context to NOT be 'in-cluster'")
 95 | 				s.Equal("fake-context", rawConfig.CurrentContext, "expected current context to be 'fake-context' as in kubeconfig")
 96 | 			})
 97 | 			s.Run("loads correct config", func() {
 98 | 				s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfig, "expected kubeconfig path to match")
 99 | 			})
100 | 			s.Run("sets default user-agent", func() {
101 | 				s.Contains(manager.cfg.UserAgent, "("+runtime.GOOS+"/"+runtime.GOARCH+")")
102 | 			})
103 | 			s.Run("rest config host points to mock server", func() {
104 | 				s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server")
105 | 			})
106 | 		})
107 | 		s.Run("with valid kubeconfig in env and explicit kubeconfig in config", func() {
108 | 			kubeconfigInEnv := s.mockServer.KubeconfigFile(s.T())
109 | 			s.Require().NoError(os.Setenv("KUBECONFIG", kubeconfigInEnv))
110 | 			kubeconfigExplicit := s.mockServer.KubeconfigFile(s.T())
111 | 			manager, err := NewKubeconfigManager(&config.StaticConfig{
112 | 				KubeConfig: kubeconfigExplicit,
113 | 			}, "")
114 | 			s.Require().NoError(err)
115 | 			s.Require().NotNil(manager)
116 | 			s.Run("behaves as NOT in cluster", func() {
117 | 				rawConfig, err := manager.clientCmdConfig.RawConfig()
118 | 				s.Require().NoError(err)
119 | 				s.NotEqual("in-cluster", rawConfig.CurrentContext, "expected current context to NOT be 'in-cluster'")
120 | 				s.Equal("fake-context", rawConfig.CurrentContext, "expected current context to be 'fake-context' as in kubeconfig")
121 | 			})
122 | 			s.Run("loads correct config (explicit)", func() {
123 | 				s.NotContains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigInEnv, "expected kubeconfig path to NOT match env")
124 | 				s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigExplicit, "expected kubeconfig path to match explicit")
125 | 			})
126 | 			s.Run("rest config host points to mock server", func() {
127 | 				s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server")
128 | 			})
129 | 		})
130 | 		s.Run("with valid kubeconfig in env and explicit kubeconfig context (valid)", func() {
131 | 			kubeconfig := s.mockServer.Kubeconfig()
132 | 			kubeconfig.Contexts["not-the-mock-server"] = clientcmdapi.NewContext()
133 | 			kubeconfig.Contexts["not-the-mock-server"].Cluster = "not-the-mock-server"
134 | 			kubeconfig.Clusters["not-the-mock-server"] = clientcmdapi.NewCluster()
135 | 			kubeconfig.Clusters["not-the-mock-server"].Server = "https://not-the-mock-server:6443" // REST configuration should point to mock server, not this
136 | 			kubeconfig.CurrentContext = "not-the-mock-server"
137 | 			kubeconfigFile := test.KubeconfigFile(s.T(), kubeconfig)
138 | 			s.Require().NoError(os.Setenv("KUBECONFIG", kubeconfigFile))
139 | 			manager, err := NewKubeconfigManager(&config.StaticConfig{}, "fake-context") // fake-context is the one mock-server serves
140 | 			s.Require().NoError(err)
141 | 			s.Require().NotNil(manager)
142 | 			s.Run("behaves as NOT in cluster", func() {
143 | 				rawConfig, err := manager.clientCmdConfig.RawConfig()
144 | 				s.Require().NoError(err)
145 | 				s.NotEqual("in-cluster", rawConfig.CurrentContext, "expected current context to NOT be 'in-cluster'")
146 | 				s.Equal("not-the-mock-server", rawConfig.CurrentContext, "expected current context to be 'not-the-mock-server' as in explicit context")
147 | 			})
148 | 			s.Run("loads correct config", func() {
149 | 				s.Contains(manager.clientCmdConfig.ConfigAccess().GetLoadingPrecedence(), kubeconfigFile, "expected kubeconfig path to match")
150 | 			})
151 | 			s.Run("rest config host points to mock server", func() {
152 | 				s.Equal(s.mockServer.Config().Host, manager.cfg.Host, "expected rest config host to match mock server")
153 | 			})
154 | 		})
155 | 		s.Run("with valid kubeconfig in env and explicit kubeconfig context (invalid)", func() {
156 | 			kubeconfigInEnv := s.mockServer.KubeconfigFile(s.T())
157 | 			s.Require().NoError(os.Setenv("KUBECONFIG", kubeconfigInEnv))
158 | 			manager, err := NewKubeconfigManager(&config.StaticConfig{}, "i-do-not-exist")
159 | 			s.Run("returns error", func() {
160 | 				s.Error(err)
161 | 				s.Nil(manager)
162 | 				s.ErrorContains(err, `failed to create kubernetes rest config from kubeconfig: context "i-do-not-exist" does not exist`)
163 | 			})
164 | 		})
165 | 		s.Run("with invalid path kubeconfig in env", func() {
166 | 			s.Require().NoError(os.Setenv("KUBECONFIG", "i-dont-exist"))
167 | 			manager, err := NewKubeconfigManager(&config.StaticConfig{}, "")
168 | 			s.Run("returns error", func() {
169 | 				s.Error(err)
170 | 				s.Nil(manager)
171 | 				s.ErrorContains(err, "failed to create kubernetes rest config")
172 | 			})
173 | 		})
174 | 		s.Run("with empty kubeconfig in env", func() {
175 | 			kubeconfigPath := filepath.Join(s.T().TempDir(), "config")
176 | 			s.Require().NoError(os.WriteFile(kubeconfigPath, []byte(""), 0644))
177 | 			s.Require().NoError(os.Setenv("KUBECONFIG", kubeconfigPath))
178 | 			manager, err := NewKubeconfigManager(&config.StaticConfig{}, "")
179 | 			s.Run("returns error", func() {
180 | 				s.Error(err)
181 | 				s.Nil(manager)
182 | 				s.ErrorContains(err, "no configuration has been provided")
183 | 			})
184 | 		})
185 | 	})
186 | 	s.Run("In cluster", func() {
187 | 		InClusterConfig = func() (*rest.Config, error) {
188 | 			return &rest.Config{}, nil
189 | 		}
190 | 		manager, err := NewKubeconfigManager(&config.StaticConfig{}, "")
191 | 		s.Run("returns error", func() {
192 | 			s.Error(err)
193 | 			s.Nil(manager)
194 | 			s.ErrorIs(err, ErrorKubeconfigInClusterNotAllowed)
195 | 			s.ErrorContains(err, "kubeconfig manager cannot be used in in-cluster deployments")
196 | 		})
197 | 	})
198 | }
199 | 
200 | func TestManager(t *testing.T) {
201 | 	suite.Run(t, new(ManagerTestSuite))
202 | }
203 | 
```
Page 2/5FirstPrevNextLast