This is page 3 of 5. Use http://codebase.md/manusa/kubernetes-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── build.yaml
│ ├── release-image.yml
│ └── release.yaml
├── .gitignore
├── AGENTS.md
├── build
│ ├── keycloak.mk
│ ├── kind.mk
│ └── tools.mk
├── CLAUDE.md
├── cmd
│ └── kubernetes-mcp-server
│ ├── main_test.go
│ └── main.go
├── dev
│ └── config
│ ├── cert-manager
│ │ └── selfsigned-issuer.yaml
│ ├── ingress
│ │ └── nginx-ingress.yaml
│ ├── keycloak
│ │ ├── client-scopes
│ │ │ ├── groups.json
│ │ │ ├── mcp-openshift.json
│ │ │ └── mcp-server.json
│ │ ├── clients
│ │ │ ├── mcp-client.json
│ │ │ ├── mcp-server-update.json
│ │ │ ├── mcp-server.json
│ │ │ └── openshift.json
│ │ ├── deployment.yaml
│ │ ├── ingress.yaml
│ │ ├── mappers
│ │ │ ├── groups-membership.json
│ │ │ ├── mcp-server-audience.json
│ │ │ ├── openshift-audience.json
│ │ │ └── username.json
│ │ ├── rbac.yaml
│ │ ├── realm
│ │ │ ├── realm-create.json
│ │ │ └── realm-events-config.json
│ │ └── users
│ │ └── mcp.json
│ └── kind
│ └── cluster.yaml
├── Dockerfile
├── docs
│ └── images
│ ├── kubernetes-mcp-server-github-copilot.jpg
│ └── vibe-coding.jpg
├── go.mod
├── go.sum
├── hack
│ └── generate-placeholder-ca.sh
├── internal
│ ├── test
│ │ ├── env.go
│ │ ├── kubernetes.go
│ │ ├── mcp.go
│ │ ├── mock_server.go
│ │ └── test.go
│ └── tools
│ └── update-readme
│ └── main.go
├── LICENSE
├── Makefile
├── npm
│ ├── kubernetes-mcp-server
│ │ ├── bin
│ │ │ └── index.js
│ │ └── package.json
│ ├── kubernetes-mcp-server-darwin-amd64
│ │ └── package.json
│ ├── kubernetes-mcp-server-darwin-arm64
│ │ └── package.json
│ ├── kubernetes-mcp-server-linux-amd64
│ │ └── package.json
│ ├── kubernetes-mcp-server-linux-arm64
│ │ └── package.json
│ ├── kubernetes-mcp-server-windows-amd64
│ │ └── package.json
│ └── kubernetes-mcp-server-windows-arm64
│ └── package.json
├── pkg
│ ├── api
│ │ ├── toolsets_test.go
│ │ └── toolsets.go
│ ├── config
│ │ ├── config_default_overrides.go
│ │ ├── config_default.go
│ │ ├── config_test.go
│ │ ├── config.go
│ │ ├── provider_config_test.go
│ │ └── provider_config.go
│ ├── helm
│ │ └── helm.go
│ ├── http
│ │ ├── authorization_test.go
│ │ ├── authorization.go
│ │ ├── http_test.go
│ │ ├── http.go
│ │ ├── middleware.go
│ │ ├── sts_test.go
│ │ ├── sts.go
│ │ └── wellknown.go
│ ├── kubernetes
│ │ ├── accesscontrol_clientset.go
│ │ ├── accesscontrol_restmapper.go
│ │ ├── accesscontrol.go
│ │ ├── common_test.go
│ │ ├── configuration.go
│ │ ├── events.go
│ │ ├── impersonate_roundtripper.go
│ │ ├── kubernetes_derived_test.go
│ │ ├── kubernetes.go
│ │ ├── manager_test.go
│ │ ├── manager.go
│ │ ├── namespaces.go
│ │ ├── nodes.go
│ │ ├── openshift.go
│ │ ├── pods.go
│ │ ├── provider_kubeconfig_test.go
│ │ ├── provider_kubeconfig.go
│ │ ├── provider_registry_test.go
│ │ ├── provider_registry.go
│ │ ├── provider_single_test.go
│ │ ├── provider_single.go
│ │ ├── provider_test.go
│ │ ├── provider.go
│ │ ├── resources.go
│ │ └── token.go
│ ├── kubernetes-mcp-server
│ │ └── cmd
│ │ ├── root_test.go
│ │ ├── root.go
│ │ └── testdata
│ │ ├── empty-config.toml
│ │ └── valid-config.toml
│ ├── mcp
│ │ ├── common_test.go
│ │ ├── configuration_test.go
│ │ ├── events_test.go
│ │ ├── helm_test.go
│ │ ├── m3labs.go
│ │ ├── mcp_middleware_test.go
│ │ ├── mcp_test.go
│ │ ├── mcp_tools_test.go
│ │ ├── mcp.go
│ │ ├── modules.go
│ │ ├── namespaces_test.go
│ │ ├── nodes_test.go
│ │ ├── pods_exec_test.go
│ │ ├── pods_test.go
│ │ ├── pods_top_test.go
│ │ ├── resources_test.go
│ │ ├── testdata
│ │ │ ├── helm-chart-no-op
│ │ │ │ └── Chart.yaml
│ │ │ ├── helm-chart-secret
│ │ │ │ ├── Chart.yaml
│ │ │ │ └── templates
│ │ │ │ └── secret.yaml
│ │ │ ├── toolsets-config-tools.json
│ │ │ ├── toolsets-core-tools.json
│ │ │ ├── toolsets-full-tools-multicluster-enum.json
│ │ │ ├── toolsets-full-tools-multicluster.json
│ │ │ ├── toolsets-full-tools-openshift.json
│ │ │ ├── toolsets-full-tools.json
│ │ │ └── toolsets-helm-tools.json
│ │ ├── tool_filter_test.go
│ │ ├── tool_filter.go
│ │ ├── tool_mutator_test.go
│ │ ├── tool_mutator.go
│ │ └── toolsets_test.go
│ ├── output
│ │ ├── output_test.go
│ │ └── output.go
│ ├── toolsets
│ │ ├── config
│ │ │ ├── configuration.go
│ │ │ └── toolset.go
│ │ ├── core
│ │ │ ├── events.go
│ │ │ ├── namespaces.go
│ │ │ ├── nodes.go
│ │ │ ├── pods.go
│ │ │ ├── resources.go
│ │ │ └── toolset.go
│ │ ├── helm
│ │ │ ├── helm.go
│ │ │ └── toolset.go
│ │ ├── toolsets_test.go
│ │ └── toolsets.go
│ └── version
│ └── version.go
├── python
│ ├── kubernetes_mcp_server
│ │ ├── __init__.py
│ │ ├── __main__.py
│ │ └── kubernetes_mcp_server.py
│ ├── pyproject.toml
│ └── README.md
├── README.md
└── smithery.yaml
```
# Files
--------------------------------------------------------------------------------
/pkg/mcp/configuration_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/mark3labs/mcp-go/mcp"
8 | "github.com/stretchr/testify/suite"
9 | "k8s.io/client-go/rest"
10 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
11 | v1 "k8s.io/client-go/tools/clientcmd/api/v1"
12 | "sigs.k8s.io/yaml"
13 |
14 | "github.com/containers/kubernetes-mcp-server/internal/test"
15 | "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
16 | )
17 |
18 | type ConfigurationSuite struct {
19 | BaseMcpSuite
20 | }
21 |
22 | func (s *ConfigurationSuite) SetupTest() {
23 | s.BaseMcpSuite.SetupTest()
24 | // Use mock server for predictable kubeconfig content
25 | mockServer := test.NewMockServer()
26 | s.T().Cleanup(mockServer.Close)
27 | kubeconfig := mockServer.Kubeconfig()
28 | for i := 0; i < 10; i++ {
29 | // Add multiple fake contexts to force configuration_contexts_list tool to appear
30 | // and test minification in configuration_view tool
31 | name := fmt.Sprintf("cluster-%d", i)
32 | kubeconfig.Contexts[name] = clientcmdapi.NewContext()
33 | kubeconfig.Clusters[name+"-cluster"] = clientcmdapi.NewCluster()
34 | kubeconfig.AuthInfos[name+"-auth"] = clientcmdapi.NewAuthInfo()
35 | kubeconfig.Contexts[name].Cluster = name + "-cluster"
36 | kubeconfig.Contexts[name].AuthInfo = name + "-auth"
37 | }
38 | s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
39 | }
40 |
41 | func (s *ConfigurationSuite) TestContextsList() {
42 | s.InitMcpClient()
43 | s.Run("configuration_contexts_list", func() {
44 | toolResult, err := s.CallTool("configuration_contexts_list", map[string]interface{}{})
45 | s.Run("returns contexts", func() {
46 | s.Nilf(err, "call tool failed %v", err)
47 | })
48 | s.Require().NotNil(toolResult, "Expected tool result from call")
49 | s.Lenf(toolResult.Content, 1, "invalid tool result content length %v", len(toolResult.Content))
50 | s.Run("contains context count", func() {
51 | s.Regexpf(`^Available Kubernetes contexts \(11 total`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool count result content %v", toolResult.Content[0].(mcp.TextContent).Text)
52 | })
53 | s.Run("contains default context name", func() {
54 | s.Regexpf(`^Available Kubernetes contexts \(\d+ total, default: fake-context\)`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
55 | s.Regexpf(`(?m)^\*fake-context -> http:\/\/127\.0\.0\.1:\d*$`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
56 | })
57 | })
58 | }
59 |
60 | func (s *ConfigurationSuite) TestConfigurationView() {
61 | s.InitMcpClient()
62 | s.Run("configuration_view", func() {
63 | toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
64 | s.Run("returns configuration", func() {
65 | s.Nilf(err, "call tool failed %v", err)
66 | })
67 | s.Require().NotNil(toolResult, "Expected tool result from call")
68 | var decoded *v1.Config
69 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
70 | s.Run("has yaml content", func() {
71 | s.Nilf(err, "invalid tool result content %v", err)
72 | })
73 | s.Run("returns current-context", func() {
74 | s.Equalf("fake-context", decoded.CurrentContext, "fake-context not found: %v", decoded.CurrentContext)
75 | })
76 | s.Run("returns context info", func() {
77 | s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
78 | s.Equalf("fake-context", decoded.Contexts[0].Name, "fake-context not found: %v", decoded.Contexts)
79 | s.Equalf("fake", decoded.Contexts[0].Context.Cluster, "fake-cluster not found: %v", decoded.Contexts)
80 | s.Equalf("fake", decoded.Contexts[0].Context.AuthInfo, "fake-auth not found: %v", decoded.Contexts)
81 | })
82 | s.Run("returns cluster info", func() {
83 | s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
84 | s.Equalf("fake", decoded.Clusters[0].Name, "fake-cluster not found: %v", decoded.Clusters)
85 | s.Regexpf(`^https?://(127\.0\.0\.1|localhost):\d{1,5}$`, decoded.Clusters[0].Cluster.Server, "fake-server not found: %v", decoded.Clusters)
86 | })
87 | s.Run("returns auth info", func() {
88 | s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
89 | s.Equalf("fake", decoded.AuthInfos[0].Name, "fake-auth not found: %v", decoded.AuthInfos)
90 | })
91 | })
92 | s.Run("configuration_view(minified=false)", func() {
93 | toolResult, err := s.CallTool("configuration_view", map[string]interface{}{
94 | "minified": false,
95 | })
96 | s.Run("returns configuration", func() {
97 | s.Nilf(err, "call tool failed %v", err)
98 | })
99 | var decoded *v1.Config
100 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
101 | s.Run("has yaml content", func() {
102 | s.Nilf(err, "invalid tool result content %v", err)
103 | })
104 | s.Run("returns additional context info", func() {
105 | s.Lenf(decoded.Contexts, 11, "invalid context count, expected 12, got %v", len(decoded.Contexts))
106 | s.Equalf("cluster-0", decoded.Contexts[0].Name, "cluster-0 not found: %v", decoded.Contexts)
107 | s.Equalf("cluster-0-cluster", decoded.Contexts[0].Context.Cluster, "cluster-0-cluster not found: %v", decoded.Contexts)
108 | s.Equalf("cluster-0-auth", decoded.Contexts[0].Context.AuthInfo, "cluster-0-auth not found: %v", decoded.Contexts)
109 | s.Equalf("fake", decoded.Contexts[10].Context.Cluster, "fake not found: %v", decoded.Contexts)
110 | s.Equalf("fake", decoded.Contexts[10].Context.AuthInfo, "fake not found: %v", decoded.Contexts)
111 | s.Equalf("fake-context", decoded.Contexts[10].Name, "fake-context not found: %v", decoded.Contexts)
112 | })
113 | s.Run("returns cluster info", func() {
114 | s.Lenf(decoded.Clusters, 11, "invalid cluster count, expected 2, got %v", len(decoded.Clusters))
115 | s.Equalf("cluster-0-cluster", decoded.Clusters[0].Name, "cluster-0-cluster not found: %v", decoded.Clusters)
116 | s.Equalf("fake", decoded.Clusters[10].Name, "fake not found: %v", decoded.Clusters)
117 | })
118 | s.Run("configuration_view with minified=false returns auth info", func() {
119 | s.Lenf(decoded.AuthInfos, 11, "invalid auth info count, expected 2, got %v", len(decoded.AuthInfos))
120 | s.Equalf("cluster-0-auth", decoded.AuthInfos[0].Name, "cluster-0-auth not found: %v", decoded.AuthInfos)
121 | s.Equalf("fake", decoded.AuthInfos[10].Name, "fake not found: %v", decoded.AuthInfos)
122 | })
123 | })
124 | }
125 |
126 | func (s *ConfigurationSuite) TestConfigurationViewInCluster() {
127 | s.Cfg.KubeConfig = "" // Force in-cluster
128 | kubernetes.InClusterConfig = func() (*rest.Config, error) {
129 | return &rest.Config{
130 | Host: "https://kubernetes.default.svc",
131 | BearerToken: "fake-token",
132 | }, nil
133 | }
134 | s.T().Cleanup(func() { kubernetes.InClusterConfig = rest.InClusterConfig })
135 | s.InitMcpClient()
136 | s.Run("configuration_view", func() {
137 | toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
138 | s.Run("returns configuration", func() {
139 | s.Nilf(err, "call tool failed %v", err)
140 | })
141 | s.Require().NotNil(toolResult, "Expected tool result from call")
142 | var decoded *v1.Config
143 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
144 | s.Run("has yaml content", func() {
145 | s.Nilf(err, "invalid tool result content %v", err)
146 | })
147 | s.Run("returns current-context", func() {
148 | s.Equalf("in-cluster", decoded.CurrentContext, "context not found: %v", decoded.CurrentContext)
149 | })
150 | s.Run("returns context info", func() {
151 | s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
152 | s.Equalf("in-cluster", decoded.Contexts[0].Name, "context not found: %v", decoded.Contexts)
153 | s.Equalf("cluster", decoded.Contexts[0].Context.Cluster, "cluster not found: %v", decoded.Contexts)
154 | s.Equalf("user", decoded.Contexts[0].Context.AuthInfo, "user not found: %v", decoded.Contexts)
155 | })
156 | s.Run("returns cluster info", func() {
157 | s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
158 | s.Equalf("cluster", decoded.Clusters[0].Name, "cluster not found: %v", decoded.Clusters)
159 | s.Equalf("https://kubernetes.default.svc", decoded.Clusters[0].Cluster.Server, "server not found: %v", decoded.Clusters)
160 | })
161 | s.Run("returns auth info", func() {
162 | s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
163 | s.Equalf("user", decoded.AuthInfos[0].Name, "user not found: %v", decoded.AuthInfos)
164 | })
165 | })
166 | }
167 |
168 | func TestConfiguration(t *testing.T) {
169 | suite.Run(t, new(ConfigurationSuite))
170 | }
171 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/pods.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "errors"
7 | "fmt"
8 |
9 | v1 "k8s.io/api/core/v1"
10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
12 | labelutil "k8s.io/apimachinery/pkg/labels"
13 | "k8s.io/apimachinery/pkg/runtime"
14 | "k8s.io/apimachinery/pkg/runtime/schema"
15 | "k8s.io/apimachinery/pkg/util/intstr"
16 | "k8s.io/apimachinery/pkg/util/rand"
17 | "k8s.io/client-go/tools/remotecommand"
18 | "k8s.io/metrics/pkg/apis/metrics"
19 | metricsv1beta1api "k8s.io/metrics/pkg/apis/metrics/v1beta1"
20 | "k8s.io/utils/ptr"
21 |
22 | "github.com/containers/kubernetes-mcp-server/pkg/version"
23 | )
24 |
25 | // Default number of lines to retrieve from the end of the logs
26 | const DefaultTailLines = int64(100)
27 |
28 | type PodsTopOptions struct {
29 | metav1.ListOptions
30 | AllNamespaces bool
31 | Namespace string
32 | Name string
33 | }
34 |
35 | func (k *Kubernetes) PodsListInAllNamespaces(ctx context.Context, options ResourceListOptions) (runtime.Unstructured, error) {
36 | return k.ResourcesList(ctx, &schema.GroupVersionKind{
37 | Group: "", Version: "v1", Kind: "Pod",
38 | }, "", options)
39 | }
40 |
41 | func (k *Kubernetes) PodsListInNamespace(ctx context.Context, namespace string, options ResourceListOptions) (runtime.Unstructured, error) {
42 | return k.ResourcesList(ctx, &schema.GroupVersionKind{
43 | Group: "", Version: "v1", Kind: "Pod",
44 | }, namespace, options)
45 | }
46 |
47 | func (k *Kubernetes) PodsGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
48 | return k.ResourcesGet(ctx, &schema.GroupVersionKind{
49 | Group: "", Version: "v1", Kind: "Pod",
50 | }, k.NamespaceOrDefault(namespace), name)
51 | }
52 |
53 | func (k *Kubernetes) PodsDelete(ctx context.Context, namespace, name string) (string, error) {
54 | namespace = k.NamespaceOrDefault(namespace)
55 | pod, err := k.ResourcesGet(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
56 | if err != nil {
57 | return "", err
58 | }
59 |
60 | isManaged := pod.GetLabels()[AppKubernetesManagedBy] == version.BinaryName
61 | managedLabelSelector := labelutil.Set{
62 | AppKubernetesManagedBy: version.BinaryName,
63 | AppKubernetesName: pod.GetLabels()[AppKubernetesName],
64 | }.AsSelector()
65 |
66 | // Delete managed service
67 | if isManaged {
68 | services, err := k.manager.accessControlClientSet.Services(namespace)
69 | if err != nil {
70 | return "", err
71 | }
72 | if sl, _ := services.List(ctx, metav1.ListOptions{
73 | LabelSelector: managedLabelSelector.String(),
74 | }); sl != nil {
75 | for _, svc := range sl.Items {
76 | _ = services.Delete(ctx, svc.Name, metav1.DeleteOptions{})
77 | }
78 | }
79 | }
80 |
81 | // Delete managed Route
82 | if isManaged && k.supportsGroupVersion("route.openshift.io/v1") {
83 | routeResources := k.manager.dynamicClient.
84 | Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
85 | Namespace(namespace)
86 | if rl, _ := routeResources.List(ctx, metav1.ListOptions{
87 | LabelSelector: managedLabelSelector.String(),
88 | }); rl != nil {
89 | for _, route := range rl.Items {
90 | _ = routeResources.Delete(ctx, route.GetName(), metav1.DeleteOptions{})
91 | }
92 | }
93 |
94 | }
95 | return "Pod deleted successfully",
96 | k.ResourcesDelete(ctx, &schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Pod"}, namespace, name)
97 | }
98 |
99 | func (k *Kubernetes) PodsLog(ctx context.Context, namespace, name, container string, previous bool, tail int64) (string, error) {
100 | pods, err := k.manager.accessControlClientSet.Pods(k.NamespaceOrDefault(namespace))
101 | if err != nil {
102 | return "", err
103 | }
104 |
105 | logOptions := &v1.PodLogOptions{
106 | Container: container,
107 | Previous: previous,
108 | }
109 |
110 | // Only set tailLines if a value is provided (non-zero)
111 | if tail > 0 {
112 | logOptions.TailLines = &tail
113 | } else {
114 | // Default to DefaultTailLines lines when not specified
115 | logOptions.TailLines = ptr.To(DefaultTailLines)
116 | }
117 |
118 | req := pods.GetLogs(name, logOptions)
119 | res := req.Do(ctx)
120 | if res.Error() != nil {
121 | return "", res.Error()
122 | }
123 | rawData, err := res.Raw()
124 | if err != nil {
125 | return "", err
126 | }
127 | return string(rawData), nil
128 | }
129 |
130 | func (k *Kubernetes) PodsRun(ctx context.Context, namespace, name, image string, port int32) ([]*unstructured.Unstructured, error) {
131 | if name == "" {
132 | name = version.BinaryName + "-run-" + rand.String(5)
133 | }
134 | labels := map[string]string{
135 | AppKubernetesName: name,
136 | AppKubernetesComponent: name,
137 | AppKubernetesManagedBy: version.BinaryName,
138 | AppKubernetesPartOf: version.BinaryName + "-run-sandbox",
139 | }
140 | // NewPod
141 | var resources []any
142 | pod := &v1.Pod{
143 | TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Pod"},
144 | ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
145 | Spec: v1.PodSpec{Containers: []v1.Container{{
146 | Name: name,
147 | Image: image,
148 | ImagePullPolicy: v1.PullAlways,
149 | }}},
150 | }
151 | resources = append(resources, pod)
152 | if port > 0 {
153 | pod.Spec.Containers[0].Ports = []v1.ContainerPort{{ContainerPort: port}}
154 | resources = append(resources, &v1.Service{
155 | TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Service"},
156 | ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.NamespaceOrDefault(namespace), Labels: labels},
157 | Spec: v1.ServiceSpec{
158 | Selector: labels,
159 | Type: v1.ServiceTypeClusterIP,
160 | Ports: []v1.ServicePort{{Port: port, TargetPort: intstr.FromInt32(port)}},
161 | },
162 | })
163 | }
164 | if port > 0 && k.supportsGroupVersion("route.openshift.io/v1") {
165 | resources = append(resources, &unstructured.Unstructured{
166 | Object: map[string]interface{}{
167 | "apiVersion": "route.openshift.io/v1",
168 | "kind": "Route",
169 | "metadata": map[string]interface{}{
170 | "name": name,
171 | "namespace": k.NamespaceOrDefault(namespace),
172 | "labels": labels,
173 | },
174 | "spec": map[string]interface{}{
175 | "to": map[string]interface{}{
176 | "kind": "Service",
177 | "name": name,
178 | "weight": 100,
179 | },
180 | "port": map[string]interface{}{
181 | "targetPort": intstr.FromInt32(port),
182 | },
183 | "tls": map[string]interface{}{
184 | "termination": "edge",
185 | "insecureEdgeTerminationPolicy": "Redirect",
186 | },
187 | },
188 | },
189 | })
190 |
191 | }
192 |
193 | // Convert the objects to Unstructured and reuse resourcesCreateOrUpdate functionality
194 | converter := runtime.DefaultUnstructuredConverter
195 | var toCreate []*unstructured.Unstructured
196 | for _, obj := range resources {
197 | m, err := converter.ToUnstructured(obj)
198 | if err != nil {
199 | return nil, err
200 | }
201 | u := &unstructured.Unstructured{}
202 | if err = converter.FromUnstructured(m, u); err != nil {
203 | return nil, err
204 | }
205 | toCreate = append(toCreate, u)
206 | }
207 | return k.resourcesCreateOrUpdate(ctx, toCreate)
208 | }
209 |
210 | func (k *Kubernetes) PodsTop(ctx context.Context, options PodsTopOptions) (*metrics.PodMetricsList, error) {
211 | // TODO, maybe move to mcp Tools setup and omit in case metrics aren't available in the target cluster
212 | if !k.supportsGroupVersion(metrics.GroupName + "/" + metricsv1beta1api.SchemeGroupVersion.Version) {
213 | return nil, errors.New("metrics API is not available")
214 | }
215 | namespace := options.Namespace
216 | if options.AllNamespaces && namespace == "" {
217 | namespace = ""
218 | } else {
219 | namespace = k.NamespaceOrDefault(namespace)
220 | }
221 | return k.manager.accessControlClientSet.PodsMetricses(ctx, namespace, options.Name, options.ListOptions)
222 | }
223 |
224 | func (k *Kubernetes) PodsExec(ctx context.Context, namespace, name, container string, command []string) (string, error) {
225 | namespace = k.NamespaceOrDefault(namespace)
226 | pods, err := k.manager.accessControlClientSet.Pods(namespace)
227 | if err != nil {
228 | return "", err
229 | }
230 | pod, err := pods.Get(ctx, name, metav1.GetOptions{})
231 | if err != nil {
232 | return "", err
233 | }
234 | // https://github.com/kubernetes/kubectl/blob/5366de04e168bcbc11f5e340d131a9ca8b7d0df4/pkg/cmd/exec/exec.go#L350-L352
235 | if pod.Status.Phase == v1.PodSucceeded || pod.Status.Phase == v1.PodFailed {
236 | return "", fmt.Errorf("cannot exec into a container in a completed pod; current phase is %s", pod.Status.Phase)
237 | }
238 | if container == "" {
239 | container = pod.Spec.Containers[0].Name
240 | }
241 | podExecOptions := &v1.PodExecOptions{
242 | Container: container,
243 | Command: command,
244 | Stdout: true,
245 | Stderr: true,
246 | }
247 | executor, err := k.manager.accessControlClientSet.PodsExec(namespace, name, podExecOptions)
248 | if err != nil {
249 | return "", err
250 | }
251 | stdout := bytes.NewBuffer(make([]byte, 0))
252 | stderr := bytes.NewBuffer(make([]byte, 0))
253 | if err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{
254 | Stdout: stdout, Stderr: stderr, Tty: false,
255 | }); err != nil {
256 | return "", err
257 | }
258 | if stdout.Len() > 0 {
259 | return stdout.String(), nil
260 | }
261 | if stderr.Len() > 0 {
262 | return stderr.String(), nil
263 | }
264 | return "", nil
265 | }
266 |
```
--------------------------------------------------------------------------------
/dev/config/ingress/nginx-ingress.yaml:
--------------------------------------------------------------------------------
```yaml
1 | ---
2 | apiVersion: v1
3 | kind: Namespace
4 | metadata:
5 | name: ingress-nginx
6 | labels:
7 | app.kubernetes.io/name: ingress-nginx
8 | app.kubernetes.io/instance: ingress-nginx
9 | ---
10 | apiVersion: v1
11 | kind: ServiceAccount
12 | metadata:
13 | labels:
14 | app.kubernetes.io/name: ingress-nginx
15 | app.kubernetes.io/instance: ingress-nginx
16 | app.kubernetes.io/component: controller
17 | name: ingress-nginx
18 | namespace: ingress-nginx
19 | ---
20 | apiVersion: v1
21 | kind: ConfigMap
22 | metadata:
23 | labels:
24 | app.kubernetes.io/name: ingress-nginx
25 | app.kubernetes.io/instance: ingress-nginx
26 | app.kubernetes.io/component: controller
27 | name: ingress-nginx-controller
28 | namespace: ingress-nginx
29 | data:
30 | allow-snippet-annotations: "true"
31 | ---
32 | apiVersion: rbac.authorization.k8s.io/v1
33 | kind: ClusterRole
34 | metadata:
35 | labels:
36 | app.kubernetes.io/name: ingress-nginx
37 | app.kubernetes.io/instance: ingress-nginx
38 | name: ingress-nginx
39 | rules:
40 | - apiGroups:
41 | - ""
42 | resources:
43 | - configmaps
44 | - endpoints
45 | - nodes
46 | - pods
47 | - secrets
48 | - namespaces
49 | verbs:
50 | - list
51 | - watch
52 | - apiGroups:
53 | - coordination.k8s.io
54 | resources:
55 | - leases
56 | verbs:
57 | - list
58 | - watch
59 | - apiGroups:
60 | - ""
61 | resources:
62 | - nodes
63 | verbs:
64 | - get
65 | - apiGroups:
66 | - ""
67 | resources:
68 | - services
69 | verbs:
70 | - get
71 | - list
72 | - watch
73 | - apiGroups:
74 | - networking.k8s.io
75 | resources:
76 | - ingresses
77 | verbs:
78 | - get
79 | - list
80 | - watch
81 | - apiGroups:
82 | - ""
83 | resources:
84 | - events
85 | verbs:
86 | - create
87 | - patch
88 | - apiGroups:
89 | - networking.k8s.io
90 | resources:
91 | - ingresses/status
92 | verbs:
93 | - update
94 | - apiGroups:
95 | - networking.k8s.io
96 | resources:
97 | - ingressclasses
98 | verbs:
99 | - get
100 | - list
101 | - watch
102 | - apiGroups:
103 | - discovery.k8s.io
104 | resources:
105 | - endpointslices
106 | verbs:
107 | - list
108 | - watch
109 | - get
110 | ---
111 | apiVersion: rbac.authorization.k8s.io/v1
112 | kind: ClusterRoleBinding
113 | metadata:
114 | labels:
115 | app.kubernetes.io/name: ingress-nginx
116 | app.kubernetes.io/instance: ingress-nginx
117 | name: ingress-nginx
118 | roleRef:
119 | apiGroup: rbac.authorization.k8s.io
120 | kind: ClusterRole
121 | name: ingress-nginx
122 | subjects:
123 | - kind: ServiceAccount
124 | name: ingress-nginx
125 | namespace: ingress-nginx
126 | ---
127 | apiVersion: rbac.authorization.k8s.io/v1
128 | kind: Role
129 | metadata:
130 | labels:
131 | app.kubernetes.io/name: ingress-nginx
132 | app.kubernetes.io/instance: ingress-nginx
133 | app.kubernetes.io/component: controller
134 | name: ingress-nginx
135 | namespace: ingress-nginx
136 | rules:
137 | - apiGroups:
138 | - ""
139 | resources:
140 | - namespaces
141 | verbs:
142 | - get
143 | - apiGroups:
144 | - ""
145 | resources:
146 | - configmaps
147 | - pods
148 | - secrets
149 | - endpoints
150 | verbs:
151 | - get
152 | - list
153 | - watch
154 | - apiGroups:
155 | - ""
156 | resources:
157 | - services
158 | verbs:
159 | - get
160 | - list
161 | - watch
162 | - apiGroups:
163 | - networking.k8s.io
164 | resources:
165 | - ingresses
166 | verbs:
167 | - get
168 | - list
169 | - watch
170 | - apiGroups:
171 | - networking.k8s.io
172 | resources:
173 | - ingresses/status
174 | verbs:
175 | - update
176 | - apiGroups:
177 | - networking.k8s.io
178 | resources:
179 | - ingressclasses
180 | verbs:
181 | - get
182 | - list
183 | - watch
184 | - apiGroups:
185 | - coordination.k8s.io
186 | resources:
187 | - leases
188 | resourceNames:
189 | - ingress-nginx-leader
190 | verbs:
191 | - get
192 | - update
193 | - apiGroups:
194 | - coordination.k8s.io
195 | resources:
196 | - leases
197 | verbs:
198 | - create
199 | - apiGroups:
200 | - ""
201 | resources:
202 | - events
203 | verbs:
204 | - create
205 | - patch
206 | - apiGroups:
207 | - discovery.k8s.io
208 | resources:
209 | - endpointslices
210 | verbs:
211 | - list
212 | - watch
213 | - get
214 | ---
215 | apiVersion: rbac.authorization.k8s.io/v1
216 | kind: RoleBinding
217 | metadata:
218 | labels:
219 | app.kubernetes.io/name: ingress-nginx
220 | app.kubernetes.io/instance: ingress-nginx
221 | app.kubernetes.io/component: controller
222 | name: ingress-nginx
223 | namespace: ingress-nginx
224 | roleRef:
225 | apiGroup: rbac.authorization.k8s.io
226 | kind: Role
227 | name: ingress-nginx
228 | subjects:
229 | - kind: ServiceAccount
230 | name: ingress-nginx
231 | namespace: ingress-nginx
232 | ---
233 | apiVersion: v1
234 | kind: Service
235 | metadata:
236 | labels:
237 | app.kubernetes.io/name: ingress-nginx
238 | app.kubernetes.io/instance: ingress-nginx
239 | app.kubernetes.io/component: controller
240 | name: ingress-nginx-controller
241 | namespace: ingress-nginx
242 | spec:
243 | type: NodePort
244 | ports:
245 | - name: http
246 | port: 80
247 | protocol: TCP
248 | targetPort: http
249 | appProtocol: http
250 | - name: https
251 | port: 443
252 | protocol: TCP
253 | targetPort: https
254 | appProtocol: https
255 | selector:
256 | app.kubernetes.io/name: ingress-nginx
257 | app.kubernetes.io/instance: ingress-nginx
258 | app.kubernetes.io/component: controller
259 | ---
260 | apiVersion: apps/v1
261 | kind: Deployment
262 | metadata:
263 | labels:
264 | app.kubernetes.io/name: ingress-nginx
265 | app.kubernetes.io/instance: ingress-nginx
266 | app.kubernetes.io/component: controller
267 | name: ingress-nginx-controller
268 | namespace: ingress-nginx
269 | spec:
270 | selector:
271 | matchLabels:
272 | app.kubernetes.io/name: ingress-nginx
273 | app.kubernetes.io/instance: ingress-nginx
274 | app.kubernetes.io/component: controller
275 | replicas: 1
276 | revisionHistoryLimit: 10
277 | minReadySeconds: 0
278 | template:
279 | metadata:
280 | labels:
281 | app.kubernetes.io/name: ingress-nginx
282 | app.kubernetes.io/instance: ingress-nginx
283 | app.kubernetes.io/component: controller
284 | spec:
285 | dnsPolicy: ClusterFirst
286 | containers:
287 | - name: controller
288 | image: registry.k8s.io/ingress-nginx/controller:v1.11.1
289 | imagePullPolicy: IfNotPresent
290 | lifecycle:
291 | preStop:
292 | exec:
293 | command:
294 | - /wait-shutdown
295 | args:
296 | - /nginx-ingress-controller
297 | - --election-id=ingress-nginx-leader
298 | - --controller-class=k8s.io/ingress-nginx
299 | - --ingress-class=nginx
300 | - --configmap=$(POD_NAMESPACE)/ingress-nginx-controller
301 | - --watch-ingress-without-class=true
302 | securityContext:
303 | runAsNonRoot: true
304 | runAsUser: 101
305 | allowPrivilegeEscalation: false
306 | seccompProfile:
307 | type: RuntimeDefault
308 | capabilities:
309 | drop:
310 | - ALL
311 | add:
312 | - NET_BIND_SERVICE
313 | env:
314 | - name: POD_NAME
315 | valueFrom:
316 | fieldRef:
317 | fieldPath: metadata.name
318 | - name: POD_NAMESPACE
319 | valueFrom:
320 | fieldRef:
321 | fieldPath: metadata.namespace
322 | - name: LD_PRELOAD
323 | value: /usr/local/lib/libmimalloc.so
324 | livenessProbe:
325 | failureThreshold: 5
326 | httpGet:
327 | path: /healthz
328 | port: 10254
329 | scheme: HTTP
330 | initialDelaySeconds: 10
331 | periodSeconds: 10
332 | successThreshold: 1
333 | timeoutSeconds: 1
334 | readinessProbe:
335 | failureThreshold: 3
336 | httpGet:
337 | path: /healthz
338 | port: 10254
339 | scheme: HTTP
340 | initialDelaySeconds: 10
341 | periodSeconds: 10
342 | successThreshold: 1
343 | timeoutSeconds: 1
344 | ports:
345 | - name: http
346 | containerPort: 80
347 | protocol: TCP
348 | hostPort: 80
349 | - name: https
350 | containerPort: 443
351 | protocol: TCP
352 | hostPort: 443
353 | - name: https-alt
354 | containerPort: 443
355 | protocol: TCP
356 | hostPort: 8443
357 | - name: webhook
358 | containerPort: 8443
359 | protocol: TCP
360 | resources:
361 | requests:
362 | cpu: 100m
363 | memory: 90Mi
364 | nodeSelector:
365 | ingress-ready: "true"
366 | kubernetes.io/os: linux
367 | serviceAccountName: ingress-nginx
368 | terminationGracePeriodSeconds: 0
369 | tolerations:
370 | - effect: NoSchedule
371 | key: node-role.kubernetes.io/master
372 | operator: Equal
373 | - effect: NoSchedule
374 | key: node-role.kubernetes.io/control-plane
375 | operator: Equal
376 | ---
377 | apiVersion: networking.k8s.io/v1
378 | kind: IngressClass
379 | metadata:
380 | labels:
381 | app.kubernetes.io/name: ingress-nginx
382 | app.kubernetes.io/instance: ingress-nginx
383 | app.kubernetes.io/component: controller
384 | name: nginx
385 | spec:
386 | controller: k8s.io/ingress-nginx
387 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/kubernetes_derived_test.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 | "testing"
9 |
10 | "github.com/containers/kubernetes-mcp-server/internal/test"
11 | "github.com/containers/kubernetes-mcp-server/pkg/config"
12 | "github.com/stretchr/testify/suite"
13 | )
14 |
15 | type DerivedTestSuite struct {
16 | suite.Suite
17 | }
18 |
19 | func (s *DerivedTestSuite) TestKubeConfig() {
20 | // Create a temporary kubeconfig file for testing
21 | tempDir := s.T().TempDir()
22 | kubeconfigPath := filepath.Join(tempDir, "config")
23 | kubeconfigContent := `
24 | apiVersion: v1
25 | kind: Config
26 | clusters:
27 | - cluster:
28 | server: https://test-cluster.example.com
29 | name: test-cluster
30 | contexts:
31 | - context:
32 | cluster: test-cluster
33 | user: test-user
34 | name: test-context
35 | current-context: test-context
36 | users:
37 | - name: test-user
38 | user:
39 | username: test-username
40 | password: test-password
41 | `
42 | err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0644)
43 | s.Require().NoError(err, "failed to create kubeconfig file")
44 |
45 | s.Run("with no RequireOAuth (default) config", func() {
46 | testStaticConfig := test.Must(config.ReadToml([]byte(`
47 | kubeconfig = "` + strings.ReplaceAll(kubeconfigPath, `\`, `\\`) + `"
48 | `)))
49 | s.Run("without authorization header returns original manager", func() {
50 | testManager, err := NewKubeconfigManager(testStaticConfig, "")
51 | s.Require().NoErrorf(err, "failed to create test manager: %v", err)
52 | s.T().Cleanup(testManager.Close)
53 |
54 | derived, err := testManager.Derived(s.T().Context())
55 | s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
56 |
57 | s.Equal(derived.manager, testManager, "expected original manager, got different manager")
58 | })
59 |
60 | s.Run("with invalid authorization header returns original manager", func() {
61 | testManager, err := NewKubeconfigManager(testStaticConfig, "")
62 | s.Require().NoErrorf(err, "failed to create test manager: %v", err)
63 | s.T().Cleanup(testManager.Close)
64 |
65 | ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "invalid-token")
66 | derived, err := testManager.Derived(ctx)
67 | s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
68 |
69 | s.Equal(derived.manager, testManager, "expected original manager, got different manager")
70 | })
71 |
72 | s.Run("with valid bearer token creates derived manager with correct configuration", func() {
73 | testManager, err := NewKubeconfigManager(testStaticConfig, "")
74 | s.Require().NoErrorf(err, "failed to create test manager: %v", err)
75 | s.T().Cleanup(testManager.Close)
76 |
77 | ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "Bearer aiTana-julIA")
78 | derived, err := testManager.Derived(ctx)
79 | s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
80 |
81 | s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager")
82 | s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager")
83 |
84 | s.Run("RestConfig is correctly copied and sensitive fields are omitted", func() {
85 | derivedCfg := derived.manager.cfg
86 | s.Require().NotNil(derivedCfg, "derived config is nil")
87 |
88 | originalCfg := testManager.cfg
89 | s.Equalf(originalCfg.Host, derivedCfg.Host, "expected Host %s, got %s", originalCfg.Host, derivedCfg.Host)
90 | s.Equalf(originalCfg.APIPath, derivedCfg.APIPath, "expected APIPath %s, got %s", originalCfg.APIPath, derivedCfg.APIPath)
91 | s.Equalf(originalCfg.QPS, derivedCfg.QPS, "expected QPS %f, got %f", originalCfg.QPS, derivedCfg.QPS)
92 | s.Equalf(originalCfg.Burst, derivedCfg.Burst, "expected Burst %d, got %d", originalCfg.Burst, derivedCfg.Burst)
93 | s.Equalf(originalCfg.Timeout, derivedCfg.Timeout, "expected Timeout %v, got %v", originalCfg.Timeout, derivedCfg.Timeout)
94 |
95 | s.Equalf(originalCfg.Insecure, derivedCfg.Insecure, "expected TLS Insecure %v, got %v", originalCfg.Insecure, derivedCfg.Insecure)
96 | s.Equalf(originalCfg.ServerName, derivedCfg.ServerName, "expected TLS ServerName %s, got %s", originalCfg.ServerName, derivedCfg.ServerName)
97 | s.Equalf(originalCfg.CAFile, derivedCfg.CAFile, "expected TLS CAFile %s, got %s", originalCfg.CAFile, derivedCfg.CAFile)
98 | s.Equalf(string(originalCfg.CAData), string(derivedCfg.CAData), "expected TLS CAData %s, got %s", string(originalCfg.CAData), string(derivedCfg.CAData))
99 |
100 | s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken)
101 | s.Equalf("kubernetes-mcp-server/bearer-token-auth", derivedCfg.UserAgent, "expected UserAgent \"kubernetes-mcp-server/bearer-token-auth\", got %s", derivedCfg.UserAgent)
102 |
103 | // Verify that sensitive fields are NOT copied to prevent credential leakage
104 | // The derived config should only use the bearer token from the Authorization header
105 | // and not inherit any authentication credentials from the original kubeconfig
106 | s.Emptyf(derivedCfg.CertFile, "expected TLS CertFile to be empty, got %s", derivedCfg.CertFile)
107 | s.Emptyf(derivedCfg.KeyFile, "expected TLS KeyFile to be empty, got %s", derivedCfg.KeyFile)
108 | s.Emptyf(len(derivedCfg.CertData), "expected TLS CertData to be empty, got %v", derivedCfg.CertData)
109 | s.Emptyf(len(derivedCfg.KeyData), "expected TLS KeyData to be empty, got %v", derivedCfg.KeyData)
110 |
111 | s.Emptyf(derivedCfg.Username, "expected Username to be empty, got %s", derivedCfg.Username)
112 | s.Emptyf(derivedCfg.Password, "expected Password to be empty, got %s", derivedCfg.Password)
113 | s.Nilf(derivedCfg.AuthProvider, "expected AuthProvider to be nil, got %v", derivedCfg.AuthProvider)
114 | s.Nilf(derivedCfg.ExecProvider, "expected ExecProvider to be nil, got %v", derivedCfg.ExecProvider)
115 | s.Emptyf(derivedCfg.BearerTokenFile, "expected BearerTokenFile to be empty, got %s", derivedCfg.BearerTokenFile)
116 | s.Emptyf(derivedCfg.Impersonate.UserName, "expected Impersonate.UserName to be empty, got %s", derivedCfg.Impersonate.UserName)
117 |
118 | // Verify that the original manager still has the sensitive data
119 | s.Falsef(originalCfg.Username == "" && originalCfg.Password == "", "original kubeconfig shouldn't be modified")
120 |
121 | })
122 | s.Run("derived manager has initialized clients", func() {
123 | // Verify that the derived manager has proper clients initialized
124 | s.NotNilf(derived.manager.accessControlClientSet, "expected accessControlClientSet to be initialized")
125 | s.Equalf(testStaticConfig, derived.manager.accessControlClientSet.staticConfig, "staticConfig not properly wired to derived manager")
126 | s.NotNilf(derived.manager.discoveryClient, "expected discoveryClient to be initialized")
127 | s.NotNilf(derived.manager.accessControlRESTMapper, "expected accessControlRESTMapper to be initialized")
128 | s.Equalf(testStaticConfig, derived.manager.accessControlRESTMapper.staticConfig, "staticConfig not properly wired to derived manager")
129 | s.NotNilf(derived.manager.dynamicClient, "expected dynamicClient to be initialized")
130 | })
131 | })
132 | })
133 |
134 | s.Run("with RequireOAuth=true", func() {
135 | testStaticConfig := test.Must(config.ReadToml([]byte(`
136 | kubeconfig = "` + strings.ReplaceAll(kubeconfigPath, `\`, `\\`) + `"
137 | require_oauth = true
138 | `)))
139 |
140 | s.Run("with no authorization header returns oauth token required error", func() {
141 | testManager, err := NewKubeconfigManager(testStaticConfig, "")
142 | s.Require().NoErrorf(err, "failed to create test manager: %v", err)
143 | s.T().Cleanup(testManager.Close)
144 |
145 | derived, err := testManager.Derived(s.T().Context())
146 | s.Require().Error(err, "expected error for missing oauth token, got nil")
147 | s.EqualError(err, "oauth token required", "expected error 'oauth token required', got %s", err.Error())
148 | s.Nil(derived, "expected nil derived manager when oauth token required")
149 | })
150 |
151 | s.Run("with invalid authorization header returns oauth token required error", func() {
152 | testManager, err := NewKubeconfigManager(testStaticConfig, "")
153 | s.Require().NoErrorf(err, "failed to create test manager: %v", err)
154 | s.T().Cleanup(testManager.Close)
155 |
156 | ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "invalid-token")
157 | derived, err := testManager.Derived(ctx)
158 | s.Require().Error(err, "expected error for invalid oauth token, got nil")
159 | s.EqualError(err, "oauth token required", "expected error 'oauth token required', got %s", err.Error())
160 | s.Nil(derived, "expected nil derived manager when oauth token required")
161 | })
162 |
163 | s.Run("with valid bearer token creates derived manager", func() {
164 | testManager, err := NewKubeconfigManager(testStaticConfig, "")
165 | s.Require().NoErrorf(err, "failed to create test manager: %v", err)
166 | s.T().Cleanup(testManager.Close)
167 |
168 | ctx := context.WithValue(s.T().Context(), HeaderKey("Authorization"), "Bearer aiTana-julIA")
169 | derived, err := testManager.Derived(ctx)
170 | s.Require().NoErrorf(err, "failed to create derived manager: %v", err)
171 |
172 | s.NotEqual(derived.manager, testManager, "expected new derived manager, got original manager")
173 | s.Equal(derived.manager.staticConfig, testStaticConfig, "staticConfig not properly wired to derived manager")
174 |
175 | derivedCfg := derived.manager.cfg
176 | s.Require().NotNil(derivedCfg, "derived config is nil")
177 |
178 | s.Equalf("aiTana-julIA", derivedCfg.BearerToken, "expected BearerToken %s, got %s", "aiTana-julIA", derivedCfg.BearerToken)
179 | })
180 | })
181 | }
182 |
183 | func TestDerived(t *testing.T) {
184 | suite.Run(t, new(DerivedTestSuite))
185 | }
186 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes/manager.go:
--------------------------------------------------------------------------------
```go
1 | package kubernetes
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "strings"
8 |
9 | "github.com/containers/kubernetes-mcp-server/pkg/config"
10 | "github.com/containers/kubernetes-mcp-server/pkg/helm"
11 | "github.com/fsnotify/fsnotify"
12 | authenticationv1api "k8s.io/api/authentication/v1"
13 | "k8s.io/apimachinery/pkg/api/meta"
14 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
15 | "k8s.io/client-go/discovery"
16 | "k8s.io/client-go/discovery/cached/memory"
17 | "k8s.io/client-go/dynamic"
18 | "k8s.io/client-go/rest"
19 | "k8s.io/client-go/restmapper"
20 | "k8s.io/client-go/tools/clientcmd"
21 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
22 | "k8s.io/klog/v2"
23 | )
24 |
25 | type Manager struct {
26 | cfg *rest.Config
27 | clientCmdConfig clientcmd.ClientConfig
28 | discoveryClient discovery.CachedDiscoveryInterface
29 | accessControlClientSet *AccessControlClientset
30 | accessControlRESTMapper *AccessControlRESTMapper
31 | dynamicClient *dynamic.DynamicClient
32 |
33 | staticConfig *config.StaticConfig
34 | CloseWatchKubeConfig CloseWatchKubeConfig
35 | }
36 |
37 | var _ helm.Kubernetes = (*Manager)(nil)
38 | var _ Openshift = (*Manager)(nil)
39 |
40 | var (
41 | ErrorKubeconfigInClusterNotAllowed = errors.New("kubeconfig manager cannot be used in in-cluster deployments")
42 | ErrorInClusterNotInCluster = errors.New("in-cluster manager cannot be used outside of a cluster")
43 | )
44 |
45 | func NewKubeconfigManager(config *config.StaticConfig, kubeconfigContext string) (*Manager, error) {
46 | if IsInCluster(config) {
47 | return nil, ErrorKubeconfigInClusterNotAllowed
48 | }
49 |
50 | pathOptions := clientcmd.NewDefaultPathOptions()
51 | if config.KubeConfig != "" {
52 | pathOptions.LoadingRules.ExplicitPath = config.KubeConfig
53 | }
54 | clientCmdConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
55 | pathOptions.LoadingRules,
56 | &clientcmd.ConfigOverrides{
57 | ClusterInfo: clientcmdapi.Cluster{Server: ""},
58 | CurrentContext: kubeconfigContext,
59 | })
60 |
61 | restConfig, err := clientCmdConfig.ClientConfig()
62 | if err != nil {
63 | return nil, fmt.Errorf("failed to create kubernetes rest config from kubeconfig: %v", err)
64 | }
65 |
66 | return newManager(config, restConfig, clientCmdConfig)
67 | }
68 |
69 | func NewInClusterManager(config *config.StaticConfig) (*Manager, error) {
70 | if config.KubeConfig != "" {
71 | return nil, fmt.Errorf("kubeconfig file %s cannot be used with the in-cluster deployments: %v", config.KubeConfig, ErrorKubeconfigInClusterNotAllowed)
72 | }
73 |
74 | if !IsInCluster(config) {
75 | return nil, ErrorInClusterNotInCluster
76 | }
77 |
78 | restConfig, err := InClusterConfig()
79 | if err != nil {
80 | return nil, fmt.Errorf("failed to create in-cluster kubernetes rest config: %v", err)
81 | }
82 |
83 | // Create a dummy kubeconfig clientcmdapi.Config for in-cluster config to be used in places where clientcmd.ClientConfig is required
84 | clientCmdConfig := clientcmdapi.NewConfig()
85 | clientCmdConfig.Clusters["cluster"] = &clientcmdapi.Cluster{
86 | Server: restConfig.Host,
87 | InsecureSkipTLSVerify: restConfig.Insecure,
88 | }
89 | clientCmdConfig.AuthInfos["user"] = &clientcmdapi.AuthInfo{
90 | Token: restConfig.BearerToken,
91 | }
92 | clientCmdConfig.Contexts[inClusterKubeConfigDefaultContext] = &clientcmdapi.Context{
93 | Cluster: "cluster",
94 | AuthInfo: "user",
95 | }
96 | clientCmdConfig.CurrentContext = inClusterKubeConfigDefaultContext
97 |
98 | return newManager(config, restConfig, clientcmd.NewDefaultClientConfig(*clientCmdConfig, nil))
99 | }
100 |
101 | func newManager(config *config.StaticConfig, restConfig *rest.Config, clientCmdConfig clientcmd.ClientConfig) (*Manager, error) {
102 | k8s := &Manager{
103 | staticConfig: config,
104 | cfg: restConfig,
105 | clientCmdConfig: clientCmdConfig,
106 | }
107 | if k8s.cfg.UserAgent == "" {
108 | k8s.cfg.UserAgent = rest.DefaultKubernetesUserAgent()
109 | }
110 | var err error
111 | // TODO: Won't work because not all client-go clients use the shared context (e.g. discovery client uses context.TODO())
112 | //k8s.cfg.Wrap(func(original http.RoundTripper) http.RoundTripper {
113 | // return &impersonateRoundTripper{original}
114 | //})
115 | k8s.accessControlClientSet, err = NewAccessControlClientset(k8s.cfg, k8s.staticConfig)
116 | if err != nil {
117 | return nil, err
118 | }
119 | k8s.discoveryClient = memory.NewMemCacheClient(k8s.accessControlClientSet.DiscoveryClient())
120 | k8s.accessControlRESTMapper = NewAccessControlRESTMapper(
121 | restmapper.NewDeferredDiscoveryRESTMapper(k8s.discoveryClient),
122 | k8s.staticConfig,
123 | )
124 | k8s.dynamicClient, err = dynamic.NewForConfig(k8s.cfg)
125 | if err != nil {
126 | return nil, err
127 | }
128 | return k8s, nil
129 | }
130 |
131 | func (m *Manager) WatchKubeConfig(onKubeConfigChange func() error) {
132 | if m.clientCmdConfig == nil {
133 | return
134 | }
135 | kubeConfigFiles := m.clientCmdConfig.ConfigAccess().GetLoadingPrecedence()
136 | if len(kubeConfigFiles) == 0 {
137 | return
138 | }
139 | watcher, err := fsnotify.NewWatcher()
140 | if err != nil {
141 | return
142 | }
143 | for _, file := range kubeConfigFiles {
144 | _ = watcher.Add(file)
145 | }
146 | go func() {
147 | for {
148 | select {
149 | case _, ok := <-watcher.Events:
150 | if !ok {
151 | return
152 | }
153 | _ = onKubeConfigChange()
154 | case _, ok := <-watcher.Errors:
155 | if !ok {
156 | return
157 | }
158 | }
159 | }
160 | }()
161 | if m.CloseWatchKubeConfig != nil {
162 | _ = m.CloseWatchKubeConfig()
163 | }
164 | m.CloseWatchKubeConfig = watcher.Close
165 | }
166 |
167 | func (m *Manager) Close() {
168 | if m.CloseWatchKubeConfig != nil {
169 | _ = m.CloseWatchKubeConfig()
170 | }
171 | }
172 |
173 | func (m *Manager) configuredNamespace() string {
174 | if ns, _, nsErr := m.clientCmdConfig.Namespace(); nsErr == nil {
175 | return ns
176 | }
177 | return ""
178 | }
179 |
180 | func (m *Manager) NamespaceOrDefault(namespace string) string {
181 | if namespace == "" {
182 | return m.configuredNamespace()
183 | }
184 | return namespace
185 | }
186 |
187 | func (m *Manager) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) {
188 | return m.discoveryClient, nil
189 | }
190 |
191 | func (m *Manager) ToRESTMapper() (meta.RESTMapper, error) {
192 | return m.accessControlRESTMapper, nil
193 | }
194 |
195 | // ToRESTConfig returns the rest.Config object (genericclioptions.RESTClientGetter)
196 | func (m *Manager) ToRESTConfig() (*rest.Config, error) {
197 | return m.cfg, nil
198 | }
199 |
200 | // ToRawKubeConfigLoader returns the clientcmd.ClientConfig object (genericclioptions.RESTClientGetter)
201 | func (m *Manager) ToRawKubeConfigLoader() clientcmd.ClientConfig {
202 | return m.clientCmdConfig
203 | }
204 |
205 | func (m *Manager) VerifyToken(ctx context.Context, token, audience string) (*authenticationv1api.UserInfo, []string, error) {
206 | tokenReviewClient, err := m.accessControlClientSet.TokenReview()
207 | if err != nil {
208 | return nil, nil, err
209 | }
210 | tokenReview := &authenticationv1api.TokenReview{
211 | TypeMeta: metav1.TypeMeta{
212 | APIVersion: "authentication.k8s.io/v1",
213 | Kind: "TokenReview",
214 | },
215 | Spec: authenticationv1api.TokenReviewSpec{
216 | Token: token,
217 | Audiences: []string{audience},
218 | },
219 | }
220 |
221 | result, err := tokenReviewClient.Create(ctx, tokenReview, metav1.CreateOptions{})
222 | if err != nil {
223 | return nil, nil, fmt.Errorf("failed to create token review: %v", err)
224 | }
225 |
226 | if !result.Status.Authenticated {
227 | if result.Status.Error != "" {
228 | return nil, nil, fmt.Errorf("token authentication failed: %s", result.Status.Error)
229 | }
230 | return nil, nil, fmt.Errorf("token authentication failed")
231 | }
232 |
233 | return &result.Status.User, result.Status.Audiences, nil
234 | }
235 |
236 | func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
237 | authorization, ok := ctx.Value(OAuthAuthorizationHeader).(string)
238 | if !ok || !strings.HasPrefix(authorization, "Bearer ") {
239 | if m.staticConfig.RequireOAuth {
240 | return nil, errors.New("oauth token required")
241 | }
242 | return &Kubernetes{manager: m}, nil
243 | }
244 | klog.V(5).Infof("%s header found (Bearer), using provided bearer token", OAuthAuthorizationHeader)
245 | derivedCfg := &rest.Config{
246 | Host: m.cfg.Host,
247 | APIPath: m.cfg.APIPath,
248 | // Copy only server verification TLS settings (CA bundle and server name)
249 | TLSClientConfig: rest.TLSClientConfig{
250 | Insecure: m.cfg.Insecure,
251 | ServerName: m.cfg.ServerName,
252 | CAFile: m.cfg.CAFile,
253 | CAData: m.cfg.CAData,
254 | },
255 | BearerToken: strings.TrimPrefix(authorization, "Bearer "),
256 | // pass custom UserAgent to identify the client
257 | UserAgent: CustomUserAgent,
258 | QPS: m.cfg.QPS,
259 | Burst: m.cfg.Burst,
260 | Timeout: m.cfg.Timeout,
261 | Impersonate: rest.ImpersonationConfig{},
262 | }
263 | clientCmdApiConfig, err := m.clientCmdConfig.RawConfig()
264 | if err != nil {
265 | if m.staticConfig.RequireOAuth {
266 | klog.Errorf("failed to get kubeconfig: %v", err)
267 | return nil, errors.New("failed to get kubeconfig")
268 | }
269 | return &Kubernetes{manager: m}, nil
270 | }
271 | clientCmdApiConfig.AuthInfos = make(map[string]*clientcmdapi.AuthInfo)
272 | derived := &Kubernetes{
273 | manager: &Manager{
274 | clientCmdConfig: clientcmd.NewDefaultClientConfig(clientCmdApiConfig, nil),
275 | cfg: derivedCfg,
276 | staticConfig: m.staticConfig,
277 | },
278 | }
279 | derived.manager.accessControlClientSet, err = NewAccessControlClientset(derived.manager.cfg, derived.manager.staticConfig)
280 | if err != nil {
281 | if m.staticConfig.RequireOAuth {
282 | klog.Errorf("failed to get kubeconfig: %v", err)
283 | return nil, errors.New("failed to get kubeconfig")
284 | }
285 | return &Kubernetes{manager: m}, nil
286 | }
287 | derived.manager.discoveryClient = memory.NewMemCacheClient(derived.manager.accessControlClientSet.DiscoveryClient())
288 | derived.manager.accessControlRESTMapper = NewAccessControlRESTMapper(
289 | restmapper.NewDeferredDiscoveryRESTMapper(derived.manager.discoveryClient),
290 | derived.manager.staticConfig,
291 | )
292 | derived.manager.dynamicClient, err = dynamic.NewForConfig(derived.manager.cfg)
293 | if err != nil {
294 | if m.staticConfig.RequireOAuth {
295 | klog.Errorf("failed to initialize dynamic client: %v", err)
296 | return nil, errors.New("failed to initialize dynamic client")
297 | }
298 | return &Kubernetes{manager: m}, nil
299 | }
300 | return derived, nil
301 | }
302 |
```
--------------------------------------------------------------------------------
/pkg/http/authorization.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "strings"
11 |
12 | "github.com/coreos/go-oidc/v3/oidc"
13 | "github.com/go-jose/go-jose/v4"
14 | "github.com/go-jose/go-jose/v4/jwt"
15 | "golang.org/x/oauth2"
16 | authenticationapiv1 "k8s.io/api/authentication/v1"
17 | "k8s.io/klog/v2"
18 | "k8s.io/utils/strings/slices"
19 |
20 | "github.com/containers/kubernetes-mcp-server/pkg/config"
21 | "github.com/containers/kubernetes-mcp-server/pkg/mcp"
22 | )
23 |
24 | type KubernetesApiTokenVerifier interface {
25 | // KubernetesApiVerifyToken TODO: clarify proper implementation
26 | KubernetesApiVerifyToken(ctx context.Context, cluster, token, audience string) (*authenticationapiv1.UserInfo, []string, error)
27 | // GetTargetParameterName returns the parameter name used for target identification in MCP requests
28 | GetTargetParameterName() string
29 | }
30 |
31 | // extractTargetFromRequest extracts cluster parameter from MCP request body
32 | func extractTargetFromRequest(r *http.Request, targetName string) (string, error) {
33 | if r.Body == nil {
34 | return "", nil
35 | }
36 |
37 | // Read the body
38 | body, err := io.ReadAll(r.Body)
39 | if err != nil {
40 | return "", err
41 | }
42 |
43 | // Restore the body for downstream handlers
44 | r.Body = io.NopCloser(bytes.NewBuffer(body))
45 |
46 | // Parse the MCP request
47 | var mcpRequest struct {
48 | Params struct {
49 | Arguments map[string]interface{} `json:"arguments"`
50 | } `json:"params"`
51 | }
52 |
53 | if err := json.Unmarshal(body, &mcpRequest); err != nil {
54 | // If we can't parse the request, just return empty cluster (will use default)
55 | return "", nil
56 | }
57 |
58 | // Extract target parameter
59 | if cluster, ok := mcpRequest.Params.Arguments[targetName].(string); ok {
60 | return cluster, nil
61 | }
62 |
63 | return "", nil
64 | }
65 |
66 | // write401 sends a 401/Unauthorized response with WWW-Authenticate header.
67 | func write401(w http.ResponseWriter, wwwAuthenticateHeader, errorType, message string) {
68 | w.Header().Set("WWW-Authenticate", wwwAuthenticateHeader+fmt.Sprintf(`, error="%s"`, errorType))
69 | http.Error(w, message, http.StatusUnauthorized)
70 | }
71 |
72 | // AuthorizationMiddleware validates the OAuth flow for protected resources.
73 | //
74 | // The flow is skipped for unprotected resources, such as health checks and well-known endpoints.
75 | //
76 | // There are several auth scenarios supported by this middleware:
77 | //
78 | // 1. requireOAuth is false:
79 | //
80 | // - The OAuth flow is skipped, and the server is effectively unprotected.
81 | // - The request is passed to the next handler without any validation.
82 | //
83 | // see TestAuthorizationRequireOAuthFalse
84 | //
85 | // 2. requireOAuth is set to true, server is protected:
86 | //
87 | // 2.1. Raw Token Validation (oidcProvider is nil):
88 | // - The token is validated offline for basic sanity checks (expiration).
89 | // - If OAuthAudience is set, the token is validated against the audience.
90 | // - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview.
91 | //
92 | // see TestAuthorizationRawToken
93 | //
94 | // 2.2. OIDC Provider Validation (oidcProvider is not nil):
95 | // - The token is validated offline for basic sanity checks (audience and expiration).
96 | // - If OAuthAudience is set, the token is validated against the audience.
97 | // - The token is then validated against the OIDC Provider.
98 | // - If ValidateToken is set, the token is then used against the Kubernetes API Server for TokenReview.
99 | //
100 | // see TestAuthorizationOidcToken
101 | //
102 | // 2.3. OIDC Token Exchange (oidcProvider is not nil, StsClientId and StsAudience are set):
103 | // - The token is validated offline for basic sanity checks (audience and expiration).
104 | // - If OAuthAudience is set, the token is validated against the audience.
105 | // - The token is then validated against the OIDC Provider.
106 | // - If the token is valid, an external account token exchange is performed using
107 | // the OIDC Provider to obtain a new token with the specified audience and scopes.
108 | // - If ValidateToken is set, the exchanged token is then used against the Kubernetes API Server for TokenReview.
109 | //
110 | // see TestAuthorizationOidcTokenExchange
111 | func AuthorizationMiddleware(staticConfig *config.StaticConfig, oidcProvider *oidc.Provider, verifier KubernetesApiTokenVerifier, httpClient *http.Client) func(http.Handler) http.Handler {
112 | return func(next http.Handler) http.Handler {
113 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114 | if r.URL.Path == healthEndpoint || slices.Contains(WellKnownEndpoints, r.URL.EscapedPath()) {
115 | next.ServeHTTP(w, r)
116 | return
117 | }
118 | if !staticConfig.RequireOAuth {
119 | next.ServeHTTP(w, r)
120 | return
121 | }
122 |
123 | wwwAuthenticateHeader := "Bearer realm=\"Kubernetes MCP Server\""
124 | if staticConfig.OAuthAudience != "" {
125 | wwwAuthenticateHeader += fmt.Sprintf(`, audience="%s"`, staticConfig.OAuthAudience)
126 | }
127 |
128 | authHeader := r.Header.Get("Authorization")
129 | if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
130 | klog.V(1).Infof("Authentication failed - missing or invalid bearer token: %s %s from %s", r.Method, r.URL.Path, r.RemoteAddr)
131 | write401(w, wwwAuthenticateHeader, "missing_token", "Unauthorized: Bearer token required")
132 | return
133 | }
134 |
135 | token := strings.TrimPrefix(authHeader, "Bearer ")
136 |
137 | claims, err := ParseJWTClaims(token)
138 | if err == nil && claims == nil {
139 | // Impossible case, but just in case
140 | err = fmt.Errorf("failed to parse JWT claims from token")
141 | }
142 | // Offline validation
143 | if err == nil {
144 | err = claims.ValidateOffline(staticConfig.OAuthAudience)
145 | }
146 | // Online OIDC provider validation
147 | if err == nil {
148 | err = claims.ValidateWithProvider(r.Context(), staticConfig.OAuthAudience, oidcProvider)
149 | }
150 | // Scopes propagation, they are likely to be used for authorization.
151 | if err == nil {
152 | scopes := claims.GetScopes()
153 | klog.V(2).Infof("JWT token validated - Scopes: %v", scopes)
154 | r = r.WithContext(context.WithValue(r.Context(), mcp.TokenScopesContextKey, scopes))
155 | }
156 | // Token exchange with OIDC provider
157 | sts := NewFromConfig(staticConfig, oidcProvider)
158 | // TODO: Maybe the token had already been exchanged, if it has the right audience and scopes, we can skip this step.
159 | if err == nil && sts.IsEnabled() {
160 | var exchangedToken *oauth2.Token
161 | // If the token is valid, we can exchange it for a new token with the specified audience and scopes.
162 | ctx := r.Context()
163 | if httpClient != nil {
164 | ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
165 | }
166 | exchangedToken, err = sts.ExternalAccountTokenExchange(ctx, &oauth2.Token{
167 | AccessToken: claims.Token,
168 | TokenType: "Bearer",
169 | })
170 | if err == nil {
171 | // Replace the original token with the exchanged token
172 | token = exchangedToken.AccessToken
173 | claims, err = ParseJWTClaims(token)
174 | r.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) // TODO: Implement test to verify, THIS IS A CRITICAL PART
175 | }
176 | }
177 | // Kubernetes API Server TokenReview validation
178 | if err == nil && staticConfig.ValidateToken {
179 | targetParameterName := verifier.GetTargetParameterName()
180 | cluster, clusterErr := extractTargetFromRequest(r, targetParameterName)
181 | if clusterErr != nil {
182 | klog.V(2).Infof("Failed to extract cluster from request, using default: %v", clusterErr)
183 | }
184 | err = claims.ValidateWithKubernetesApi(r.Context(), staticConfig.OAuthAudience, cluster, verifier)
185 | }
186 | if err != nil {
187 | klog.V(1).Infof("Authentication failed - JWT validation error: %s %s from %s, error: %v", r.Method, r.URL.Path, r.RemoteAddr, err)
188 | write401(w, wwwAuthenticateHeader, "invalid_token", "Unauthorized: Invalid token")
189 | return
190 | }
191 |
192 | next.ServeHTTP(w, r)
193 | })
194 | }
195 | }
196 |
197 | var allSignatureAlgorithms = []jose.SignatureAlgorithm{
198 | jose.EdDSA,
199 | jose.HS256,
200 | jose.HS384,
201 | jose.HS512,
202 | jose.RS256,
203 | jose.RS384,
204 | jose.RS512,
205 | jose.ES256,
206 | jose.ES384,
207 | jose.ES512,
208 | jose.PS256,
209 | jose.PS384,
210 | jose.PS512,
211 | }
212 |
213 | type JWTClaims struct {
214 | jwt.Claims
215 | Token string `json:"-"`
216 | Scope string `json:"scope,omitempty"`
217 | }
218 |
219 | func (c *JWTClaims) GetScopes() []string {
220 | if c.Scope == "" {
221 | return nil
222 | }
223 | return strings.Fields(c.Scope)
224 | }
225 |
226 | // ValidateOffline Checks if the JWT claims are valid and if the audience matches the expected one.
227 | func (c *JWTClaims) ValidateOffline(audience string) error {
228 | expected := jwt.Expected{}
229 | if audience != "" {
230 | expected.AnyAudience = jwt.Audience{audience}
231 | }
232 | if err := c.Validate(expected); err != nil {
233 | return fmt.Errorf("JWT token validation error: %v", err)
234 | }
235 | return nil
236 | }
237 |
238 | // ValidateWithProvider validates the JWT claims against the OIDC provider.
239 | func (c *JWTClaims) ValidateWithProvider(ctx context.Context, audience string, provider *oidc.Provider) error {
240 | if provider != nil {
241 | verifier := provider.Verifier(&oidc.Config{
242 | ClientID: audience,
243 | })
244 | _, err := verifier.Verify(ctx, c.Token)
245 | if err != nil {
246 | return fmt.Errorf("OIDC token validation error: %v", err)
247 | }
248 | }
249 | return nil
250 | }
251 |
252 | func (c *JWTClaims) ValidateWithKubernetesApi(ctx context.Context, audience, cluster string, verifier KubernetesApiTokenVerifier) error {
253 | if verifier != nil {
254 | _, _, err := verifier.KubernetesApiVerifyToken(ctx, cluster, c.Token, audience)
255 | if err != nil {
256 | return fmt.Errorf("kubernetes API token validation error: %v", err)
257 | }
258 | }
259 | return nil
260 | }
261 |
262 | func ParseJWTClaims(token string) (*JWTClaims, error) {
263 | tkn, err := jwt.ParseSigned(token, allSignatureAlgorithms)
264 | if err != nil {
265 | return nil, fmt.Errorf("failed to parse JWT token: %w", err)
266 | }
267 | claims := &JWTClaims{}
268 | err = tkn.UnsafeClaimsWithoutVerification(claims)
269 | claims.Token = token
270 | return claims, err
271 | }
272 |
```
--------------------------------------------------------------------------------
/pkg/toolsets/core/resources.go:
--------------------------------------------------------------------------------
```go
1 | package core
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 |
8 | "github.com/google/jsonschema-go/jsonschema"
9 | "k8s.io/apimachinery/pkg/runtime/schema"
10 | "k8s.io/utils/ptr"
11 |
12 | "github.com/containers/kubernetes-mcp-server/pkg/api"
13 | internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
14 | "github.com/containers/kubernetes-mcp-server/pkg/output"
15 | )
16 |
17 | func initResources(o internalk8s.Openshift) []api.ServerTool {
18 | commonApiVersion := "v1 Pod, v1 Service, v1 Node, apps/v1 Deployment, networking.k8s.io/v1 Ingress"
19 | if o.IsOpenShift(context.Background()) {
20 | commonApiVersion += ", route.openshift.io/v1 Route"
21 | }
22 | commonApiVersion = fmt.Sprintf("(common apiVersion and kind include: %s)", commonApiVersion)
23 | return []api.ServerTool{
24 | {Tool: api.Tool{
25 | Name: "resources_list",
26 | Description: "List Kubernetes resources and objects in the current cluster by providing their apiVersion and kind and optionally the namespace and label selector\n" + commonApiVersion,
27 | InputSchema: &jsonschema.Schema{
28 | Type: "object",
29 | Properties: map[string]*jsonschema.Schema{
30 | "apiVersion": {
31 | Type: "string",
32 | Description: "apiVersion of the resources (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
33 | },
34 | "kind": {
35 | Type: "string",
36 | Description: "kind of the resources (examples of valid kind are: Pod, Service, Deployment, Ingress)",
37 | },
38 | "namespace": {
39 | Type: "string",
40 | Description: "Optional Namespace to retrieve the namespaced resources from (ignored in case of cluster scoped resources). If not provided, will list resources from all namespaces",
41 | },
42 | "labelSelector": {
43 | Type: "string",
44 | Description: "Optional Kubernetes label selector (e.g. 'app=myapp,env=prod' or 'app in (myapp,yourapp)'), use this option when you want to filter the pods by label",
45 | Pattern: "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]",
46 | },
47 | },
48 | Required: []string{"apiVersion", "kind"},
49 | },
50 | Annotations: api.ToolAnnotations{
51 | Title: "Resources: List",
52 | ReadOnlyHint: ptr.To(true),
53 | DestructiveHint: ptr.To(false),
54 | IdempotentHint: ptr.To(false),
55 | OpenWorldHint: ptr.To(true),
56 | },
57 | }, Handler: resourcesList},
58 | {Tool: api.Tool{
59 | Name: "resources_get",
60 | Description: "Get a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
61 | InputSchema: &jsonschema.Schema{
62 | Type: "object",
63 | Properties: map[string]*jsonschema.Schema{
64 | "apiVersion": {
65 | Type: "string",
66 | Description: "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
67 | },
68 | "kind": {
69 | Type: "string",
70 | Description: "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
71 | },
72 | "namespace": {
73 | Type: "string",
74 | Description: "Optional Namespace to retrieve the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will get resource from configured namespace",
75 | },
76 | "name": {
77 | Type: "string",
78 | Description: "Name of the resource",
79 | },
80 | },
81 | Required: []string{"apiVersion", "kind", "name"},
82 | },
83 | Annotations: api.ToolAnnotations{
84 | Title: "Resources: Get",
85 | ReadOnlyHint: ptr.To(true),
86 | DestructiveHint: ptr.To(false),
87 | IdempotentHint: ptr.To(false),
88 | OpenWorldHint: ptr.To(true),
89 | },
90 | }, Handler: resourcesGet},
91 | {Tool: api.Tool{
92 | Name: "resources_create_or_update",
93 | Description: "Create or update a Kubernetes resource in the current cluster by providing a YAML or JSON representation of the resource\n" + commonApiVersion,
94 | InputSchema: &jsonschema.Schema{
95 | Type: "object",
96 | Properties: map[string]*jsonschema.Schema{
97 | "resource": {
98 | Type: "string",
99 | Description: "A JSON or YAML containing a representation of the Kubernetes resource. Should include top-level fields such as apiVersion,kind,metadata, and spec",
100 | },
101 | },
102 | Required: []string{"resource"},
103 | },
104 | Annotations: api.ToolAnnotations{
105 | Title: "Resources: Create or Update",
106 | ReadOnlyHint: ptr.To(false),
107 | DestructiveHint: ptr.To(true),
108 | IdempotentHint: ptr.To(true),
109 | OpenWorldHint: ptr.To(true),
110 | },
111 | }, Handler: resourcesCreateOrUpdate},
112 | {Tool: api.Tool{
113 | Name: "resources_delete",
114 | Description: "Delete a Kubernetes resource in the current cluster by providing its apiVersion, kind, optionally the namespace, and its name\n" + commonApiVersion,
115 | InputSchema: &jsonschema.Schema{
116 | Type: "object",
117 | Properties: map[string]*jsonschema.Schema{
118 | "apiVersion": {
119 | Type: "string",
120 | Description: "apiVersion of the resource (examples of valid apiVersion are: v1, apps/v1, networking.k8s.io/v1)",
121 | },
122 | "kind": {
123 | Type: "string",
124 | Description: "kind of the resource (examples of valid kind are: Pod, Service, Deployment, Ingress)",
125 | },
126 | "namespace": {
127 | Type: "string",
128 | Description: "Optional Namespace to delete the namespaced resource from (ignored in case of cluster scoped resources). If not provided, will delete resource from configured namespace",
129 | },
130 | "name": {
131 | Type: "string",
132 | Description: "Name of the resource",
133 | },
134 | },
135 | Required: []string{"apiVersion", "kind", "name"},
136 | },
137 | Annotations: api.ToolAnnotations{
138 | Title: "Resources: Delete",
139 | ReadOnlyHint: ptr.To(false),
140 | DestructiveHint: ptr.To(true),
141 | IdempotentHint: ptr.To(true),
142 | OpenWorldHint: ptr.To(true),
143 | },
144 | }, Handler: resourcesDelete},
145 | }
146 | }
147 |
148 | func resourcesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
149 | namespace := params.GetArguments()["namespace"]
150 | if namespace == nil {
151 | namespace = ""
152 | }
153 | labelSelector := params.GetArguments()["labelSelector"]
154 | resourceListOptions := internalk8s.ResourceListOptions{
155 | AsTable: params.ListOutput.AsTable(),
156 | }
157 |
158 | if labelSelector != nil {
159 | l, ok := labelSelector.(string)
160 | if !ok {
161 | return api.NewToolCallResult("", fmt.Errorf("labelSelector is not a string")), nil
162 | }
163 | resourceListOptions.LabelSelector = l
164 | }
165 | gvk, err := parseGroupVersionKind(params.GetArguments())
166 | if err != nil {
167 | return api.NewToolCallResult("", fmt.Errorf("failed to list resources, %s", err)), nil
168 | }
169 |
170 | ns, ok := namespace.(string)
171 | if !ok {
172 | return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
173 | }
174 |
175 | ret, err := params.ResourcesList(params, gvk, ns, resourceListOptions)
176 | if err != nil {
177 | return api.NewToolCallResult("", fmt.Errorf("failed to list resources: %v", err)), nil
178 | }
179 | return api.NewToolCallResult(params.ListOutput.PrintObj(ret)), nil
180 | }
181 |
182 | func resourcesGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
183 | namespace := params.GetArguments()["namespace"]
184 | if namespace == nil {
185 | namespace = ""
186 | }
187 | gvk, err := parseGroupVersionKind(params.GetArguments())
188 | if err != nil {
189 | return api.NewToolCallResult("", fmt.Errorf("failed to get resource, %s", err)), nil
190 | }
191 | name := params.GetArguments()["name"]
192 | if name == nil {
193 | return api.NewToolCallResult("", errors.New("failed to get resource, missing argument name")), nil
194 | }
195 |
196 | ns, ok := namespace.(string)
197 | if !ok {
198 | return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
199 | }
200 |
201 | n, ok := name.(string)
202 | if !ok {
203 | return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
204 | }
205 |
206 | ret, err := params.ResourcesGet(params, gvk, ns, n)
207 | if err != nil {
208 | return api.NewToolCallResult("", fmt.Errorf("failed to get resource: %v", err)), nil
209 | }
210 | return api.NewToolCallResult(output.MarshalYaml(ret)), nil
211 | }
212 |
213 | func resourcesCreateOrUpdate(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
214 | resource := params.GetArguments()["resource"]
215 | if resource == nil || resource == "" {
216 | return api.NewToolCallResult("", errors.New("failed to create or update resources, missing argument resource")), nil
217 | }
218 |
219 | r, ok := resource.(string)
220 | if !ok {
221 | return api.NewToolCallResult("", fmt.Errorf("resource is not a string")), nil
222 | }
223 |
224 | resources, err := params.ResourcesCreateOrUpdate(params, r)
225 | if err != nil {
226 | return api.NewToolCallResult("", fmt.Errorf("failed to create or update resources: %v", err)), nil
227 | }
228 | marshalledYaml, err := output.MarshalYaml(resources)
229 | if err != nil {
230 | err = fmt.Errorf("failed to create or update resources:: %v", err)
231 | }
232 | return api.NewToolCallResult("# The following resources (YAML) have been created or updated successfully\n"+marshalledYaml, err), nil
233 | }
234 |
235 | func resourcesDelete(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
236 | namespace := params.GetArguments()["namespace"]
237 | if namespace == nil {
238 | namespace = ""
239 | }
240 | gvk, err := parseGroupVersionKind(params.GetArguments())
241 | if err != nil {
242 | return api.NewToolCallResult("", fmt.Errorf("failed to delete resource, %s", err)), nil
243 | }
244 | name := params.GetArguments()["name"]
245 | if name == nil {
246 | return api.NewToolCallResult("", errors.New("failed to delete resource, missing argument name")), nil
247 | }
248 |
249 | ns, ok := namespace.(string)
250 | if !ok {
251 | return api.NewToolCallResult("", fmt.Errorf("namespace is not a string")), nil
252 | }
253 |
254 | n, ok := name.(string)
255 | if !ok {
256 | return api.NewToolCallResult("", fmt.Errorf("name is not a string")), nil
257 | }
258 |
259 | err = params.ResourcesDelete(params, gvk, ns, n)
260 | if err != nil {
261 | return api.NewToolCallResult("", fmt.Errorf("failed to delete resource: %v", err)), nil
262 | }
263 | return api.NewToolCallResult("Resource deleted successfully", err), nil
264 | }
265 |
266 | func parseGroupVersionKind(arguments map[string]interface{}) (*schema.GroupVersionKind, error) {
267 | apiVersion := arguments["apiVersion"]
268 | if apiVersion == nil {
269 | return nil, errors.New("missing argument apiVersion")
270 | }
271 | kind := arguments["kind"]
272 | if kind == nil {
273 | return nil, errors.New("missing argument kind")
274 | }
275 |
276 | a, ok := apiVersion.(string)
277 | if !ok {
278 | return nil, fmt.Errorf("name is not a string")
279 | }
280 |
281 | gv, err := schema.ParseGroupVersion(a)
282 | if err != nil {
283 | return nil, errors.New("invalid argument apiVersion")
284 | }
285 | return &schema.GroupVersionKind{Group: gv.Group, Version: gv.Version, Kind: kind.(string)}, nil
286 | }
287 |
```
--------------------------------------------------------------------------------
/pkg/http/authorization_test.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/go-jose/go-jose/v4/jwt"
8 | )
9 |
10 | const (
11 | // https://jwt.io/#token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q // notsecret
12 | tokenBasicNotExpired = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCJ9.ld9aJaQX5k44KOV1bv8MCY2RceAZ9jAjN2vKswKmINNiOpRMl0f8Y0trrq7gdRlKwGLsCUjz8hbHsGcM43QtNrcwfvH5imRnlAKANPUgswwEadCTjASihlo6ADsn9fjAWB4viplFwq8VdzcwpcyActYJi2TBFoRq204STZJIcAW_B40HOuCB2XxQ81V4_XWLzL03Bt-YmYUhliiiE5YSKS1WEEWIbdel--b7Gvp-VS1I2eeiOqV3SelMBHbF9EwKGAkyObg0JhGqr5XHLd6WOmhvLus4eCkyakQMgr2tZIdvbt2yEUDiId6r27tlgAPLmqlyYMEhyiM212_Sth3T3Q" // notsecret
13 | // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ // notsecret
14 | tokenBasicExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MSwiaWF0IjowLCJpc3MiOiJodHRwczovL2t1YmVybmV0ZXMuZGVmYXVsdC5zdmMuY2x1c3Rlci5sb2NhbCIsImp0aSI6Ijk5MjIyZDU2LTM0MGUtNGViNi04NTg4LTI2MTQxMWYzNWQyNiIsImt1YmVybmV0ZXMuaW8iOnsibmFtZXNwYWNlIjoiZGVmYXVsdCIsInNlcnZpY2VhY2NvdW50Ijp7Im5hbWUiOiJkZWZhdWx0IiwidWlkIjoiZWFjYjZhZDItODBiNy00MTc5LTg0M2QtOTJlYjFlNmJiYmE2In19LCJuYmYiOjAsInN1YiI6InN5c3RlbTpzZXJ2aWNlYWNjb3VudDpkZWZhdWx0OmRlZmF1bHQifQ.iVrxt6glbY3Qe_mEtK-lYpx4Z3VC1a7zgGRSmfu29pMmnKhlTk56y0Wx45DQ4PSYCTwC6CJnGGZNbJyr4JS8PQ" // notsecret
15 | // https://jwt.io/#token=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg // notsecret
16 | tokenMultipleAudienceNotExpired = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZDU3YmUwNWI3ZjUzNWIwMzYyYjg2MDJhNTJlNGYxIn0.eyJhdWQiOlsiaHR0cHM6Ly9rdWJlcm5ldGVzLmRlZmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWwiLCJtY3Atc2VydmVyIl0sImV4cCI6MjUzNDAyMjk3MTk5LCJpYXQiOjAsImlzcyI6Imh0dHBzOi8va3ViZXJuZXRlcy5kZWZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsIiwianRpIjoiOTkyMjJkNTYtMzQwZS00ZWI2LTg1ODgtMjYxNDExZjM1ZDI2Iiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0Iiwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImRlZmF1bHQiLCJ1aWQiOiJlYWNiNmFkMi04MGI3LTQxNzktODQzZC05MmViMWU2YmJiYTYifX0sIm5iZiI6MCwic3ViIjoic3lzdGVtOnNlcnZpY2VhY2NvdW50OmRlZmF1bHQ6ZGVmYXVsdCIsInNjb3BlIjoicmVhZCB3cml0ZSJ9.m5mFXp0TDSvgLevQ76nX65N14w1RxTClMaannLLOuBIUEsmXhMYZjGtf5mWMcxVOkSh65rLFiKugaMXgv877Mg" // notsecret
17 | )
18 |
19 | func TestParseJWTClaimsPayloadValid(t *testing.T) {
20 | basicClaims, err := ParseJWTClaims(tokenBasicNotExpired)
21 | t.Run("Is parseable", func(t *testing.T) {
22 | if err != nil {
23 | t.Fatalf("expected no error, got %v", err)
24 | }
25 | if basicClaims == nil {
26 | t.Fatal("expected claims, got nil")
27 | }
28 | })
29 | t.Run("Parses issuer", func(t *testing.T) {
30 | if basicClaims.Issuer != "https://kubernetes.default.svc.cluster.local" {
31 | t.Errorf("expected issuer 'https://kubernetes.default.svc.cluster.local', got %s", basicClaims.Issuer)
32 | }
33 | })
34 | t.Run("Parses audience", func(t *testing.T) {
35 | expectedAudiences := []string{"https://kubernetes.default.svc.cluster.local", "mcp-server"}
36 | for _, expected := range expectedAudiences {
37 | if !basicClaims.Audience.Contains(expected) {
38 | t.Errorf("expected audience to contain %s", expected)
39 | }
40 | }
41 | })
42 | t.Run("Parses expiration", func(t *testing.T) {
43 | if *basicClaims.Expiry != jwt.NumericDate(253402297199) {
44 | t.Errorf("expected expiration 253402297199, got %d", basicClaims.Expiry)
45 | }
46 | })
47 | t.Run("Parses scope", func(t *testing.T) {
48 | scopeClaims, err := ParseJWTClaims(tokenMultipleAudienceNotExpired)
49 | if err != nil {
50 | t.Fatalf("expected no error, got %v", err)
51 | }
52 | if scopeClaims == nil {
53 | t.Fatal("expected claims, got nil")
54 | }
55 |
56 | scopes := scopeClaims.GetScopes()
57 |
58 | expectedScopes := []string{"read", "write"}
59 | if len(scopes) != len(expectedScopes) {
60 | t.Errorf("expected %d scopes, got %d", len(expectedScopes), len(scopes))
61 | }
62 | for i, expectedScope := range expectedScopes {
63 | if scopes[i] != expectedScope {
64 | t.Errorf("expected scope[%d] to be '%s', got '%s'", i, expectedScope, scopes[i])
65 | }
66 | }
67 | })
68 | t.Run("Parses expired token", func(t *testing.T) {
69 | expiredClaims, err := ParseJWTClaims(tokenBasicExpired)
70 | if err != nil {
71 | t.Fatalf("expected no error, got %v", err)
72 | }
73 |
74 | if *expiredClaims.Expiry != jwt.NumericDate(1) {
75 | t.Errorf("expected expiration 1, got %d", basicClaims.Expiry)
76 | }
77 | })
78 | }
79 |
80 | func TestParseJWTClaimsPayloadInvalid(t *testing.T) {
81 | t.Run("invalid token segments", func(t *testing.T) {
82 | invalidToken := "header.payload.signature.extra"
83 |
84 | _, err := ParseJWTClaims(invalidToken)
85 | if err == nil {
86 | t.Fatal("expected error for invalid token segments, got nil")
87 | }
88 |
89 | if !strings.Contains(err.Error(), "compact JWS format must have three parts") {
90 | t.Errorf("expected invalid token segments error message, got %v", err)
91 | }
92 | })
93 | t.Run("invalid base64 payload", func(t *testing.T) {
94 | invalidPayload := strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid")
95 |
96 | _, err := ParseJWTClaims(invalidPayload)
97 | if err == nil {
98 | t.Fatal("expected error for invalid base64, got nil")
99 | }
100 |
101 | if !strings.Contains(err.Error(), "illegal base64 data") {
102 | t.Errorf("expected decode error message, got %v", err)
103 | }
104 | })
105 | }
106 |
107 | func TestJWTTokenValidateOffline(t *testing.T) {
108 | t.Run("expired token returns error", func(t *testing.T) {
109 | claims, err := ParseJWTClaims(tokenBasicExpired)
110 | if err != nil {
111 | t.Fatalf("expected no error for expired token parsing, got %v", err)
112 | }
113 |
114 | err = claims.ValidateOffline("mcp-server")
115 | if err == nil {
116 | t.Fatalf("expected error for expired token, got nil")
117 | }
118 |
119 | if !strings.Contains(err.Error(), "token is expired (exp)") {
120 | t.Errorf("expected expiration error message, got %v", err)
121 | }
122 | })
123 |
124 | t.Run("multiple audiences with correct one", func(t *testing.T) {
125 | claims, err := ParseJWTClaims(tokenMultipleAudienceNotExpired)
126 | if err != nil {
127 | t.Fatalf("expected no error for multiple audience token parsing, got %v", err)
128 | }
129 | if claims == nil {
130 | t.Fatalf("expected claims to be returned, got nil")
131 | }
132 |
133 | err = claims.ValidateOffline("mcp-server")
134 | if err != nil {
135 | t.Fatalf("expected no error for valid audience, got %v", err)
136 | }
137 | })
138 |
139 | t.Run("multiple audiences with mismatch returns error", func(t *testing.T) {
140 | claims, err := ParseJWTClaims(tokenMultipleAudienceNotExpired)
141 | if err != nil {
142 | t.Fatalf("expected no error for multiple audience token parsing, got %v", err)
143 | }
144 | if claims == nil {
145 | t.Fatalf("expected claims to be returned, got nil")
146 | }
147 |
148 | err = claims.ValidateOffline("missing-audience")
149 | if err == nil {
150 | t.Fatalf("expected error for token with wrong audience, got nil")
151 | }
152 |
153 | if !strings.Contains(err.Error(), "invalid audience claim (aud)") {
154 | t.Errorf("expected audience mismatch error, got %v", err)
155 | }
156 | })
157 | }
158 |
159 | func TestJWTClaimsGetScopes(t *testing.T) {
160 | t.Run("no scopes", func(t *testing.T) {
161 | claims, err := ParseJWTClaims(tokenBasicExpired)
162 | if err != nil {
163 | t.Fatalf("expected no error for parsing token, got %v", err)
164 | }
165 |
166 | if scopes := claims.GetScopes(); len(scopes) != 0 {
167 | t.Errorf("expected no scopes, got %d", len(scopes))
168 | }
169 | })
170 | t.Run("single scope", func(t *testing.T) {
171 | claims := &JWTClaims{
172 | Scope: "read",
173 | }
174 | scopes := claims.GetScopes()
175 | expected := []string{"read"}
176 |
177 | if len(scopes) != 1 {
178 | t.Errorf("expected 1 scope, got %d", len(scopes))
179 | }
180 | if scopes[0] != expected[0] {
181 | t.Errorf("expected scope 'read', got '%s'", scopes[0])
182 | }
183 | })
184 |
185 | t.Run("multiple scopes", func(t *testing.T) {
186 | claims := &JWTClaims{
187 | Scope: "read write admin",
188 | }
189 | scopes := claims.GetScopes()
190 | expected := []string{"read", "write", "admin"}
191 |
192 | if len(scopes) != 3 {
193 | t.Errorf("expected 3 scopes, got %d", len(scopes))
194 | }
195 |
196 | for i, expectedScope := range expected {
197 | if i >= len(scopes) || scopes[i] != expectedScope {
198 | t.Errorf("expected scope[%d] to be '%s', got '%s'", i, expectedScope, scopes[i])
199 | }
200 | }
201 | })
202 |
203 | t.Run("scopes with extra whitespace", func(t *testing.T) {
204 | claims := &JWTClaims{
205 | Scope: " read write admin ",
206 | }
207 | scopes := claims.GetScopes()
208 | expected := []string{"read", "write", "admin"}
209 |
210 | if len(scopes) != 3 {
211 | t.Errorf("expected 3 scopes, got %d", len(scopes))
212 | }
213 |
214 | for i, expectedScope := range expected {
215 | if i >= len(scopes) || scopes[i] != expectedScope {
216 | t.Errorf("expected scope[%d] to be '%s', got '%s'", i, expectedScope, scopes[i])
217 | }
218 | }
219 | })
220 | }
221 |
```
--------------------------------------------------------------------------------
/pkg/mcp/tool_mutator_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/containers/kubernetes-mcp-server/pkg/api"
7 | "github.com/google/jsonschema-go/jsonschema"
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | "github.com/stretchr/testify/suite"
11 | "k8s.io/utils/ptr"
12 | )
13 |
14 | // createTestTool creates a basic ServerTool for testing
15 | func createTestTool(name string) api.ServerTool {
16 | return api.ServerTool{
17 | Tool: api.Tool{
18 | Name: name,
19 | Description: "A test tool",
20 | InputSchema: &jsonschema.Schema{
21 | Type: "object",
22 | Properties: make(map[string]*jsonschema.Schema),
23 | },
24 | },
25 | }
26 | }
27 |
28 | // createTestToolWithNilSchema creates a ServerTool with nil InputSchema for testing
29 | func createTestToolWithNilSchema(name string) api.ServerTool {
30 | return api.ServerTool{
31 | Tool: api.Tool{
32 | Name: name,
33 | Description: "A test tool",
34 | InputSchema: nil,
35 | },
36 | }
37 | }
38 |
39 | // createTestToolWithNilProperties creates a ServerTool with nil Properties for testing
40 | func createTestToolWithNilProperties(name string) api.ServerTool {
41 | return api.ServerTool{
42 | Tool: api.Tool{
43 | Name: name,
44 | Description: "A test tool",
45 | InputSchema: &jsonschema.Schema{
46 | Type: "object",
47 | Properties: nil,
48 | },
49 | },
50 | }
51 | }
52 |
53 | // createTestToolWithExistingProperties creates a ServerTool with existing properties for testing
54 | func createTestToolWithExistingProperties(name string) api.ServerTool {
55 | return api.ServerTool{
56 | Tool: api.Tool{
57 | Name: name,
58 | Description: "A test tool",
59 | InputSchema: &jsonschema.Schema{
60 | Type: "object",
61 | Properties: map[string]*jsonschema.Schema{
62 | "existing-prop": {Type: "string"},
63 | },
64 | },
65 | },
66 | }
67 | }
68 |
69 | func TestWithClusterParameter(t *testing.T) {
70 | tests := []struct {
71 | name string
72 | defaultCluster string
73 | targetParameterName string
74 | clusters []string
75 | toolName string
76 | toolFactory func(string) api.ServerTool
77 | expectCluster bool
78 | expectEnum bool
79 | enumCount int
80 | }{
81 | {
82 | name: "adds cluster parameter when multiple clusters provided",
83 | defaultCluster: "default-cluster",
84 | clusters: []string{"cluster1", "cluster2", "cluster3"},
85 | toolName: "test-tool",
86 | toolFactory: createTestTool,
87 | expectCluster: true,
88 | expectEnum: true,
89 | enumCount: 3,
90 | },
91 | {
92 | name: "does not add cluster parameter when single cluster provided",
93 | defaultCluster: "default-cluster",
94 | clusters: []string{"single-cluster"},
95 | toolName: "test-tool",
96 | toolFactory: createTestTool,
97 | expectCluster: false,
98 | expectEnum: false,
99 | enumCount: 0,
100 | },
101 | {
102 | name: "creates InputSchema when nil",
103 | defaultCluster: "default-cluster",
104 | clusters: []string{"cluster1", "cluster2"},
105 | toolName: "test-tool",
106 | toolFactory: createTestToolWithNilSchema,
107 | expectCluster: true,
108 | expectEnum: true,
109 | enumCount: 2,
110 | },
111 | {
112 | name: "creates Properties map when nil",
113 | defaultCluster: "default-cluster",
114 | clusters: []string{"cluster1", "cluster2"},
115 | toolName: "test-tool",
116 | toolFactory: createTestToolWithNilProperties,
117 | expectCluster: true,
118 | expectEnum: true,
119 | enumCount: 2,
120 | },
121 | {
122 | name: "preserves existing properties",
123 | defaultCluster: "default-cluster",
124 | clusters: []string{"cluster1", "cluster2"},
125 | toolName: "test-tool",
126 | toolFactory: createTestToolWithExistingProperties,
127 | expectCluster: true,
128 | expectEnum: true,
129 | enumCount: 2,
130 | },
131 | }
132 |
133 | for _, tt := range tests {
134 | t.Run(tt.name, func(t *testing.T) {
135 | if tt.targetParameterName == "" {
136 | tt.targetParameterName = "cluster"
137 | }
138 | mutator := WithTargetParameter(tt.defaultCluster, tt.targetParameterName, tt.clusters)
139 | tool := tt.toolFactory(tt.toolName)
140 | originalTool := tool // Keep reference to check if tool was unchanged
141 |
142 | result := mutator(tool)
143 |
144 | if !tt.expectCluster {
145 | if tt.toolName == "skip-this-tool" {
146 | // For skipped tools, the entire tool should be unchanged
147 | assert.Equal(t, originalTool, result)
148 | } else {
149 | // For single cluster, schema should exist but no cluster property
150 | require.NotNil(t, result.Tool.InputSchema)
151 | require.NotNil(t, result.Tool.InputSchema.Properties)
152 | _, exists := result.Tool.InputSchema.Properties["cluster"]
153 | assert.False(t, exists, "cluster property should not exist")
154 | }
155 | return
156 | }
157 |
158 | // Common assertions for cases where cluster parameter should be added
159 | require.NotNil(t, result.Tool.InputSchema)
160 | assert.Equal(t, "object", result.Tool.InputSchema.Type)
161 | require.NotNil(t, result.Tool.InputSchema.Properties)
162 |
163 | clusterProperty, exists := result.Tool.InputSchema.Properties["cluster"]
164 | assert.True(t, exists, "cluster property should exist")
165 | assert.NotNil(t, clusterProperty)
166 | assert.Equal(t, "string", clusterProperty.Type)
167 | assert.Contains(t, clusterProperty.Description, tt.defaultCluster)
168 |
169 | if tt.expectEnum {
170 | assert.NotNil(t, clusterProperty.Enum)
171 | assert.Equal(t, tt.enumCount, len(clusterProperty.Enum))
172 | for _, cluster := range tt.clusters {
173 | assert.Contains(t, clusterProperty.Enum, cluster)
174 | }
175 | }
176 | })
177 | }
178 | }
179 |
180 | func TestCreateClusterProperty(t *testing.T) {
181 | tests := []struct {
182 | name string
183 | defaultCluster string
184 | targetName string
185 | clusters []string
186 | expectEnum bool
187 | expectedCount int
188 | }{
189 | {
190 | name: "creates property with enum when clusters <= maxClustersInEnum",
191 | defaultCluster: "default",
192 | targetName: "cluster",
193 | clusters: []string{"cluster1", "cluster2", "cluster3"},
194 | expectEnum: true,
195 | expectedCount: 3,
196 | },
197 | {
198 | name: "creates property without enum when clusters > maxClustersInEnum",
199 | defaultCluster: "default",
200 | targetName: "cluster",
201 | clusters: make([]string, maxTargetsInEnum+5), // 20 clusters
202 | expectEnum: false,
203 | expectedCount: 0,
204 | },
205 | {
206 | name: "creates property with exact maxClustersInEnum clusters",
207 | defaultCluster: "default",
208 | targetName: "cluster",
209 | clusters: make([]string, maxTargetsInEnum),
210 | expectEnum: true,
211 | expectedCount: maxTargetsInEnum,
212 | },
213 | {
214 | name: "handles single cluster",
215 | defaultCluster: "default",
216 | targetName: "cluster",
217 | clusters: []string{"single-cluster"},
218 | expectEnum: true,
219 | expectedCount: 1,
220 | },
221 | {
222 | name: "handles empty clusters list",
223 | defaultCluster: "default",
224 | targetName: "cluster",
225 | clusters: []string{},
226 | expectEnum: true,
227 | expectedCount: 0,
228 | },
229 | }
230 |
231 | for _, tt := range tests {
232 | t.Run(tt.name, func(t *testing.T) {
233 | // Initialize clusters with names if they were created with make()
234 | if len(tt.clusters) > 3 && tt.clusters[0] == "" {
235 | for i := range tt.clusters {
236 | tt.clusters[i] = "cluster" + string(rune('A'+i))
237 | }
238 | }
239 |
240 | property := createTargetProperty(tt.defaultCluster, tt.targetName, tt.clusters)
241 |
242 | assert.Equal(t, "string", property.Type)
243 | assert.Contains(t, property.Description, tt.defaultCluster)
244 | assert.Contains(t, property.Description, "Defaults to "+tt.defaultCluster+" if not set")
245 |
246 | if tt.expectEnum {
247 | assert.NotNil(t, property.Enum, "enum should be created")
248 | assert.Equal(t, tt.expectedCount, len(property.Enum))
249 | if tt.expectedCount > 0 && tt.expectedCount <= 3 {
250 | // Only check specific values for small, predefined lists
251 | for _, cluster := range tt.clusters {
252 | assert.Contains(t, property.Enum, cluster)
253 | }
254 | }
255 | } else {
256 | assert.Nil(t, property.Enum, "enum should not be created for too many clusters")
257 | }
258 | })
259 | }
260 | }
261 |
262 | func TestToolMutatorType(t *testing.T) {
263 | t.Run("ToolMutator type can be used as function", func(t *testing.T) {
264 | var mutator ToolMutator = func(tool api.ServerTool) api.ServerTool {
265 | tool.Tool.Name = "modified-" + tool.Tool.Name
266 | return tool
267 | }
268 |
269 | originalTool := createTestTool("original")
270 | result := mutator(originalTool)
271 | assert.Equal(t, "modified-original", result.Tool.Name)
272 | })
273 | }
274 |
275 | func TestMaxClustersInEnumConstant(t *testing.T) {
276 | t.Run("maxClustersInEnum has expected value", func(t *testing.T) {
277 | assert.Equal(t, 5, maxTargetsInEnum, "maxClustersInEnum should be 5")
278 | })
279 | }
280 |
281 | type TargetParameterToolMutatorSuite struct {
282 | suite.Suite
283 | }
284 |
285 | func (s *TargetParameterToolMutatorSuite) TestClusterAwareTool() {
286 | tm := WithTargetParameter("default-cluster", "cluster", []string{"cluster-1", "cluster-2", "cluster-3"})
287 | tool := createTestTool("cluster-aware-tool")
288 | // Tools are cluster-aware by default
289 | tm(tool)
290 | s.Require().NotNil(tool.Tool.InputSchema.Properties)
291 | s.Run("adds cluster parameter", func() {
292 | s.NotNil(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
293 | })
294 | s.Run("adds correct description", func() {
295 | desc := tool.Tool.InputSchema.Properties["cluster"].Description
296 | s.Contains(desc, "Optional parameter selecting which cluster to run the tool in", "Expected description to mention cluster selection")
297 | s.Contains(desc, "Defaults to default-cluster if not set", "Expected description to mention default cluster")
298 | })
299 | s.Run("adds enum with clusters", func() {
300 | s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
301 | enum := tool.Tool.InputSchema.Properties["cluster"].Enum
302 | s.NotNilf(enum, "Expected enum to be set")
303 | s.Equal(3, len(enum), "Expected enum to have 3 entries")
304 | s.Contains(enum, "cluster-1", "Expected enum to contain cluster-1")
305 | s.Contains(enum, "cluster-2", "Expected enum to contain cluster-2")
306 | s.Contains(enum, "cluster-3", "Expected enum to contain cluster-3")
307 | })
308 | }
309 |
310 | func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolSingleCluster() {
311 | tm := WithTargetParameter("default", "cluster", []string{"only-cluster"})
312 | tool := createTestTool("cluster-aware-tool-single-cluster")
313 | // Tools are cluster-aware by default
314 | tm(tool)
315 | s.Run("does not add cluster parameter for single cluster", func() {
316 | s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added for single cluster")
317 | })
318 | }
319 |
320 | func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolMultipleClusters() {
321 | tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2", "cluster-3", "cluster-4", "cluster-5", "cluster-6"})
322 | tool := createTestTool("cluster-aware-tool-multiple-clusters")
323 | // Tools are cluster-aware by default
324 | tm(tool)
325 | s.Run("adds cluster parameter", func() {
326 | s.NotNilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
327 | })
328 | s.Run("does not add enum when list of clusters is > 5", func() {
329 | s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
330 | enum := tool.Tool.InputSchema.Properties["cluster"].Enum
331 | s.Nilf(enum, "Expected enum to not be set for too many clusters")
332 | })
333 | }
334 |
335 | func (s *TargetParameterToolMutatorSuite) TestNonClusterAwareTool() {
336 | tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2"})
337 | tool := createTestTool("non-cluster-aware-tool")
338 | tool.ClusterAware = ptr.To(false)
339 | tm(tool)
340 | s.Run("does not add cluster parameter", func() {
341 | s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added")
342 | })
343 | }
344 |
345 | func TestTargetParameterToolMutator(t *testing.T) {
346 | suite.Run(t, new(TargetParameterToolMutatorSuite))
347 | }
348 |
```
--------------------------------------------------------------------------------
/pkg/mcp/pods_top_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "net/http"
5 | "regexp"
6 | "testing"
7 |
8 | "github.com/BurntSushi/toml"
9 | "github.com/containers/kubernetes-mcp-server/internal/test"
10 | "github.com/mark3labs/mcp-go/mcp"
11 | "github.com/stretchr/testify/suite"
12 | )
13 |
14 | type PodsTopSuite struct {
15 | BaseMcpSuite
16 | mockServer *test.MockServer
17 | }
18 |
19 | func (s *PodsTopSuite) SetupTest() {
20 | s.BaseMcpSuite.SetupTest()
21 | s.mockServer = test.NewMockServer()
22 | s.Cfg.KubeConfig = s.mockServer.KubeconfigFile(s.T())
23 | }
24 |
25 | func (s *PodsTopSuite) TearDownTest() {
26 | s.BaseMcpSuite.TearDownTest()
27 | if s.mockServer != nil {
28 | s.mockServer.Close()
29 | }
30 | }
31 |
32 | func (s *PodsTopSuite) TestPodsTopMetricsUnavailable() {
33 | s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
34 | w.Header().Set("Content-Type", "application/json")
35 | // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
36 | if req.URL.Path == "/api" {
37 | _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":[],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
38 | return
39 | }
40 | // Request Performed by DiscoveryClient to Kube API (Get API Groups)
41 | if req.URL.Path == "/apis" {
42 | _, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
43 | return
44 | }
45 | }))
46 | s.InitMcpClient()
47 |
48 | s.Run("pods_top with metrics API not available", func() {
49 | result, err := s.CallTool("pods_top", map[string]interface{}{})
50 | s.NoError(err, "call tool failed %v", err)
51 | s.Require().NoError(err)
52 | s.True(result.IsError, "call tool should have returned an error")
53 | s.Equalf("failed to get pods top: metrics API is not available", result.Content[0].(mcp.TextContent).Text,
54 | "call tool returned unexpected content: %s", result.Content[0].(mcp.TextContent).Text)
55 | })
56 | }
57 |
58 | func (s *PodsTopSuite) TestPodsTopMetricsAvailable() {
59 | s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
60 | w.Header().Set("Content-Type", "application/json")
61 | // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
62 | if req.URL.Path == "/api" {
63 | _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
64 | return
65 | }
66 | // Request Performed by DiscoveryClient to Kube API (Get API Groups)
67 | if req.URL.Path == "/apis" {
68 | _, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
69 | return
70 | }
71 | // Request Performed by DiscoveryClient to Kube API (Get API Resources)
72 | if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
73 | _, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
74 | return
75 | }
76 | // Pod Metrics from all namespaces
77 | if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/pods" {
78 | if req.URL.Query().Get("labelSelector") == "app=pod-ns-5-42" {
79 | _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
80 | `{"metadata":{"name":"pod-ns-5-42","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"42m","memory":"42Mi","swap":"42Mi"}}]}` +
81 | `]}`))
82 | } else {
83 | _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
84 | `{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"100m","memory":"200Mi","swap":"13Mi"}},{"name":"container-2","usage":{"cpu":"200m","memory":"300Mi","swap":"37Mi"}}]},` +
85 | `{"metadata":{"name":"pod-2","namespace":"ns-1"},"containers":[{"name":"container-1-ns-1","usage":{"cpu":"300m","memory":"400Mi","swap":"42Mi"}}]}` +
86 | `]}`))
87 |
88 | }
89 | return
90 | }
91 | // Pod Metrics from configured namespace
92 | if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/default/pods" {
93 | _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
94 | `{"metadata":{"name":"pod-1","namespace":"default"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi","swap":"13Mi"}},{"name":"container-2","usage":{"cpu":"30m","memory":"40Mi","swap":"37Mi"}}]}` +
95 | `]}`))
96 | return
97 | }
98 | // Pod Metrics from ns-5 namespace
99 | if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods" {
100 | _, _ = w.Write([]byte(`{"kind":"PodMetricsList","apiVersion":"metrics.k8s.io/v1beta1","items":[` +
101 | `{"metadata":{"name":"pod-ns-5-1","namespace":"ns-5"},"containers":[{"name":"container-1","usage":{"cpu":"10m","memory":"20Mi","swap":"42Mi"}}]}` +
102 | `]}`))
103 | return
104 | }
105 | // Pod Metrics from ns-5 namespace with pod-ns-5-5 pod name
106 | if req.URL.Path == "/apis/metrics.k8s.io/v1beta1/namespaces/ns-5/pods/pod-ns-5-5" {
107 | _, _ = w.Write([]byte(`{"kind":"PodMetrics","apiVersion":"metrics.k8s.io/v1beta1",` +
108 | `"metadata":{"name":"pod-ns-5-5","namespace":"ns-5"},` +
109 | `"containers":[{"name":"container-1","usage":{"cpu":"13m","memory":"37Mi","swap":"42Mi"}}]` +
110 | `}`))
111 | }
112 | }))
113 | s.InitMcpClient()
114 |
115 | s.Run("pods_top(defaults) returns pod metrics from all namespaces", func() {
116 | result, err := s.CallTool("pods_top", map[string]interface{}{})
117 | s.Require().NotNil(result)
118 | s.NoErrorf(err, "call tool failed %v", err)
119 | textContent := result.Content[0].(mcp.TextContent).Text
120 | s.Falsef(result.IsError, "call tool failed %v", textContent)
121 |
122 | expectedHeaders := regexp.MustCompile(`(?m)^\s*NAMESPACE\s+POD\s+NAME\s+CPU\(cores\)\s+MEMORY\(bytes\)\s+SWAP\(bytes\)\s*$`)
123 | s.Regexpf(expectedHeaders, textContent, "expected headers '%s' not found in output:\n%s", expectedHeaders.String(), textContent)
124 | expectedRows := []string{
125 | "default\\s+pod-1\\s+container-1\\s+100m\\s+200Mi\\s+13Mi",
126 | "default\\s+pod-1\\s+container-2\\s+200m\\s+300Mi\\s+37Mi",
127 | "ns-1\\s+pod-2\\s+container-1-ns-1\\s+300m\\s+400Mi\\s+42Mi",
128 | }
129 |
130 | for _, row := range expectedRows {
131 | s.Regexpf(row, textContent, "expected row '%s' not found in output:\n%s", row, textContent)
132 | }
133 |
134 | expectedTotal := regexp.MustCompile(`(?m)^\s+600m\s+900Mi\s+92Mi\s*$`)
135 | s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
136 | })
137 |
138 | s.Run("pods_top(allNamespaces=false) returns pod metrics from configured namespace", func() {
139 | result, err := s.CallTool("pods_top", map[string]interface{}{
140 | "all_namespaces": false,
141 | })
142 | s.Require().NotNil(result)
143 | s.NoErrorf(err, "call tool failed %v", err)
144 | textContent := result.Content[0].(mcp.TextContent).Text
145 | s.Falsef(result.IsError, "call tool failed %v", textContent)
146 |
147 | expectedRows := []string{
148 | "default\\s+pod-1\\s+container-1\\s+10m\\s+20Mi\\s+13Mi",
149 | "default\\s+pod-1\\s+container-2\\s+30m\\s+40Mi\\s+37Mi",
150 | }
151 | for _, row := range expectedRows {
152 | s.Regexpf(row, textContent, "expected row '%s' not found in output:\n%s", row, textContent)
153 | }
154 |
155 | expectedTotal := regexp.MustCompile(`(?m)^\s+40m\s+60Mi\s+50Mi\s*$`)
156 | s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
157 | })
158 |
159 | s.Run("pods_top(namespace=ns-5) returns pod metrics from provided namespace", func() {
160 | result, err := s.CallTool("pods_top", map[string]interface{}{
161 | "namespace": "ns-5",
162 | })
163 | s.Require().NotNil(result)
164 | s.NoErrorf(err, "call tool failed %v", err)
165 | textContent := result.Content[0].(mcp.TextContent).Text
166 | s.Falsef(result.IsError, "call tool failed %v", textContent)
167 |
168 | expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-1\s+container-1\s+10m\s+20Mi\s+42Mi`)
169 | s.Regexpf(expectedRow, textContent, "expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
170 |
171 | expectedTotal := regexp.MustCompile(`(?m)^\s+10m\s+20Mi\s+42Mi\s*$`)
172 | s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
173 | })
174 |
175 | s.Run("pods_top(namespace=ns-5,name=pod-ns-5-5) returns pod metrics from provided namespace and name", func() {
176 | result, err := s.CallTool("pods_top", map[string]interface{}{
177 | "namespace": "ns-5",
178 | "name": "pod-ns-5-5",
179 | })
180 | s.Require().NotNil(result)
181 | s.NoErrorf(err, "call tool failed %v", err)
182 | textContent := result.Content[0].(mcp.TextContent).Text
183 | s.Falsef(result.IsError, "call tool failed %v", textContent)
184 |
185 | expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-5\s+container-1\s+13m\s+37Mi\s+42Mi`)
186 | s.Regexpf(expectedRow, textContent, "expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
187 |
188 | expectedTotal := regexp.MustCompile(`(?m)^\s+13m\s+37Mi\s+42Mi\s*$`)
189 | s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
190 | })
191 |
192 | s.Run("pods_top[label_selector=app=pod-ns-5-42] returns pod metrics from pods matching selector", func() {
193 | result, err := s.CallTool("pods_top", map[string]interface{}{
194 | "label_selector": "app=pod-ns-5-42",
195 | })
196 | s.Require().NotNil(result)
197 | s.NoErrorf(err, "call tool failed %v", err)
198 | textContent := result.Content[0].(mcp.TextContent).Text
199 | s.Falsef(result.IsError, "call tool failed %v", textContent)
200 |
201 | expectedRow := regexp.MustCompile(`ns-5\s+pod-ns-5-42\s+container-1\s+42m\s+42Mi`)
202 | s.Regexpf(expectedRow, textContent, "expected row '%s' not found in output:\n%s", expectedRow.String(), textContent)
203 |
204 | expectedTotal := regexp.MustCompile(`(?m)^\s+42m\s+42Mi\s+42Mi\s*$`)
205 | s.Regexpf(expectedTotal, textContent, "expected total row '%s' not found in output:\n%s", expectedTotal.String(), textContent)
206 | })
207 | }
208 |
209 | func (s *PodsTopSuite) TestPodsTopDenied() {
210 | s.Require().NoError(toml.Unmarshal([]byte(`
211 | denied_resources = [ { group = "metrics.k8s.io", version = "v1beta1" } ]
212 | `), s.Cfg), "Expected to parse denied resources config")
213 | s.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
214 | w.Header().Set("Content-Type", "application/json")
215 | // Request Performed by DiscoveryClient to Kube API (Get API Groups legacy -core-)
216 | if req.URL.Path == "/api" {
217 | _, _ = w.Write([]byte(`{"kind":"APIVersions","versions":["metrics.k8s.io/v1beta1"],"serverAddressByClientCIDRs":[{"clientCIDR":"0.0.0.0/0"}]}`))
218 | return
219 | }
220 | // Request Performed by DiscoveryClient to Kube API (Get API Groups)
221 | if req.URL.Path == "/apis" {
222 | _, _ = w.Write([]byte(`{"kind":"APIGroupList","apiVersion":"v1","groups":[]}`))
223 | return
224 | }
225 | // Request Performed by DiscoveryClient to Kube API (Get API Resources)
226 | if req.URL.Path == "/apis/metrics.k8s.io/v1beta1" {
227 | _, _ = w.Write([]byte(`{"kind":"APIResourceList","apiVersion":"v1","groupVersion":"metrics.k8s.io/v1beta1","resources":[{"name":"pods","singularName":"","namespaced":true,"kind":"PodMetrics","verbs":["get","list"]}]}`))
228 | return
229 | }
230 | }))
231 | s.InitMcpClient()
232 |
233 | s.Run("pods_top (denied)", func() {
234 | result, err := s.CallTool("pods_top", map[string]interface{}{})
235 | s.Require().NotNil(result, "toolResult should not be nil")
236 | s.Run("has error", func() {
237 | s.Truef(result.IsError, "call tool should fail")
238 | s.Nilf(err, "call tool should not return error object")
239 | })
240 | s.Run("describes denial", func() {
241 | expectedMessage := "failed to get pods top: resource not allowed: metrics.k8s.io/v1beta1, Kind=PodMetrics"
242 | s.Equalf(expectedMessage, result.Content[0].(mcp.TextContent).Text,
243 | "expected descriptive error '%s', got %v", expectedMessage, result.Content[0].(mcp.TextContent).Text)
244 | })
245 | })
246 | }
247 |
248 | func TestPodsTop(t *testing.T) {
249 | suite.Run(t, new(PodsTopSuite))
250 | }
251 |
```
--------------------------------------------------------------------------------
/pkg/mcp/helm_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "context"
5 | "encoding/base64"
6 | "path/filepath"
7 | "runtime"
8 | "strings"
9 | "testing"
10 |
11 | "github.com/BurntSushi/toml"
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/stretchr/testify/suite"
14 | corev1 "k8s.io/api/core/v1"
15 | "k8s.io/apimachinery/pkg/api/errors"
16 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
17 | "k8s.io/client-go/kubernetes"
18 | "sigs.k8s.io/yaml"
19 | )
20 |
21 | type HelmSuite struct {
22 | BaseMcpSuite
23 | }
24 |
25 | func (s *HelmSuite) SetupTest() {
26 | s.BaseMcpSuite.SetupTest()
27 | clearHelmReleases(s.T().Context(), kubernetes.NewForConfigOrDie(envTestRestConfig))
28 | }
29 |
30 | func (s *HelmSuite) TestHelmInstall() {
31 | s.InitMcpClient()
32 | s.Run("helm_install(chart=helm-chart-no-op)", func() {
33 | _, file, _, _ := runtime.Caller(0)
34 | chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-no-op")
35 | toolResult, err := s.CallTool("helm_install", map[string]interface{}{
36 | "chart": chartPath,
37 | })
38 | s.Run("no error", func() {
39 | s.Nilf(err, "call tool failed %v", err)
40 | s.Falsef(toolResult.IsError, "call tool failed")
41 | })
42 | s.Run("returns installed chart", func() {
43 | var decoded []map[string]interface{}
44 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
45 | s.Run("has yaml content", func() {
46 | s.Nilf(err, "invalid tool result content %v", err)
47 | })
48 | s.Run("has 1 item", func() {
49 | s.Lenf(decoded, 1, "invalid helm install count, expected 1, got %v", len(decoded))
50 | })
51 | s.Run("has valid name", func() {
52 | s.Truef(strings.HasPrefix(decoded[0]["name"].(string), "helm-chart-no-op-"), "invalid helm install name, expected no-op-*, got %v", decoded[0]["name"])
53 | })
54 | s.Run("has valid namespace", func() {
55 | s.Equalf("default", decoded[0]["namespace"], "invalid helm install namespace, expected default, got %v", decoded[0]["namespace"])
56 | })
57 | s.Run("has valid chart", func() {
58 | s.Equalf("no-op", decoded[0]["chart"], "invalid helm install name, expected release name, got empty")
59 | })
60 | s.Run("has valid chartVersion", func() {
61 | s.Equalf("1.33.7", decoded[0]["chartVersion"], "invalid helm install version, expected 1.33.7, got empty")
62 | })
63 | s.Run("has valid status", func() {
64 | s.Equalf("deployed", decoded[0]["status"], "invalid helm install status, expected deployed, got %v", decoded[0]["status"])
65 | })
66 | s.Run("has valid revision", func() {
67 | s.Equalf(float64(1), decoded[0]["revision"], "invalid helm install revision, expected 1, got %v", decoded[0]["revision"])
68 | })
69 | })
70 | })
71 | }
72 |
73 | func (s *HelmSuite) TestHelmInstallDenied() {
74 | s.Require().NoError(toml.Unmarshal([]byte(`
75 | denied_resources = [ { version = "v1", kind = "Secret" } ]
76 | `), s.Cfg), "Expected to parse denied resources config")
77 | s.InitMcpClient()
78 | s.Run("helm_install(chart=helm-chart-secret, denied)", func() {
79 | _, file, _, _ := runtime.Caller(0)
80 | chartPath := filepath.Join(filepath.Dir(file), "testdata", "helm-chart-secret")
81 | toolResult, err := s.CallTool("helm_install", map[string]interface{}{
82 | "chart": chartPath,
83 | })
84 | s.Run("has error", func() {
85 | s.Truef(toolResult.IsError, "call tool should fail")
86 | s.Nilf(err, "call tool should not return error object")
87 | })
88 | s.Run("describes denial", func() {
89 | s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "failed to install helm chart"), "expected descriptive error, got %v", toolResult.Content[0].(mcp.TextContent).Text)
90 | expectedMessage := ": resource not allowed: /v1, Kind=Secret"
91 | s.Truef(strings.HasSuffix(toolResult.Content[0].(mcp.TextContent).Text, expectedMessage), "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
92 | })
93 | })
94 | }
95 |
96 | func (s *HelmSuite) TestHelmListNoReleases() {
97 | s.InitMcpClient()
98 | s.Run("helm_list() with no releases", func() {
99 | toolResult, err := s.CallTool("helm_list", map[string]interface{}{})
100 | s.Run("no error", func() {
101 | s.Nilf(err, "call tool failed %v", err)
102 | s.Falsef(toolResult.IsError, "call tool failed")
103 | })
104 | s.Run("returns not found", func() {
105 | s.Equalf("No Helm releases found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
106 | })
107 | })
108 | }
109 |
110 | func (s *HelmSuite) TestHelmList() {
111 | kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
112 | _, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
113 | ObjectMeta: metav1.ObjectMeta{
114 | Name: "sh.helm.release.v1.release-to-list",
115 | Labels: map[string]string{"owner": "helm", "name": "release-to-list"},
116 | },
117 | Data: map[string][]byte{
118 | "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
119 | "\"name\":\"release-to-list\"," +
120 | "\"info\":{\"status\":\"deployed\"}" +
121 | "}"))),
122 | },
123 | }, metav1.CreateOptions{})
124 | s.Require().NoError(err)
125 | s.InitMcpClient()
126 | s.Run("helm_list() with deployed release", func() {
127 | toolResult, err := s.CallTool("helm_list", map[string]interface{}{})
128 | s.Run("no error", func() {
129 | s.Nilf(err, "call tool failed %v", err)
130 | s.Falsef(toolResult.IsError, "call tool failed")
131 | })
132 | s.Run("returns release", func() {
133 | var decoded []map[string]interface{}
134 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
135 | s.Run("has yaml content", func() {
136 | s.Nilf(err, "invalid tool result content %v", err)
137 | })
138 | s.Run("has 1 item", func() {
139 | s.Lenf(decoded, 1, "invalid helm list count, expected 1, got %v", len(decoded))
140 | })
141 | s.Run("has valid name", func() {
142 | s.Equalf("release-to-list", decoded[0]["name"], "invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
143 | })
144 | s.Run("has valid status", func() {
145 | s.Equalf("deployed", decoded[0]["status"], "invalid helm list status, expected deployed, got %v", decoded[0]["status"])
146 | })
147 | })
148 | })
149 | s.Run("helm_list(namespace=ns-1) with deployed release in other namespaces", func() {
150 | toolResult, err := s.CallTool("helm_list", map[string]interface{}{"namespace": "ns-1"})
151 | s.Run("no error", func() {
152 | s.Nilf(err, "call tool failed %v", err)
153 | s.Falsef(toolResult.IsError, "call tool failed")
154 | })
155 | s.Run("returns not found", func() {
156 | s.Equalf("No Helm releases found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
157 | })
158 | })
159 | s.Run("helm_list(namespace=ns-1, all_namespaces=true) with deployed release in all namespaces", func() {
160 | toolResult, err := s.CallTool("helm_list", map[string]interface{}{"namespace": "ns-1", "all_namespaces": true})
161 | s.Run("no error", func() {
162 | s.Nilf(err, "call tool failed %v", err)
163 | s.Falsef(toolResult.IsError, "call tool failed")
164 | })
165 | s.Run("returns release", func() {
166 | var decoded []map[string]interface{}
167 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
168 | s.Run("has yaml content", func() {
169 | s.Nilf(err, "invalid tool result content %v", err)
170 | })
171 | s.Run("has 1 item", func() {
172 | s.Lenf(decoded, 1, "invalid helm list count, expected 1, got %v", len(decoded))
173 | })
174 | s.Run("has valid name", func() {
175 | s.Equalf("release-to-list", decoded[0]["name"], "invalid helm list name, expected release-to-list, got %v", decoded[0]["name"])
176 | })
177 | s.Run("has valid status", func() {
178 | s.Equalf("deployed", decoded[0]["status"], "invalid helm list status, expected deployed, got %v", decoded[0]["status"])
179 | })
180 | })
181 | })
182 | }
183 |
184 | func (s *HelmSuite) TestHelmUninstallNoReleases() {
185 | s.InitMcpClient()
186 | s.Run("helm_uninstall(name=release-to-uninstall) with no releases", func() {
187 | toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
188 | "name": "release-to-uninstall",
189 | })
190 | s.Run("no error", func() {
191 | s.Nilf(err, "call tool failed %v", err)
192 | s.Falsef(toolResult.IsError, "call tool failed")
193 | })
194 | s.Run("returns not found", func() {
195 | s.Equalf("Release release-to-uninstall not found", toolResult.Content[0].(mcp.TextContent).Text, "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
196 | })
197 | })
198 | }
199 |
200 | func (s *HelmSuite) TestHelmUninstall() {
201 | kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
202 | _, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
203 | ObjectMeta: metav1.ObjectMeta{
204 | Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
205 | Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
206 | },
207 | Data: map[string][]byte{
208 | "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
209 | "\"name\":\"existent-release-to-uninstall\"," +
210 | "\"info\":{\"status\":\"deployed\"}" +
211 | "}"))),
212 | },
213 | }, metav1.CreateOptions{})
214 | s.Require().NoError(err)
215 | s.InitMcpClient()
216 | s.Run("helm_uninstall(name=existent-release-to-uninstall) with deployed release", func() {
217 | toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
218 | "name": "existent-release-to-uninstall",
219 | })
220 | s.Run("no error", func() {
221 | s.Nilf(err, "call tool failed %v", err)
222 | s.Falsef(toolResult.IsError, "call tool failed")
223 | })
224 | s.Run("returns uninstalled", func() {
225 | s.Truef(strings.HasPrefix(toolResult.Content[0].(mcp.TextContent).Text, "Uninstalled release existent-release-to-uninstall"), "unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
226 | _, err = kc.CoreV1().Secrets("default").Get(s.T().Context(), "sh.helm.release.v1.existent-release-to-uninstall.v0", metav1.GetOptions{})
227 | s.Truef(errors.IsNotFound(err), "expected release to be deleted, but it still exists")
228 | })
229 |
230 | })
231 | }
232 |
233 | func (s *HelmSuite) TestHelmUninstallDenied() {
234 | s.Require().NoError(toml.Unmarshal([]byte(`
235 | denied_resources = [ { version = "v1", kind = "Secret" } ]
236 | `), s.Cfg), "Expected to parse denied resources config")
237 | kc := kubernetes.NewForConfigOrDie(envTestRestConfig)
238 | _, err := kc.CoreV1().Secrets("default").Create(s.T().Context(), &corev1.Secret{
239 | ObjectMeta: metav1.ObjectMeta{
240 | Name: "sh.helm.release.v1.existent-release-to-uninstall.v0",
241 | Labels: map[string]string{"owner": "helm", "name": "existent-release-to-uninstall"},
242 | },
243 | Data: map[string][]byte{
244 | "release": []byte(base64.StdEncoding.EncodeToString([]byte("{" +
245 | "\"name\":\"existent-release-to-uninstall\"," +
246 | "\"info\":{\"status\":\"deployed\"}," +
247 | "\"manifest\":\"apiVersion: v1\\nkind: Secret\\nmetadata:\\n name: secret-to-deny\\n namespace: default\\n\"" +
248 | "}"))),
249 | },
250 | }, metav1.CreateOptions{})
251 | s.Require().NoError(err)
252 | s.InitMcpClient()
253 | s.Run("helm_uninstall(name=existent-release-to-uninstall) with deployed release (denied)", func() {
254 | toolResult, err := s.CallTool("helm_uninstall", map[string]interface{}{
255 | "name": "existent-release-to-uninstall",
256 | })
257 | s.Run("has error", func() {
258 | s.Truef(toolResult.IsError, "call tool should fail")
259 | s.Nilf(err, "call tool should not return error object")
260 | })
261 | s.Run("describes denial", func() {
262 | s.T().Skipf("Helm won't report what underlying resource caused the failure, so we can't assert on it")
263 | expectedMessage := "failed to uninstall release: resource not allowed: /v1, Kind=Secret"
264 | s.Equalf(expectedMessage, toolResult.Content[0].(mcp.TextContent).Text, "expected descriptive error '%s', got %v", expectedMessage, toolResult.Content[0].(mcp.TextContent).Text)
265 | })
266 | })
267 | }
268 |
269 | func clearHelmReleases(ctx context.Context, kc *kubernetes.Clientset) {
270 | secrets, _ := kc.CoreV1().Secrets("default").List(ctx, metav1.ListOptions{})
271 | for _, secret := range secrets.Items {
272 | if strings.HasPrefix(secret.Name, "sh.helm.release.v1.") {
273 | _ = kc.CoreV1().Secrets("default").Delete(ctx, secret.Name, metav1.DeleteOptions{})
274 | }
275 | }
276 | }
277 |
278 | func TestHelm(t *testing.T) {
279 | suite.Run(t, new(HelmSuite))
280 | }
281 |
```
--------------------------------------------------------------------------------
/pkg/kubernetes-mcp-server/cmd/root_test.go:
--------------------------------------------------------------------------------
```go
1 | package cmd
2 |
3 | import (
4 | "bytes"
5 | "io"
6 | "os"
7 | "path/filepath"
8 | "regexp"
9 | "runtime"
10 | "strings"
11 | "testing"
12 |
13 | "github.com/stretchr/testify/assert"
14 | "github.com/stretchr/testify/require"
15 | "k8s.io/cli-runtime/pkg/genericiooptions"
16 | )
17 |
18 | func captureOutput(f func() error) (string, error) {
19 | originalOut := os.Stdout
20 | defer func() {
21 | os.Stdout = originalOut
22 | }()
23 | r, w, _ := os.Pipe()
24 | os.Stdout = w
25 | err := f()
26 | _ = w.Close()
27 | out, _ := io.ReadAll(r)
28 | return string(out), err
29 | }
30 |
31 | func testStream() (genericiooptions.IOStreams, *bytes.Buffer) {
32 | out := &bytes.Buffer{}
33 | return genericiooptions.IOStreams{
34 | In: &bytes.Buffer{},
35 | Out: out,
36 | ErrOut: io.Discard,
37 | }, out
38 | }
39 |
40 | func TestVersion(t *testing.T) {
41 | ioStreams, out := testStream()
42 | rootCmd := NewMCPServer(ioStreams)
43 | rootCmd.SetArgs([]string{"--version"})
44 | if err := rootCmd.Execute(); out.String() != "0.0.0\n" {
45 | t.Fatalf("Expected version 0.0.0, got %s %v", out.String(), err)
46 | }
47 | }
48 |
49 | func TestConfig(t *testing.T) {
50 | t.Run("defaults to none", func(t *testing.T) {
51 | ioStreams, out := testStream()
52 | rootCmd := NewMCPServer(ioStreams)
53 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
54 | expectedConfig := `" - Config: "`
55 | if err := rootCmd.Execute(); !strings.Contains(out.String(), expectedConfig) {
56 | t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err)
57 | }
58 | })
59 | t.Run("set with --config", func(t *testing.T) {
60 | ioStreams, out := testStream()
61 | rootCmd := NewMCPServer(ioStreams)
62 | _, file, _, _ := runtime.Caller(0)
63 | emptyConfigPath := filepath.Join(filepath.Dir(file), "testdata", "empty-config.toml")
64 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", emptyConfigPath})
65 | _ = rootCmd.Execute()
66 | expected := `(?m)\" - Config\:[^\"]+empty-config\.toml\"`
67 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
68 | t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err)
69 | }
70 | })
71 | t.Run("invalid path throws error", func(t *testing.T) {
72 | ioStreams, _ := testStream()
73 | rootCmd := NewMCPServer(ioStreams)
74 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--config", "invalid-path-to-config.toml"})
75 | err := rootCmd.Execute()
76 | if err == nil {
77 | t.Fatal("Expected error for invalid config path, got nil")
78 | }
79 | expected := "open invalid-path-to-config.toml: "
80 | if !strings.HasPrefix(err.Error(), expected) {
81 | t.Fatalf("Expected error to be %s, got %s", expected, err.Error())
82 | }
83 | })
84 | t.Run("set with valid --config", func(t *testing.T) {
85 | ioStreams, out := testStream()
86 | rootCmd := NewMCPServer(ioStreams)
87 | _, file, _, _ := runtime.Caller(0)
88 | validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml")
89 | rootCmd.SetArgs([]string{"--version", "--config", validConfigPath})
90 | _ = rootCmd.Execute()
91 | expectedConfig := `(?m)\" - Config\:[^\"]+valid-config\.toml\"`
92 | if m, err := regexp.MatchString(expectedConfig, out.String()); !m || err != nil {
93 | t.Fatalf("Expected config to be %s, got %s %v", expectedConfig, out.String(), err)
94 | }
95 | expectedListOutput := `(?m)\" - ListOutput\: yaml"`
96 | if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil {
97 | t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err)
98 | }
99 | expectedReadOnly := `(?m)\" - Read-only mode: true"`
100 | if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil {
101 | t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err)
102 | }
103 | expectedDisableDestruction := `(?m)\" - Disable destructive tools: true"`
104 | if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil {
105 | t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err)
106 | }
107 | })
108 | t.Run("set with valid --config, flags take precedence", func(t *testing.T) {
109 | ioStreams, out := testStream()
110 | rootCmd := NewMCPServer(ioStreams)
111 | _, file, _, _ := runtime.Caller(0)
112 | validConfigPath := filepath.Join(filepath.Dir(file), "testdata", "valid-config.toml")
113 | rootCmd.SetArgs([]string{"--version", "--list-output=table", "--disable-destructive=false", "--read-only=false", "--config", validConfigPath})
114 | _ = rootCmd.Execute()
115 | expected := `(?m)\" - Config\:[^\"]+valid-config\.toml\"`
116 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
117 | t.Fatalf("Expected config to be %s, got %s %v", expected, out.String(), err)
118 | }
119 | expectedListOutput := `(?m)\" - ListOutput\: table"`
120 | if m, err := regexp.MatchString(expectedListOutput, out.String()); !m || err != nil {
121 | t.Fatalf("Expected config to be %s, got %s %v", expectedListOutput, out.String(), err)
122 | }
123 | expectedReadOnly := `(?m)\" - Read-only mode: false"`
124 | if m, err := regexp.MatchString(expectedReadOnly, out.String()); !m || err != nil {
125 | t.Fatalf("Expected config to be %s, got %s %v", expectedReadOnly, out.String(), err)
126 | }
127 | expectedDisableDestruction := `(?m)\" - Disable destructive tools: false"`
128 | if m, err := regexp.MatchString(expectedDisableDestruction, out.String()); !m || err != nil {
129 | t.Fatalf("Expected config to be %s, got %s %v", expectedDisableDestruction, out.String(), err)
130 | }
131 | })
132 | }
133 |
134 | func TestToolsets(t *testing.T) {
135 | t.Run("available", func(t *testing.T) {
136 | ioStreams, _ := testStream()
137 | rootCmd := NewMCPServer(ioStreams)
138 | rootCmd.SetArgs([]string{"--help"})
139 | o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
140 | if !strings.Contains(o, "Comma-separated list of MCP toolsets to use (available toolsets: config, core, helm).") {
141 | t.Fatalf("Expected all available toolsets, got %s %v", o, err)
142 | }
143 | })
144 | t.Run("default", func(t *testing.T) {
145 | ioStreams, out := testStream()
146 | rootCmd := NewMCPServer(ioStreams)
147 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
148 | if err := rootCmd.Execute(); !strings.Contains(out.String(), "- Toolsets: core, config, helm") {
149 | t.Fatalf("Expected toolsets 'full', got %s %v", out, err)
150 | }
151 | })
152 | t.Run("set with --toolsets", func(t *testing.T) {
153 | ioStreams, out := testStream()
154 | rootCmd := NewMCPServer(ioStreams)
155 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--toolsets", "helm,config"})
156 | _ = rootCmd.Execute()
157 | expected := `(?m)\" - Toolsets\: helm, config\"`
158 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
159 | t.Fatalf("Expected toolset to be %s, got %s %v", expected, out.String(), err)
160 | }
161 | })
162 | }
163 |
164 | func TestListOutput(t *testing.T) {
165 | t.Run("available", func(t *testing.T) {
166 | ioStreams, _ := testStream()
167 | rootCmd := NewMCPServer(ioStreams)
168 | rootCmd.SetArgs([]string{"--help"})
169 | o, err := captureOutput(rootCmd.Execute) // --help doesn't use logger/klog, cobra prints directly to stdout
170 | if !strings.Contains(o, "Output format for resource list operations (one of: yaml, table)") {
171 | t.Fatalf("Expected all available outputs, got %s %v", o, err)
172 | }
173 | })
174 | t.Run("defaults to table", func(t *testing.T) {
175 | ioStreams, out := testStream()
176 | rootCmd := NewMCPServer(ioStreams)
177 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
178 | if err := rootCmd.Execute(); !strings.Contains(out.String(), "- ListOutput: table") {
179 | t.Fatalf("Expected list-output 'table', got %s %v", out, err)
180 | }
181 | })
182 | t.Run("set with --list-output", func(t *testing.T) {
183 | ioStreams, out := testStream()
184 | rootCmd := NewMCPServer(ioStreams)
185 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--list-output", "yaml"})
186 | _ = rootCmd.Execute()
187 | expected := `(?m)\" - ListOutput\: yaml\"`
188 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
189 | t.Fatalf("Expected list-output to be %s, got %s %v", expected, out.String(), err)
190 | }
191 | })
192 | }
193 |
194 | func TestReadOnly(t *testing.T) {
195 | t.Run("defaults to false", func(t *testing.T) {
196 | ioStreams, out := testStream()
197 | rootCmd := NewMCPServer(ioStreams)
198 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
199 | if err := rootCmd.Execute(); !strings.Contains(out.String(), " - Read-only mode: false") {
200 | t.Fatalf("Expected read-only mode false, got %s %v", out, err)
201 | }
202 | })
203 | t.Run("set with --read-only", func(t *testing.T) {
204 | ioStreams, out := testStream()
205 | rootCmd := NewMCPServer(ioStreams)
206 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--read-only"})
207 | _ = rootCmd.Execute()
208 | expected := `(?m)\" - Read-only mode\: true\"`
209 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
210 | t.Fatalf("Expected read-only mode to be %s, got %s %v", expected, out.String(), err)
211 | }
212 | })
213 | }
214 |
215 | func TestDisableDestructive(t *testing.T) {
216 | t.Run("defaults to false", func(t *testing.T) {
217 | ioStreams, out := testStream()
218 | rootCmd := NewMCPServer(ioStreams)
219 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
220 | if err := rootCmd.Execute(); !strings.Contains(out.String(), " - Disable destructive tools: false") {
221 | t.Fatalf("Expected disable destructive false, got %s %v", out, err)
222 | }
223 | })
224 | t.Run("set with --disable-destructive", func(t *testing.T) {
225 | ioStreams, out := testStream()
226 | rootCmd := NewMCPServer(ioStreams)
227 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--disable-destructive"})
228 | _ = rootCmd.Execute()
229 | expected := `(?m)\" - Disable destructive tools\: true\"`
230 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
231 | t.Fatalf("Expected disable-destructive mode to be %s, got %s %v", expected, out.String(), err)
232 | }
233 | })
234 | }
235 |
236 | func TestAuthorizationURL(t *testing.T) {
237 | t.Run("invalid authorization-url without protocol", func(t *testing.T) {
238 | ioStreams, _ := testStream()
239 | rootCmd := NewMCPServer(ioStreams)
240 | rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--authorization-url", "example.com/auth", "--server-url", "https://example.com:8080"})
241 | err := rootCmd.Execute()
242 | if err == nil {
243 | t.Fatal("Expected error for invalid authorization-url without protocol, got nil")
244 | }
245 | expected := "--authorization-url must be a valid URL"
246 | if !strings.Contains(err.Error(), expected) {
247 | t.Fatalf("Expected error to contain %s, got %s", expected, err.Error())
248 | }
249 | })
250 | t.Run("valid authorization-url with https", func(t *testing.T) {
251 | ioStreams, _ := testStream()
252 | rootCmd := NewMCPServer(ioStreams)
253 | rootCmd.SetArgs([]string{"--version", "--require-oauth", "--port=8080", "--authorization-url", "https://example.com/auth", "--server-url", "https://example.com:8080"})
254 | err := rootCmd.Execute()
255 | if err != nil {
256 | t.Fatalf("Expected no error for valid https authorization-url, got %s", err.Error())
257 | }
258 | })
259 | }
260 |
261 | func TestStdioLogging(t *testing.T) {
262 | t.Run("stdio disables klog", func(t *testing.T) {
263 | ioStreams, out := testStream()
264 | rootCmd := NewMCPServer(ioStreams)
265 | rootCmd.SetArgs([]string{"--version", "--log-level=1"})
266 | err := rootCmd.Execute()
267 | require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
268 | assert.Equalf(t, "0.0.0\n", out.String(), "Expected only version output, got %s", out.String())
269 | })
270 | t.Run("http mode enables klog", func(t *testing.T) {
271 | ioStreams, out := testStream()
272 | rootCmd := NewMCPServer(ioStreams)
273 | rootCmd.SetArgs([]string{"--version", "--log-level=1", "--port=1337"})
274 | err := rootCmd.Execute()
275 | require.NoErrorf(t, err, "Expected no error executing command, got %v", err)
276 | assert.Containsf(t, out.String(), "Starting kubernetes-mcp-server", "Expected klog output, got %s", out.String())
277 | })
278 | }
279 |
280 | func TestDisableMultiCluster(t *testing.T) {
281 | t.Run("defaults to false", func(t *testing.T) {
282 | ioStreams, out := testStream()
283 | rootCmd := NewMCPServer(ioStreams)
284 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1"})
285 | if err := rootCmd.Execute(); !strings.Contains(out.String(), " - ClusterProviderStrategy: auto-detect (it is recommended to set this explicitly in your Config)") {
286 | t.Fatalf("Expected ClusterProviderStrategy kubeconfig, got %s %v", out, err)
287 | }
288 | })
289 | t.Run("set with --disable-multi-cluster", func(t *testing.T) {
290 | ioStreams, out := testStream()
291 | rootCmd := NewMCPServer(ioStreams)
292 | rootCmd.SetArgs([]string{"--version", "--port=1337", "--log-level=1", "--disable-multi-cluster"})
293 | _ = rootCmd.Execute()
294 | expected := `(?m)\" - ClusterProviderStrategy\: disabled\"`
295 | if m, err := regexp.MatchString(expected, out.String()); !m || err != nil {
296 | t.Fatalf("Expected ClusterProviderStrategy %s, got %s %v", expected, out.String(), err)
297 | }
298 | })
299 | }
300 |
```