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 |
```