This is page 5 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/resources_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/mark3labs/mcp-go/mcp"
9 | corev1 "k8s.io/api/core/v1"
10 | v1 "k8s.io/api/rbac/v1"
11 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13 | "k8s.io/apimachinery/pkg/runtime/schema"
14 | "k8s.io/client-go/dynamic"
15 | "sigs.k8s.io/yaml"
16 |
17 | "github.com/containers/kubernetes-mcp-server/internal/test"
18 | "github.com/containers/kubernetes-mcp-server/pkg/config"
19 | "github.com/containers/kubernetes-mcp-server/pkg/output"
20 | )
21 |
22 | func TestResourcesList(t *testing.T) {
23 | testCase(t, func(c *mcpContext) {
24 | c.withEnvTest()
25 | t.Run("resources_list with missing apiVersion returns error", func(t *testing.T) {
26 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{})
27 | if !toolResult.IsError {
28 | t.Fatalf("call tool should fail")
29 | }
30 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument apiVersion" {
31 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
32 | }
33 | })
34 | t.Run("resources_list with missing kind returns error", func(t *testing.T) {
35 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1"})
36 | if !toolResult.IsError {
37 | t.Fatalf("call tool should fail")
38 | }
39 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, missing argument kind" {
40 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
41 | }
42 | })
43 | t.Run("resources_list with invalid apiVersion returns error", func(t *testing.T) {
44 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod"})
45 | if !toolResult.IsError {
46 | t.Fatalf("call tool should fail")
47 | }
48 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list resources, invalid argument apiVersion" {
49 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
50 | }
51 | })
52 | t.Run("resources_list with nonexistent apiVersion returns error", func(t *testing.T) {
53 | toolResult, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom"})
54 | if !toolResult.IsError {
55 | t.Fatalf("call tool should fail")
56 | }
57 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to list resources: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
58 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
59 | }
60 | })
61 | namespaces, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
62 | t.Run("resources_list returns namespaces", func(t *testing.T) {
63 | if err != nil {
64 | t.Fatalf("call tool failed %v", err)
65 | return
66 | }
67 | if namespaces.IsError {
68 | t.Fatalf("call tool failed")
69 | return
70 | }
71 | })
72 | var decodedNamespaces []unstructured.Unstructured
73 | err = yaml.Unmarshal([]byte(namespaces.Content[0].(mcp.TextContent).Text), &decodedNamespaces)
74 | t.Run("resources_list has yaml content", func(t *testing.T) {
75 | if err != nil {
76 | t.Fatalf("invalid tool result content %v", err)
77 | }
78 | })
79 | t.Run("resources_list returns more than 2 items", func(t *testing.T) {
80 | if len(decodedNamespaces) < 3 {
81 | t.Fatalf("invalid namespace count, expected >2, got %v", len(decodedNamespaces))
82 | }
83 | })
84 |
85 | // Test label selector functionality
86 | t.Run("resources_list with label selector returns filtered pods", func(t *testing.T) {
87 |
88 | // List pods with label selector
89 | result, err := c.callTool("resources_list", map[string]interface{}{
90 | "apiVersion": "v1",
91 | "kind": "Pod",
92 | "namespace": "default",
93 | "labelSelector": "app=nginx",
94 | })
95 |
96 | if err != nil {
97 | t.Fatalf("call tool failed %v", err)
98 | return
99 | }
100 | if result.IsError {
101 | t.Fatalf("call tool failed")
102 | return
103 | }
104 |
105 | var decodedPods []unstructured.Unstructured
106 | err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods)
107 | if err != nil {
108 | t.Fatalf("invalid tool result content %v", err)
109 | return
110 | }
111 |
112 | // Verify only the pod with matching label is returned
113 | if len(decodedPods) != 1 {
114 | t.Fatalf("expected 1 pod, got %d", len(decodedPods))
115 | return
116 | }
117 |
118 | if decodedPods[0].GetName() != "a-pod-in-default" {
119 | t.Fatalf("expected pod-with-label, got %s", decodedPods[0].GetName())
120 | return
121 | }
122 |
123 | // Test that multiple label selectors work
124 | result, err = c.callTool("resources_list", map[string]interface{}{
125 | "apiVersion": "v1",
126 | "kind": "Pod",
127 | "namespace": "default",
128 | "labelSelector": "test-label=test-value,another=value",
129 | })
130 |
131 | if err != nil {
132 | t.Fatalf("call tool failed %v", err)
133 | return
134 | }
135 | if result.IsError {
136 | t.Fatalf("call tool failed")
137 | return
138 | }
139 |
140 | err = yaml.Unmarshal([]byte(result.Content[0].(mcp.TextContent).Text), &decodedPods)
141 | if err != nil {
142 | t.Fatalf("invalid tool result content %v", err)
143 | return
144 | }
145 |
146 | // Verify no pods match multiple label selector
147 | if len(decodedPods) != 0 {
148 | t.Fatalf("expected 0 pods, got %d", len(decodedPods))
149 | return
150 | }
151 | })
152 | })
153 | }
154 |
155 | func TestResourcesListDenied(t *testing.T) {
156 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
157 | denied_resources = [
158 | { version = "v1", kind = "Secret" },
159 | { group = "rbac.authorization.k8s.io", version = "v1" }
160 | ]
161 | `)))
162 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
163 | c.withEnvTest()
164 | deniedByKind, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Secret"})
165 | t.Run("resources_list (denied by kind) has error", func(t *testing.T) {
166 | if !deniedByKind.IsError {
167 | t.Fatalf("call tool should fail")
168 | }
169 | })
170 | t.Run("resources_list (denied by kind) describes denial", func(t *testing.T) {
171 | expectedMessage := "failed to list resources: resource not allowed: /v1, Kind=Secret"
172 | if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
173 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
174 | }
175 | })
176 | deniedByGroup, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role"})
177 | t.Run("resources_list (denied by group) has error", func(t *testing.T) {
178 | if !deniedByGroup.IsError {
179 | t.Fatalf("call tool should fail")
180 | }
181 | })
182 | t.Run("resources_list (denied by group) describes denial", func(t *testing.T) {
183 | expectedMessage := "failed to list resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
184 | if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
185 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
186 | }
187 | })
188 | allowedResource, _ := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
189 | t.Run("resources_list (not denied) returns list", func(t *testing.T) {
190 | if allowedResource.IsError {
191 | t.Fatalf("call tool should not fail")
192 | }
193 | })
194 | })
195 | }
196 |
197 | func TestResourcesListAsTable(t *testing.T) {
198 | testCaseWithContext(t, &mcpContext{listOutput: output.Table, before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
199 | c.withEnvTest()
200 | kc := c.newKubernetesClient()
201 | _, _ = kc.CoreV1().ConfigMaps("default").Create(t.Context(), &corev1.ConfigMap{
202 | ObjectMeta: metav1.ObjectMeta{Name: "a-configmap-to-list-as-table", Labels: map[string]string{"resource": "config-map"}},
203 | Data: map[string]string{"key": "value"},
204 | }, metav1.CreateOptions{})
205 | configMapList, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap"})
206 | t.Run("resources_list returns ConfigMap list", func(t *testing.T) {
207 | if err != nil {
208 | t.Fatalf("call tool failed %v", err)
209 | }
210 | if configMapList.IsError {
211 | t.Fatalf("call tool failed")
212 | }
213 | })
214 | outConfigMapList := configMapList.Content[0].(mcp.TextContent).Text
215 | t.Run("resources_list returns column headers for ConfigMap list", func(t *testing.T) {
216 | expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+DATA\\s+AGE\\s+LABELS"
217 | if m, e := regexp.MatchString(expectedHeaders, outConfigMapList); !m || e != nil {
218 | t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outConfigMapList)
219 | }
220 | })
221 | t.Run("resources_list returns formatted row for a-configmap-to-list-as-table", func(t *testing.T) {
222 | expectedRow := "(?<namespace>default)\\s+" +
223 | "(?<apiVersion>v1)\\s+" +
224 | "(?<kind>ConfigMap)\\s+" +
225 | "(?<name>a-configmap-to-list-as-table)\\s+" +
226 | "(?<data>1)\\s+" +
227 | "(?<age>(\\d+m)?(\\d+s)?)\\s+" +
228 | "(?<labels>resource=config-map)"
229 | if m, e := regexp.MatchString(expectedRow, outConfigMapList); !m || e != nil {
230 | t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outConfigMapList)
231 | }
232 | })
233 | // Custom Resource List
234 | _, _ = dynamic.NewForConfigOrDie(envTestRestConfig).
235 | Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
236 | Namespace("default").
237 | Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
238 | "apiVersion": "route.openshift.io/v1",
239 | "kind": "Route",
240 | "metadata": map[string]interface{}{
241 | "name": "an-openshift-route-to-list-as-table",
242 | },
243 | }}, metav1.CreateOptions{})
244 | routeList, err := c.callTool("resources_list", map[string]interface{}{"apiVersion": "route.openshift.io/v1", "kind": "Route"})
245 | t.Run("resources_list returns Route list", func(t *testing.T) {
246 | if err != nil {
247 | t.Fatalf("call tool failed %v", err)
248 | }
249 | if routeList.IsError {
250 | t.Fatalf("call tool failed")
251 | }
252 | })
253 | outRouteList := routeList.Content[0].(mcp.TextContent).Text
254 | t.Run("resources_list returns column headers for Route list", func(t *testing.T) {
255 | expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+AGE\\s+LABELS"
256 | if m, e := regexp.MatchString(expectedHeaders, outRouteList); !m || e != nil {
257 | t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outRouteList)
258 | }
259 | })
260 | t.Run("resources_list returns formatted row for an-openshift-route-to-list-as-table", func(t *testing.T) {
261 | expectedRow := "(?<namespace>default)\\s+" +
262 | "(?<apiVersion>route.openshift.io/v1)\\s+" +
263 | "(?<kind>Route)\\s+" +
264 | "(?<name>an-openshift-route-to-list-as-table)\\s+" +
265 | "(?<age>(\\d+m)?(\\d+s)?)\\s+" +
266 | "(?<labels><none>)"
267 | if m, e := regexp.MatchString(expectedRow, outRouteList); !m || e != nil {
268 | t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outRouteList)
269 | }
270 | })
271 | })
272 | }
273 |
274 | func TestResourcesGet(t *testing.T) {
275 | testCase(t, func(c *mcpContext) {
276 | c.withEnvTest()
277 | t.Run("resources_get with missing apiVersion returns error", func(t *testing.T) {
278 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{})
279 | if !toolResult.IsError {
280 | t.Fatalf("call tool should fail")
281 | return
282 | }
283 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument apiVersion" {
284 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
285 | return
286 | }
287 | })
288 | t.Run("resources_get with missing kind returns error", func(t *testing.T) {
289 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1"})
290 | if !toolResult.IsError {
291 | t.Fatalf("call tool should fail")
292 | return
293 | }
294 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument kind" {
295 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
296 | return
297 | }
298 | })
299 | t.Run("resources_get with invalid apiVersion returns error", func(t *testing.T) {
300 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"})
301 | if !toolResult.IsError {
302 | t.Fatalf("call tool should fail")
303 | return
304 | }
305 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, invalid argument apiVersion" {
306 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
307 | return
308 | }
309 | })
310 | t.Run("resources_get with nonexistent apiVersion returns error", func(t *testing.T) {
311 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"})
312 | if !toolResult.IsError {
313 | t.Fatalf("call tool should fail")
314 | return
315 | }
316 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to get resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
317 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
318 | return
319 | }
320 | })
321 | t.Run("resources_get with missing name returns error", func(t *testing.T) {
322 | toolResult, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
323 | if !toolResult.IsError {
324 | t.Fatalf("call tool should fail")
325 | return
326 | }
327 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get resource, missing argument name" {
328 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
329 | return
330 | }
331 | })
332 | namespace, err := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"})
333 | t.Run("resources_get returns namespace", func(t *testing.T) {
334 | if err != nil {
335 | t.Fatalf("call tool failed %v", err)
336 | return
337 | }
338 | if namespace.IsError {
339 | t.Fatalf("call tool failed")
340 | return
341 | }
342 | })
343 | var decodedNamespace unstructured.Unstructured
344 | err = yaml.Unmarshal([]byte(namespace.Content[0].(mcp.TextContent).Text), &decodedNamespace)
345 | t.Run("resources_get has yaml content", func(t *testing.T) {
346 | if err != nil {
347 | t.Fatalf("invalid tool result content %v", err)
348 | return
349 | }
350 | })
351 | t.Run("resources_get returns default namespace", func(t *testing.T) {
352 | if decodedNamespace.GetName() != "default" {
353 | t.Fatalf("invalid namespace name, expected default, got %v", decodedNamespace.GetName())
354 | return
355 | }
356 | })
357 | })
358 | }
359 |
360 | func TestResourcesGetDenied(t *testing.T) {
361 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
362 | denied_resources = [
363 | { version = "v1", kind = "Secret" },
364 | { group = "rbac.authorization.k8s.io", version = "v1" }
365 | ]
366 | `)))
367 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
368 | c.withEnvTest()
369 | kc := c.newKubernetesClient()
370 | _, _ = kc.CoreV1().Secrets("default").Create(c.ctx, &corev1.Secret{
371 | ObjectMeta: metav1.ObjectMeta{Name: "denied-secret"},
372 | }, metav1.CreateOptions{})
373 | _, _ = kc.RbacV1().Roles("default").Create(c.ctx, &v1.Role{
374 | ObjectMeta: metav1.ObjectMeta{Name: "denied-role"},
375 | }, metav1.CreateOptions{})
376 | deniedByKind, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"})
377 | t.Run("resources_get (denied by kind) has error", func(t *testing.T) {
378 | if !deniedByKind.IsError {
379 | t.Fatalf("call tool should fail")
380 | }
381 | })
382 | t.Run("resources_get (denied by kind) describes denial", func(t *testing.T) {
383 | expectedMessage := "failed to get resource: resource not allowed: /v1, Kind=Secret"
384 | if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
385 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
386 | }
387 | })
388 | deniedByGroup, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
389 | t.Run("resources_get (denied by group) has error", func(t *testing.T) {
390 | if !deniedByGroup.IsError {
391 | t.Fatalf("call tool should fail")
392 | }
393 | })
394 | t.Run("resources_get (denied by group) describes denial", func(t *testing.T) {
395 | expectedMessage := "failed to get resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
396 | if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
397 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
398 | }
399 | })
400 | allowedResource, _ := c.callTool("resources_get", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "default"})
401 | t.Run("resources_get (not denied) returns resource", func(t *testing.T) {
402 | if allowedResource.IsError {
403 | t.Fatalf("call tool should not fail")
404 | }
405 | })
406 | })
407 | }
408 |
409 | func TestResourcesCreateOrUpdate(t *testing.T) {
410 | testCase(t, func(c *mcpContext) {
411 | c.withEnvTest()
412 | t.Run("resources_create_or_update with nil resource returns error", func(t *testing.T) {
413 | toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{})
414 | if toolResult.IsError != true {
415 | t.Fatalf("call tool should fail")
416 | return
417 | }
418 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" {
419 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
420 | return
421 | }
422 | })
423 | t.Run("resources_create_or_update with empty resource returns error", func(t *testing.T) {
424 | toolResult, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": ""})
425 | if toolResult.IsError != true {
426 | t.Fatalf("call tool should fail")
427 | return
428 | }
429 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to create or update resources, missing argument resource" {
430 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
431 | return
432 | }
433 | })
434 | client := c.newKubernetesClient()
435 | configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n"
436 | resourcesCreateOrUpdateCm1, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml})
437 | t.Run("resources_create_or_update with valid namespaced yaml resource returns success", func(t *testing.T) {
438 | if err != nil {
439 | t.Fatalf("call tool failed %v", err)
440 | return
441 | }
442 | if resourcesCreateOrUpdateCm1.IsError {
443 | t.Errorf("call tool failed")
444 | return
445 | }
446 | })
447 | var decodedCreateOrUpdateCm1 []unstructured.Unstructured
448 | err = yaml.Unmarshal([]byte(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text), &decodedCreateOrUpdateCm1)
449 | t.Run("resources_create_or_update with valid namespaced yaml resource returns yaml content", func(t *testing.T) {
450 | if err != nil {
451 | t.Errorf("invalid tool result content %v", err)
452 | return
453 | }
454 | if !strings.HasPrefix(resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text, "# The following resources (YAML) have been created or updated successfully") {
455 | t.Errorf("Excpected success message, got %v", resourcesCreateOrUpdateCm1.Content[0].(mcp.TextContent).Text)
456 | return
457 | }
458 | if len(decodedCreateOrUpdateCm1) != 1 {
459 | t.Errorf("invalid resource count, expected 1, got %v", len(decodedCreateOrUpdateCm1))
460 | return
461 | }
462 | if decodedCreateOrUpdateCm1[0].GetName() != "a-cm-created-or-updated" {
463 | t.Errorf("invalid resource name, expected a-cm-created-or-updated, got %v", decodedCreateOrUpdateCm1[0].GetName())
464 | return
465 | }
466 | if decodedCreateOrUpdateCm1[0].GetUID() == "" {
467 | t.Errorf("invalid uid, got %v", decodedCreateOrUpdateCm1[0].GetUID())
468 | return
469 | }
470 | })
471 | t.Run("resources_create_or_update with valid namespaced yaml resource creates ConfigMap", func(t *testing.T) {
472 | cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated", metav1.GetOptions{})
473 | if cm == nil {
474 | t.Fatalf("ConfigMap not found")
475 | return
476 | }
477 | })
478 | configMapJson := "{\"apiVersion\": \"v1\", \"kind\": \"ConfigMap\", \"metadata\": {\"name\": \"a-cm-created-or-updated-2\", \"namespace\": \"default\"}}"
479 | resourcesCreateOrUpdateCm2, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapJson})
480 | t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
481 | if err != nil {
482 | t.Fatalf("call tool failed %v", err)
483 | return
484 | }
485 | if resourcesCreateOrUpdateCm2.IsError {
486 | t.Fatalf("call tool failed")
487 | return
488 | }
489 | })
490 | t.Run("resources_create_or_update with valid namespaced json resource creates config map", func(t *testing.T) {
491 | cm, _ := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-cm-created-or-updated-2", metav1.GetOptions{})
492 | if cm == nil {
493 | t.Fatalf("ConfigMap not found")
494 | return
495 | }
496 | })
497 | customResourceDefinitionJson := `
498 | {
499 | "apiVersion": "apiextensions.k8s.io/v1",
500 | "kind": "CustomResourceDefinition",
501 | "metadata": {"name": "customs.example.com"},
502 | "spec": {
503 | "group": "example.com",
504 | "versions": [{
505 | "name": "v1","served": true,"storage": true,
506 | "schema": {"openAPIV3Schema": {"type": "object"}}
507 | }],
508 | "scope": "Namespaced",
509 | "names": {"plural": "customs","singular": "custom","kind": "Custom"}
510 | }
511 | }`
512 | resourcesCreateOrUpdateCrd, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customResourceDefinitionJson})
513 | t.Run("resources_create_or_update with valid cluster-scoped json resource returns success", func(t *testing.T) {
514 | if err != nil {
515 | t.Fatalf("call tool failed %v", err)
516 | return
517 | }
518 | if resourcesCreateOrUpdateCrd.IsError {
519 | t.Fatalf("call tool failed")
520 | return
521 | }
522 | })
523 | t.Run("resources_create_or_update with valid cluster-scoped json resource creates custom resource definition", func(t *testing.T) {
524 | apiExtensionsV1Client := c.newApiExtensionsClient()
525 | _, err = apiExtensionsV1Client.CustomResourceDefinitions().Get(c.ctx, "customs.example.com", metav1.GetOptions{})
526 | if err != nil {
527 | t.Fatalf("custom resource definition not found")
528 | return
529 | }
530 | })
531 | c.crdWaitUntilReady("customs.example.com")
532 | customJson := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\"}}"
533 | resourcesCreateOrUpdateCustom, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJson})
534 | t.Run("resources_create_or_update with valid namespaced json resource returns success", func(t *testing.T) {
535 | if err != nil {
536 | t.Fatalf("call tool failed %v", err)
537 | return
538 | }
539 | if resourcesCreateOrUpdateCustom.IsError {
540 | t.Fatalf("call tool failed, got: %v", resourcesCreateOrUpdateCustom.Content)
541 | return
542 | }
543 | })
544 | t.Run("resources_create_or_update with valid namespaced json resource creates custom resource", func(t *testing.T) {
545 | dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
546 | _, err = dynamicClient.
547 | Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
548 | Namespace("default").
549 | Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
550 | if err != nil {
551 | t.Fatalf("custom resource not found")
552 | return
553 | }
554 | })
555 | customJsonUpdated := "{\"apiVersion\": \"example.com/v1\", \"kind\": \"Custom\", \"metadata\": {\"name\": \"a-custom-resource\",\"annotations\": {\"updated\": \"true\"}}}"
556 | resourcesCreateOrUpdateCustomUpdated, err := c.callTool("resources_create_or_update", map[string]interface{}{"resource": customJsonUpdated})
557 | t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
558 | if err != nil {
559 | t.Fatalf("call tool failed %v", err)
560 | return
561 | }
562 | if resourcesCreateOrUpdateCustomUpdated.IsError {
563 | t.Fatalf("call tool failed")
564 | return
565 | }
566 | })
567 | t.Run("resources_create_or_update with valid namespaced json resource updates custom resource", func(t *testing.T) {
568 | dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
569 | customResource, _ := dynamicClient.
570 | Resource(schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "customs"}).
571 | Namespace("default").
572 | Get(c.ctx, "a-custom-resource", metav1.GetOptions{})
573 | if customResource == nil {
574 | t.Fatalf("custom resource not found")
575 | return
576 | }
577 | annotations := customResource.GetAnnotations()
578 | if annotations == nil || annotations["updated"] != "true" {
579 | t.Fatalf("custom resource not updated")
580 | return
581 | }
582 | })
583 | })
584 | }
585 |
586 | func TestResourcesCreateOrUpdateDenied(t *testing.T) {
587 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
588 | denied_resources = [
589 | { version = "v1", kind = "Secret" },
590 | { group = "rbac.authorization.k8s.io", version = "v1" }
591 | ]
592 | `)))
593 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
594 | c.withEnvTest()
595 | secretYaml := "apiVersion: v1\nkind: Secret\nmetadata:\n name: a-denied-secret\n namespace: default\n"
596 | deniedByKind, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": secretYaml})
597 | t.Run("resources_create_or_update (denied by kind) has error", func(t *testing.T) {
598 | if !deniedByKind.IsError {
599 | t.Fatalf("call tool should fail")
600 | }
601 | })
602 | t.Run("resources_create_or_update (denied by kind) describes denial", func(t *testing.T) {
603 | expectedMessage := "failed to create or update resources: resource not allowed: /v1, Kind=Secret"
604 | if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
605 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
606 | }
607 | })
608 | roleYaml := "apiVersion: rbac.authorization.k8s.io/v1\nkind: Role\nmetadata:\n name: a-denied-role\n namespace: default\n"
609 | deniedByGroup, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": roleYaml})
610 | t.Run("resources_create_or_update (denied by group) has error", func(t *testing.T) {
611 | if !deniedByGroup.IsError {
612 | t.Fatalf("call tool should fail")
613 | }
614 | })
615 | t.Run("resources_create_or_update (denied by group) describes denial", func(t *testing.T) {
616 | expectedMessage := "failed to create or update resources: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
617 | if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
618 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
619 | }
620 | })
621 | configMapYaml := "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: a-cm-created-or-updated\n namespace: default\n"
622 | allowedResource, _ := c.callTool("resources_create_or_update", map[string]interface{}{"resource": configMapYaml})
623 | t.Run("resources_create_or_update (not denied) creates or updates resource", func(t *testing.T) {
624 | if allowedResource.IsError {
625 | t.Fatalf("call tool should not fail")
626 | }
627 | })
628 | })
629 | }
630 |
631 | func TestResourcesDelete(t *testing.T) {
632 | testCase(t, func(c *mcpContext) {
633 | c.withEnvTest()
634 | t.Run("resources_delete with missing apiVersion returns error", func(t *testing.T) {
635 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{})
636 | if !toolResult.IsError {
637 | t.Fatalf("call tool should fail")
638 | return
639 | }
640 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument apiVersion" {
641 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
642 | return
643 | }
644 | })
645 | t.Run("resources_delete with missing kind returns error", func(t *testing.T) {
646 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1"})
647 | if !toolResult.IsError {
648 | t.Fatalf("call tool should fail")
649 | return
650 | }
651 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument kind" {
652 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
653 | return
654 | }
655 | })
656 | t.Run("resources_delete with invalid apiVersion returns error", func(t *testing.T) {
657 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "invalid/api/version", "kind": "Pod", "name": "a-pod"})
658 | if !toolResult.IsError {
659 | t.Fatalf("call tool should fail")
660 | return
661 | }
662 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, invalid argument apiVersion" {
663 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
664 | return
665 | }
666 | })
667 | t.Run("resources_delete with nonexistent apiVersion returns error", func(t *testing.T) {
668 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "custom.non.existent.example.com/v1", "kind": "Custom", "name": "a-custom"})
669 | if !toolResult.IsError {
670 | t.Fatalf("call tool should fail")
671 | return
672 | }
673 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: no matches for kind "Custom" in version "custom.non.existent.example.com/v1"` {
674 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
675 | return
676 | }
677 | })
678 | t.Run("resources_delete with missing name returns error", func(t *testing.T) {
679 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace"})
680 | if !toolResult.IsError {
681 | t.Fatalf("call tool should fail")
682 | return
683 | }
684 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete resource, missing argument name" {
685 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
686 | return
687 | }
688 | })
689 | t.Run("resources_delete with nonexistent resource returns error", func(t *testing.T) {
690 | toolResult, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "nonexistent-configmap"})
691 | if !toolResult.IsError {
692 | t.Fatalf("call tool should fail")
693 | return
694 | }
695 | if toolResult.Content[0].(mcp.TextContent).Text != `failed to delete resource: configmaps "nonexistent-configmap" not found` {
696 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
697 | return
698 | }
699 | })
700 | resourcesDeleteCm, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "a-configmap-to-delete"})
701 | t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) {
702 | if err != nil {
703 | t.Fatalf("call tool failed %v", err)
704 | return
705 | }
706 | if resourcesDeleteCm.IsError {
707 | t.Fatalf("call tool failed")
708 | return
709 | }
710 | if resourcesDeleteCm.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" {
711 | t.Fatalf("invalid tool result content got: %v", resourcesDeleteCm.Content[0].(mcp.TextContent).Text)
712 | return
713 | }
714 | })
715 | client := c.newKubernetesClient()
716 | t.Run("resources_delete with valid namespaced resource deletes ConfigMap", func(t *testing.T) {
717 | _, err := client.CoreV1().ConfigMaps("default").Get(c.ctx, "a-configmap-to-delete", metav1.GetOptions{})
718 | if err == nil {
719 | t.Fatalf("ConfigMap not deleted")
720 | return
721 | }
722 | })
723 | resourcesDeleteNamespace, err := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Namespace", "name": "ns-to-delete"})
724 | t.Run("resources_delete with valid namespaced resource returns success", func(t *testing.T) {
725 | if err != nil {
726 | t.Fatalf("call tool failed %v", err)
727 | return
728 | }
729 | if resourcesDeleteNamespace.IsError {
730 | t.Fatalf("call tool failed")
731 | return
732 | }
733 | if resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text != "Resource deleted successfully" {
734 | t.Fatalf("invalid tool result content got: %v", resourcesDeleteNamespace.Content[0].(mcp.TextContent).Text)
735 | return
736 | }
737 | })
738 | t.Run("resources_delete with valid namespaced resource deletes Namespace", func(t *testing.T) {
739 | ns, err := client.CoreV1().Namespaces().Get(c.ctx, "ns-to-delete", metav1.GetOptions{})
740 | if err == nil && ns != nil && ns.DeletionTimestamp == nil {
741 | t.Fatalf("Namespace not deleted")
742 | return
743 | }
744 | })
745 | })
746 | }
747 |
748 | func TestResourcesDeleteDenied(t *testing.T) {
749 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
750 | denied_resources = [
751 | { version = "v1", kind = "Secret" },
752 | { group = "rbac.authorization.k8s.io", version = "v1" }
753 | ]
754 | `)))
755 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
756 | c.withEnvTest()
757 | kc := c.newKubernetesClient()
758 | _, _ = kc.CoreV1().ConfigMaps("default").Create(c.ctx, &corev1.ConfigMap{
759 | ObjectMeta: metav1.ObjectMeta{Name: "allowed-configmap-to-delete"},
760 | }, metav1.CreateOptions{})
761 | deniedByKind, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "Secret", "namespace": "default", "name": "denied-secret"})
762 | t.Run("resources_delete (denied by kind) has error", func(t *testing.T) {
763 | if !deniedByKind.IsError {
764 | t.Fatalf("call tool should fail")
765 | }
766 | })
767 | t.Run("resources_delete (denied by kind) describes denial", func(t *testing.T) {
768 | expectedMessage := "failed to delete resource: resource not allowed: /v1, Kind=Secret"
769 | if deniedByKind.Content[0].(mcp.TextContent).Text != expectedMessage {
770 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
771 | }
772 | })
773 | deniedByGroup, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "rbac.authorization.k8s.io/v1", "kind": "Role", "namespace": "default", "name": "denied-role"})
774 | t.Run("resources_delete (denied by group) has error", func(t *testing.T) {
775 | if !deniedByGroup.IsError {
776 | t.Fatalf("call tool should fail")
777 | }
778 | })
779 | t.Run("resources_delete (denied by group) describes denial", func(t *testing.T) {
780 | expectedMessage := "failed to delete resource: resource not allowed: rbac.authorization.k8s.io/v1, Kind=Role"
781 | if deniedByGroup.Content[0].(mcp.TextContent).Text != expectedMessage {
782 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, deniedByKind.Content[0].(mcp.TextContent).Text)
783 | }
784 | })
785 | allowedResource, _ := c.callTool("resources_delete", map[string]interface{}{"apiVersion": "v1", "kind": "ConfigMap", "name": "allowed-configmap-to-delete"})
786 | t.Run("resources_delete (not denied) deletes resource", func(t *testing.T) {
787 | if allowedResource.IsError {
788 | t.Fatalf("call tool should not fail")
789 | }
790 | })
791 | })
792 | }
793 |
```
--------------------------------------------------------------------------------
/pkg/mcp/pods_test.go:
--------------------------------------------------------------------------------
```go
1 | package mcp
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/containers/kubernetes-mcp-server/internal/test"
9 | "github.com/containers/kubernetes-mcp-server/pkg/config"
10 | "github.com/containers/kubernetes-mcp-server/pkg/output"
11 |
12 | "github.com/mark3labs/mcp-go/mcp"
13 | corev1 "k8s.io/api/core/v1"
14 | rbacv1 "k8s.io/api/rbac/v1"
15 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
17 | "k8s.io/apimachinery/pkg/runtime/schema"
18 | "k8s.io/client-go/dynamic"
19 | "sigs.k8s.io/yaml"
20 | )
21 |
22 | func TestPodsListInAllNamespaces(t *testing.T) {
23 | testCase(t, func(c *mcpContext) {
24 | c.withEnvTest()
25 | toolResult, err := c.callTool("pods_list", map[string]interface{}{})
26 | t.Run("pods_list returns pods list", func(t *testing.T) {
27 | if err != nil {
28 | t.Fatalf("call tool failed %v", err)
29 | }
30 | if toolResult.IsError {
31 | t.Fatalf("call tool failed")
32 | }
33 | })
34 | var decoded []unstructured.Unstructured
35 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
36 | t.Run("pods_list has yaml content", func(t *testing.T) {
37 | if err != nil {
38 | t.Fatalf("invalid tool result content %v", err)
39 | }
40 | })
41 | t.Run("pods_list returns 3 items", func(t *testing.T) {
42 | if len(decoded) != 3 {
43 | t.Fatalf("invalid pods count, expected 3, got %v", len(decoded))
44 | }
45 | })
46 | t.Run("pods_list returns pod in ns-1", func(t *testing.T) {
47 | if decoded[1].GetName() != "a-pod-in-ns-1" {
48 | t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[1].GetName())
49 | }
50 | if decoded[1].GetNamespace() != "ns-1" {
51 | t.Fatalf("invalid pod namespace, expected ns-1, got %v", decoded[1].GetNamespace())
52 | }
53 | })
54 | t.Run("pods_list returns pod in ns-2", func(t *testing.T) {
55 | if decoded[2].GetName() != "a-pod-in-ns-2" {
56 | t.Fatalf("invalid pod name, expected a-pod-in-ns-2, got %v", decoded[2].GetName())
57 | }
58 | if decoded[2].GetNamespace() != "ns-2" {
59 | t.Fatalf("invalid pod namespace, expected ns-2, got %v", decoded[2].GetNamespace())
60 | }
61 | })
62 | t.Run("pods_list omits managed fields", func(t *testing.T) {
63 | if decoded[1].GetManagedFields() != nil {
64 | t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
65 | }
66 | })
67 | })
68 | }
69 |
70 | func TestPodsListInAllNamespacesUnauthorized(t *testing.T) {
71 | testCase(t, func(c *mcpContext) {
72 | c.withEnvTest()
73 | defer restoreAuth(c.ctx)
74 | client := c.newKubernetesClient()
75 | // Authorize user only for default/configured namespace
76 | r, _ := client.RbacV1().Roles("default").Create(c.ctx, &rbacv1.Role{
77 | ObjectMeta: metav1.ObjectMeta{Name: "allow-pods-list"},
78 | Rules: []rbacv1.PolicyRule{{
79 | Verbs: []string{"get", "list"},
80 | APIGroups: []string{""},
81 | Resources: []string{"pods"},
82 | }},
83 | }, metav1.CreateOptions{})
84 | _, _ = client.RbacV1().RoleBindings("default").Create(c.ctx, &rbacv1.RoleBinding{
85 | ObjectMeta: metav1.ObjectMeta{Name: "allow-pods-list"},
86 | Subjects: []rbacv1.Subject{{Kind: "User", Name: envTestUser.Name}},
87 | RoleRef: rbacv1.RoleRef{Kind: "Role", Name: r.Name},
88 | }, metav1.CreateOptions{})
89 | // Deny cluster by removing cluster rule
90 | _ = client.RbacV1().ClusterRoles().Delete(c.ctx, "allow-all", metav1.DeleteOptions{})
91 | toolResult, err := c.callTool("pods_list", map[string]interface{}{})
92 | t.Run("pods_list returns pods list for default namespace only", func(t *testing.T) {
93 | if err != nil {
94 | t.Fatalf("call tool failed %v", err)
95 | return
96 | }
97 | if toolResult.IsError {
98 | t.Fatalf("call tool failed %v", toolResult.Content)
99 | return
100 | }
101 | })
102 | var decoded []unstructured.Unstructured
103 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
104 | t.Run("pods_list has yaml content", func(t *testing.T) {
105 | if err != nil {
106 | t.Fatalf("invalid tool result content %v", err)
107 | return
108 | }
109 | })
110 | t.Run("pods_list returns 1 items", func(t *testing.T) {
111 | if len(decoded) != 1 {
112 | t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
113 | return
114 | }
115 | })
116 | t.Run("pods_list returns pod in default", func(t *testing.T) {
117 | if decoded[0].GetName() != "a-pod-in-default" {
118 | t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decoded[0].GetName())
119 | return
120 | }
121 | if decoded[0].GetNamespace() != "default" {
122 | t.Fatalf("invalid pod namespace, expected default, got %v", decoded[0].GetNamespace())
123 | return
124 | }
125 | })
126 | })
127 | }
128 |
129 | func TestPodsListInNamespace(t *testing.T) {
130 | testCase(t, func(c *mcpContext) {
131 | c.withEnvTest()
132 | t.Run("pods_list_in_namespace with nil namespace returns error", func(t *testing.T) {
133 | toolResult, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{})
134 | if toolResult.IsError != true {
135 | t.Fatalf("call tool should fail")
136 | return
137 | }
138 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to list pods in namespace, missing argument namespace" {
139 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
140 | return
141 | }
142 | })
143 | toolResult, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
144 | "namespace": "ns-1",
145 | })
146 | t.Run("pods_list_in_namespace returns pods list", func(t *testing.T) {
147 | if err != nil {
148 | t.Fatalf("call tool failed %v", err)
149 | }
150 | if toolResult.IsError {
151 | t.Fatalf("call tool failed")
152 | }
153 | })
154 | var decoded []unstructured.Unstructured
155 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
156 | t.Run("pods_list_in_namespace has yaml content", func(t *testing.T) {
157 | if err != nil {
158 | t.Fatalf("invalid tool result content %v", err)
159 | }
160 | })
161 | t.Run("pods_list_in_namespace returns 1 items", func(t *testing.T) {
162 | if len(decoded) != 1 {
163 | t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
164 | }
165 | })
166 | t.Run("pods_list_in_namespace returns pod in ns-1", func(t *testing.T) {
167 | if decoded[0].GetName() != "a-pod-in-ns-1" {
168 | t.Errorf("invalid pod name, expected a-pod-in-ns-1, got %v", decoded[0].GetName())
169 | }
170 | if decoded[0].GetNamespace() != "ns-1" {
171 | t.Errorf("invalid pod namespace, expected ns-1, got %v", decoded[0].GetNamespace())
172 | }
173 | })
174 | t.Run("pods_list_in_namespace omits managed fields", func(t *testing.T) {
175 | if decoded[0].GetManagedFields() != nil {
176 | t.Fatalf("managed fields should be omitted, got %v", decoded[0].GetManagedFields())
177 | }
178 | })
179 | })
180 | }
181 |
182 | func TestPodsListDenied(t *testing.T) {
183 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
184 | denied_resources = [ { version = "v1", kind = "Pod" } ]
185 | `)))
186 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
187 | c.withEnvTest()
188 | podsList, _ := c.callTool("pods_list", map[string]interface{}{})
189 | t.Run("pods_list has error", func(t *testing.T) {
190 | if !podsList.IsError {
191 | t.Fatalf("call tool should fail")
192 | }
193 | })
194 | t.Run("pods_list describes denial", func(t *testing.T) {
195 | expectedMessage := "failed to list pods in all namespaces: resource not allowed: /v1, Kind=Pod"
196 | if podsList.Content[0].(mcp.TextContent).Text != expectedMessage {
197 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsList.Content[0].(mcp.TextContent).Text)
198 | }
199 | })
200 | podsListInNamespace, _ := c.callTool("pods_list_in_namespace", map[string]interface{}{"namespace": "ns-1"})
201 | t.Run("pods_list_in_namespace has error", func(t *testing.T) {
202 | if !podsListInNamespace.IsError {
203 | t.Fatalf("call tool should fail")
204 | }
205 | })
206 | t.Run("pods_list_in_namespace describes denial", func(t *testing.T) {
207 | expectedMessage := "failed to list pods in namespace ns-1: resource not allowed: /v1, Kind=Pod"
208 | if podsListInNamespace.Content[0].(mcp.TextContent).Text != expectedMessage {
209 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsListInNamespace.Content[0].(mcp.TextContent).Text)
210 | }
211 | })
212 | })
213 | }
214 |
215 | func TestPodsListAsTable(t *testing.T) {
216 | testCaseWithContext(t, &mcpContext{listOutput: output.Table}, func(c *mcpContext) {
217 | c.withEnvTest()
218 | podsList, err := c.callTool("pods_list", map[string]interface{}{})
219 | t.Run("pods_list returns pods list", func(t *testing.T) {
220 | if err != nil {
221 | t.Fatalf("call tool failed %v", err)
222 | }
223 | if podsList.IsError {
224 | t.Fatalf("call tool failed")
225 | }
226 | })
227 | outPodsList := podsList.Content[0].(mcp.TextContent).Text
228 | t.Run("pods_list returns table with 1 header and 3 rows", func(t *testing.T) {
229 | lines := strings.Count(outPodsList, "\n")
230 | if lines != 4 {
231 | t.Fatalf("invalid line count, expected 4 (1 header, 3 row), got %v", lines)
232 | }
233 | })
234 | t.Run("pods_list_in_namespace returns column headers", func(t *testing.T) {
235 | expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+READY\\s+STATUS\\s+RESTARTS\\s+AGE\\s+IP\\s+NODE\\s+NOMINATED NODE\\s+READINESS GATES\\s+LABELS"
236 | if m, e := regexp.MatchString(expectedHeaders, outPodsList); !m || e != nil {
237 | t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outPodsList)
238 | }
239 | })
240 | t.Run("pods_list_in_namespace returns formatted row for a-pod-in-ns-1", func(t *testing.T) {
241 | expectedRow := "(?<namespace>ns-1)\\s+" +
242 | "(?<apiVersion>v1)\\s+" +
243 | "(?<kind>Pod)\\s+" +
244 | "(?<name>a-pod-in-ns-1)\\s+" +
245 | "(?<ready>0\\/1)\\s+" +
246 | "(?<status>Pending)\\s+" +
247 | "(?<restarts>0)\\s+" +
248 | "(?<age>(\\d+m)?(\\d+s)?)\\s+" +
249 | "(?<ip><none>)\\s+" +
250 | "(?<node><none>)\\s+" +
251 | "(?<nominated_node><none>)\\s+" +
252 | "(?<readiness_gates><none>)\\s+" +
253 | "(?<labels><none>)"
254 | if m, e := regexp.MatchString(expectedRow, outPodsList); !m || e != nil {
255 | t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsList)
256 | }
257 | })
258 | t.Run("pods_list_in_namespace returns formatted row for a-pod-in-default", func(t *testing.T) {
259 | expectedRow := "(?<namespace>default)\\s+" +
260 | "(?<apiVersion>v1)\\s+" +
261 | "(?<kind>Pod)\\s+" +
262 | "(?<name>a-pod-in-default)\\s+" +
263 | "(?<ready>0\\/1)\\s+" +
264 | "(?<status>Pending)\\s+" +
265 | "(?<restarts>0)\\s+" +
266 | "(?<age>(\\d+m)?(\\d+s)?)\\s+" +
267 | "(?<ip><none>)\\s+" +
268 | "(?<node><none>)\\s+" +
269 | "(?<nominated_node><none>)\\s+" +
270 | "(?<readiness_gates><none>)\\s+" +
271 | "(?<labels>app=nginx)"
272 | if m, e := regexp.MatchString(expectedRow, outPodsList); !m || e != nil {
273 | t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsList)
274 | }
275 | })
276 | podsListInNamespace, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
277 | "namespace": "ns-1",
278 | })
279 | t.Run("pods_list_in_namespace returns pods list", func(t *testing.T) {
280 | if err != nil {
281 | t.Fatalf("call tool failed %v", err)
282 | return
283 | }
284 | if podsListInNamespace.IsError {
285 | t.Fatalf("call tool failed")
286 | }
287 | })
288 | outPodsListInNamespace := podsListInNamespace.Content[0].(mcp.TextContent).Text
289 | t.Run("pods_list_in_namespace returns table with 1 header and 1 row", func(t *testing.T) {
290 | lines := strings.Count(outPodsListInNamespace, "\n")
291 | if lines != 2 {
292 | t.Fatalf("invalid line count, expected 2 (1 header, 1 row), got %v", lines)
293 | }
294 | })
295 | t.Run("pods_list_in_namespace returns column headers", func(t *testing.T) {
296 | expectedHeaders := "NAMESPACE\\s+APIVERSION\\s+KIND\\s+NAME\\s+READY\\s+STATUS\\s+RESTARTS\\s+AGE\\s+IP\\s+NODE\\s+NOMINATED NODE\\s+READINESS GATES\\s+LABELS"
297 | if m, e := regexp.MatchString(expectedHeaders, outPodsListInNamespace); !m || e != nil {
298 | t.Fatalf("Expected headers '%s' not found in output:\n%s", expectedHeaders, outPodsListInNamespace)
299 | }
300 | })
301 | t.Run("pods_list_in_namespace returns formatted row", func(t *testing.T) {
302 | expectedRow := "(?<namespace>ns-1)\\s+" +
303 | "(?<apiVersion>v1)\\s+" +
304 | "(?<kind>Pod)\\s+" +
305 | "(?<name>a-pod-in-ns-1)\\s+" +
306 | "(?<ready>0\\/1)\\s+" +
307 | "(?<status>Pending)\\s+" +
308 | "(?<restarts>0)\\s+" +
309 | "(?<age>(\\d+m)?(\\d+s)?)\\s+" +
310 | "(?<ip><none>)\\s+" +
311 | "(?<node><none>)\\s+" +
312 | "(?<nominated_node><none>)\\s+" +
313 | "(?<readiness_gates><none>)\\s+" +
314 | "(?<labels><none>)"
315 | if m, e := regexp.MatchString(expectedRow, outPodsListInNamespace); !m || e != nil {
316 | t.Fatalf("Expected row '%s' not found in output:\n%s", expectedRow, outPodsListInNamespace)
317 | }
318 | })
319 | })
320 | }
321 |
322 | func TestPodsGet(t *testing.T) {
323 | testCase(t, func(c *mcpContext) {
324 | c.withEnvTest()
325 | t.Run("pods_get with nil name returns error", func(t *testing.T) {
326 | toolResult, _ := c.callTool("pods_get", map[string]interface{}{})
327 | if toolResult.IsError != true {
328 | t.Fatalf("call tool should fail")
329 | return
330 | }
331 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod, missing argument name" {
332 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
333 | return
334 | }
335 | })
336 | t.Run("pods_get with not found name returns error", func(t *testing.T) {
337 | toolResult, _ := c.callTool("pods_get", map[string]interface{}{"name": "not-found"})
338 | if toolResult.IsError != true {
339 | t.Fatalf("call tool should fail")
340 | return
341 | }
342 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod not-found in namespace : pods \"not-found\" not found" {
343 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
344 | return
345 | }
346 | })
347 | podsGetNilNamespace, err := c.callTool("pods_get", map[string]interface{}{
348 | "name": "a-pod-in-default",
349 | })
350 | t.Run("pods_get with name and nil namespace returns pod", func(t *testing.T) {
351 | if err != nil {
352 | t.Fatalf("call tool failed %v", err)
353 | return
354 | }
355 | if podsGetNilNamespace.IsError {
356 | t.Fatalf("call tool failed")
357 | return
358 | }
359 | })
360 | var decodedNilNamespace unstructured.Unstructured
361 | err = yaml.Unmarshal([]byte(podsGetNilNamespace.Content[0].(mcp.TextContent).Text), &decodedNilNamespace)
362 | t.Run("pods_get with name and nil namespace has yaml content", func(t *testing.T) {
363 | if err != nil {
364 | t.Fatalf("invalid tool result content %v", err)
365 | return
366 | }
367 | })
368 | t.Run("pods_get with name and nil namespace returns pod in default", func(t *testing.T) {
369 | if decodedNilNamespace.GetName() != "a-pod-in-default" {
370 | t.Fatalf("invalid pod name, expected a-pod-in-default, got %v", decodedNilNamespace.GetName())
371 | return
372 | }
373 | if decodedNilNamespace.GetNamespace() != "default" {
374 | t.Fatalf("invalid pod namespace, expected default, got %v", decodedNilNamespace.GetNamespace())
375 | return
376 | }
377 | })
378 | t.Run("pods_get with name and nil namespace omits managed fields", func(t *testing.T) {
379 | if decodedNilNamespace.GetManagedFields() != nil {
380 | t.Fatalf("managed fields should be omitted, got %v", decodedNilNamespace.GetManagedFields())
381 | return
382 | }
383 | })
384 | podsGetInNamespace, err := c.callTool("pods_get", map[string]interface{}{
385 | "namespace": "ns-1",
386 | "name": "a-pod-in-ns-1",
387 | })
388 | t.Run("pods_get with name and namespace returns pod", func(t *testing.T) {
389 | if err != nil {
390 | t.Fatalf("call tool failed %v", err)
391 | return
392 | }
393 | if podsGetInNamespace.IsError {
394 | t.Fatalf("call tool failed")
395 | return
396 | }
397 | })
398 | var decodedInNamespace unstructured.Unstructured
399 | err = yaml.Unmarshal([]byte(podsGetInNamespace.Content[0].(mcp.TextContent).Text), &decodedInNamespace)
400 | t.Run("pods_get with name and namespace has yaml content", func(t *testing.T) {
401 | if err != nil {
402 | t.Fatalf("invalid tool result content %v", err)
403 | return
404 | }
405 | })
406 | t.Run("pods_get with name and namespace returns pod in ns-1", func(t *testing.T) {
407 | if decodedInNamespace.GetName() != "a-pod-in-ns-1" {
408 | t.Fatalf("invalid pod name, expected a-pod-in-ns-1, got %v", decodedInNamespace.GetName())
409 | return
410 | }
411 | if decodedInNamespace.GetNamespace() != "ns-1" {
412 | t.Fatalf("invalid pod namespace, ns-1 ns-1, got %v", decodedInNamespace.GetNamespace())
413 | return
414 | }
415 | })
416 | })
417 | }
418 |
419 | func TestPodsGetDenied(t *testing.T) {
420 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
421 | denied_resources = [ { version = "v1", kind = "Pod" } ]
422 | `)))
423 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
424 | c.withEnvTest()
425 | podsGet, _ := c.callTool("pods_get", map[string]interface{}{"name": "a-pod-in-default"})
426 | t.Run("pods_get has error", func(t *testing.T) {
427 | if !podsGet.IsError {
428 | t.Fatalf("call tool should fail")
429 | }
430 | })
431 | t.Run("pods_get describes denial", func(t *testing.T) {
432 | expectedMessage := "failed to get pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
433 | if podsGet.Content[0].(mcp.TextContent).Text != expectedMessage {
434 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsGet.Content[0].(mcp.TextContent).Text)
435 | }
436 | })
437 | })
438 | }
439 |
440 | func TestPodsDelete(t *testing.T) {
441 | testCase(t, func(c *mcpContext) {
442 | c.withEnvTest()
443 | // Errors
444 | t.Run("pods_delete with nil name returns error", func(t *testing.T) {
445 | toolResult, _ := c.callTool("pods_delete", map[string]interface{}{})
446 | if toolResult.IsError != true {
447 | t.Errorf("call tool should fail")
448 | return
449 | }
450 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete pod, missing argument name" {
451 | t.Errorf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
452 | return
453 | }
454 | })
455 | t.Run("pods_delete with not found name returns error", func(t *testing.T) {
456 | toolResult, _ := c.callTool("pods_delete", map[string]interface{}{"name": "not-found"})
457 | if toolResult.IsError != true {
458 | t.Errorf("call tool should fail")
459 | return
460 | }
461 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to delete pod not-found in namespace : pods \"not-found\" not found" {
462 | t.Errorf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
463 | return
464 | }
465 | })
466 | // Default/nil Namespace
467 | kc := c.newKubernetesClient()
468 | _, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
469 | ObjectMeta: metav1.ObjectMeta{Name: "a-pod-to-delete"},
470 | Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
471 | }, metav1.CreateOptions{})
472 | podsDeleteNilNamespace, err := c.callTool("pods_delete", map[string]interface{}{
473 | "name": "a-pod-to-delete",
474 | })
475 | t.Run("pods_delete with name and nil namespace returns success", func(t *testing.T) {
476 | if err != nil {
477 | t.Errorf("call tool failed %v", err)
478 | return
479 | }
480 | if podsDeleteNilNamespace.IsError {
481 | t.Errorf("call tool failed")
482 | return
483 | }
484 | if podsDeleteNilNamespace.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
485 | t.Errorf("invalid tool result content, got %v", podsDeleteNilNamespace.Content[0].(mcp.TextContent).Text)
486 | return
487 | }
488 | })
489 | t.Run("pods_delete with name and nil namespace deletes Pod", func(t *testing.T) {
490 | p, pErr := kc.CoreV1().Pods("default").Get(c.ctx, "a-pod-to-delete", metav1.GetOptions{})
491 | if pErr == nil && p != nil && p.DeletionTimestamp == nil {
492 | t.Errorf("Pod not deleted")
493 | return
494 | }
495 | })
496 | // Provided Namespace
497 | _, _ = kc.CoreV1().Pods("ns-1").Create(c.ctx, &corev1.Pod{
498 | ObjectMeta: metav1.ObjectMeta{Name: "a-pod-to-delete-in-ns-1"},
499 | Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
500 | }, metav1.CreateOptions{})
501 | podsDeleteInNamespace, err := c.callTool("pods_delete", map[string]interface{}{
502 | "namespace": "ns-1",
503 | "name": "a-pod-to-delete-in-ns-1",
504 | })
505 | t.Run("pods_delete with name and namespace returns success", func(t *testing.T) {
506 | if err != nil {
507 | t.Errorf("call tool failed %v", err)
508 | return
509 | }
510 | if podsDeleteInNamespace.IsError {
511 | t.Errorf("call tool failed")
512 | return
513 | }
514 | if podsDeleteInNamespace.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
515 | t.Errorf("invalid tool result content, got %v", podsDeleteInNamespace.Content[0].(mcp.TextContent).Text)
516 | return
517 | }
518 | })
519 | t.Run("pods_delete with name and namespace deletes Pod", func(t *testing.T) {
520 | p, pErr := kc.CoreV1().Pods("ns-1").Get(c.ctx, "a-pod-to-delete-in-ns-1", metav1.GetOptions{})
521 | if pErr == nil && p != nil && p.DeletionTimestamp == nil {
522 | t.Errorf("Pod not deleted")
523 | return
524 | }
525 | })
526 | // Managed Pod
527 | managedLabels := map[string]string{
528 | "app.kubernetes.io/managed-by": "kubernetes-mcp-server",
529 | "app.kubernetes.io/name": "a-manged-pod-to-delete",
530 | }
531 | _, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
532 | ObjectMeta: metav1.ObjectMeta{Name: "a-managed-pod-to-delete", Labels: managedLabels},
533 | Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
534 | }, metav1.CreateOptions{})
535 | _, _ = kc.CoreV1().Services("default").Create(c.ctx, &corev1.Service{
536 | ObjectMeta: metav1.ObjectMeta{Name: "a-managed-service-to-delete", Labels: managedLabels},
537 | Spec: corev1.ServiceSpec{Selector: managedLabels, Ports: []corev1.ServicePort{{Port: 80}}},
538 | }, metav1.CreateOptions{})
539 | podsDeleteManaged, err := c.callTool("pods_delete", map[string]interface{}{
540 | "name": "a-managed-pod-to-delete",
541 | })
542 | t.Run("pods_delete with managed pod returns success", func(t *testing.T) {
543 | if err != nil {
544 | t.Errorf("call tool failed %v", err)
545 | return
546 | }
547 | if podsDeleteManaged.IsError {
548 | t.Errorf("call tool failed")
549 | return
550 | }
551 | if podsDeleteManaged.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
552 | t.Errorf("invalid tool result content, got %v", podsDeleteManaged.Content[0].(mcp.TextContent).Text)
553 | return
554 | }
555 | })
556 | t.Run("pods_delete with managed pod deletes Pod and Service", func(t *testing.T) {
557 | p, pErr := kc.CoreV1().Pods("default").Get(c.ctx, "a-managed-pod-to-delete", metav1.GetOptions{})
558 | if pErr == nil && p != nil && p.DeletionTimestamp == nil {
559 | t.Errorf("Pod not deleted")
560 | return
561 | }
562 | s, sErr := kc.CoreV1().Services("default").Get(c.ctx, "a-managed-service-to-delete", metav1.GetOptions{})
563 | if sErr == nil && s != nil && s.DeletionTimestamp == nil {
564 | t.Errorf("Service not deleted")
565 | return
566 | }
567 | })
568 | })
569 | }
570 |
571 | func TestPodsDeleteDenied(t *testing.T) {
572 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
573 | denied_resources = [ { version = "v1", kind = "Pod" } ]
574 | `)))
575 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
576 | c.withEnvTest()
577 | podsDelete, _ := c.callTool("pods_delete", map[string]interface{}{"name": "a-pod-in-default"})
578 | t.Run("pods_delete has error", func(t *testing.T) {
579 | if !podsDelete.IsError {
580 | t.Fatalf("call tool should fail")
581 | }
582 | })
583 | t.Run("pods_delete describes denial", func(t *testing.T) {
584 | expectedMessage := "failed to delete pod a-pod-in-default in namespace : resource not allowed: /v1, Kind=Pod"
585 | if podsDelete.Content[0].(mcp.TextContent).Text != expectedMessage {
586 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsDelete.Content[0].(mcp.TextContent).Text)
587 | }
588 | })
589 | })
590 | }
591 |
592 | func TestPodsDeleteInOpenShift(t *testing.T) {
593 | testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
594 | managedLabels := map[string]string{
595 | "app.kubernetes.io/managed-by": "kubernetes-mcp-server",
596 | "app.kubernetes.io/name": "a-manged-pod-to-delete",
597 | }
598 | kc := c.newKubernetesClient()
599 | _, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
600 | ObjectMeta: metav1.ObjectMeta{Name: "a-managed-pod-to-delete-in-openshift", Labels: managedLabels},
601 | Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
602 | }, metav1.CreateOptions{})
603 | dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
604 | _, _ = dynamicClient.Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
605 | Namespace("default").Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
606 | "apiVersion": "route.openshift.io/v1",
607 | "kind": "Route",
608 | "metadata": map[string]interface{}{
609 | "name": "a-managed-route-to-delete",
610 | "labels": managedLabels,
611 | },
612 | }}, metav1.CreateOptions{})
613 | podsDeleteManagedOpenShift, err := c.callTool("pods_delete", map[string]interface{}{
614 | "name": "a-managed-pod-to-delete-in-openshift",
615 | })
616 | t.Run("pods_delete with managed pod in OpenShift returns success", func(t *testing.T) {
617 | if err != nil {
618 | t.Errorf("call tool failed %v", err)
619 | return
620 | }
621 | if podsDeleteManagedOpenShift.IsError {
622 | t.Errorf("call tool failed")
623 | return
624 | }
625 | if podsDeleteManagedOpenShift.Content[0].(mcp.TextContent).Text != "Pod deleted successfully" {
626 | t.Errorf("invalid tool result content, got %v", podsDeleteManagedOpenShift.Content[0].(mcp.TextContent).Text)
627 | return
628 | }
629 | })
630 | t.Run("pods_delete with managed pod in OpenShift deletes Pod and Route", func(t *testing.T) {
631 | p, pErr := kc.CoreV1().Pods("default").Get(c.ctx, "a-managed-pod-to-delete-in-openshift", metav1.GetOptions{})
632 | if pErr == nil && p != nil && p.DeletionTimestamp == nil {
633 | t.Errorf("Pod not deleted")
634 | return
635 | }
636 | r, rErr := dynamicClient.
637 | Resource(schema.GroupVersionResource{Group: "route.openshift.io", Version: "v1", Resource: "routes"}).
638 | Namespace("default").Get(c.ctx, "a-managed-route-to-delete", metav1.GetOptions{})
639 | if rErr == nil && r != nil && r.GetDeletionTimestamp() == nil {
640 | t.Errorf("Route not deleted")
641 | return
642 | }
643 | })
644 | })
645 | }
646 |
647 | func TestPodsLog(t *testing.T) {
648 | testCase(t, func(c *mcpContext) {
649 | c.withEnvTest()
650 | t.Run("pods_log with nil name returns error", func(t *testing.T) {
651 | toolResult, _ := c.callTool("pods_log", map[string]interface{}{})
652 | if toolResult.IsError != true {
653 | t.Fatalf("call tool should fail")
654 | return
655 | }
656 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod log, missing argument name" {
657 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
658 | return
659 | }
660 | })
661 | t.Run("pods_log with not found name returns error", func(t *testing.T) {
662 | toolResult, _ := c.callTool("pods_log", map[string]interface{}{"name": "not-found"})
663 | if toolResult.IsError != true {
664 | t.Fatalf("call tool should fail")
665 | return
666 | }
667 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod not-found log in namespace : pods \"not-found\" not found" {
668 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
669 | return
670 | }
671 | })
672 | podsLogNilNamespace, err := c.callTool("pods_log", map[string]interface{}{
673 | "name": "a-pod-in-default",
674 | })
675 | t.Run("pods_log with name and nil namespace returns pod log", func(t *testing.T) {
676 | if err != nil {
677 | t.Fatalf("call tool failed %v", err)
678 | return
679 | }
680 | if podsLogNilNamespace.IsError {
681 | t.Fatalf("call tool failed")
682 | return
683 | }
684 | })
685 | podsLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
686 | "namespace": "ns-1",
687 | "name": "a-pod-in-ns-1",
688 | })
689 | t.Run("pods_log with name and namespace returns pod log", func(t *testing.T) {
690 | if err != nil {
691 | t.Fatalf("call tool failed %v", err)
692 | return
693 | }
694 | if podsLogInNamespace.IsError {
695 | t.Fatalf("call tool failed")
696 | return
697 | }
698 | })
699 | podsContainerLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
700 | "namespace": "ns-1",
701 | "name": "a-pod-in-ns-1",
702 | "container": "nginx",
703 | })
704 | t.Run("pods_log with name, container and namespace returns pod log", func(t *testing.T) {
705 | if err != nil {
706 | t.Fatalf("call tool failed %v", err)
707 | return
708 | }
709 | if podsContainerLogInNamespace.IsError {
710 | t.Fatalf("call tool failed")
711 | return
712 | }
713 | })
714 | toolResult, err := c.callTool("pods_log", map[string]interface{}{
715 | "namespace": "ns-1",
716 | "name": "a-pod-in-ns-1",
717 | "container": "a-not-existing-container",
718 | })
719 | t.Run("pods_log with non existing container returns error", func(t *testing.T) {
720 | if toolResult.IsError != true {
721 | t.Fatalf("call tool should fail")
722 | return
723 | }
724 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to get pod a-pod-in-ns-1 log in namespace ns-1: container a-not-existing-container is not valid for pod a-pod-in-ns-1" {
725 | t.Fatalf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
726 | return
727 | }
728 | })
729 | podsPreviousLogInNamespace, err := c.callTool("pods_log", map[string]interface{}{
730 | "namespace": "ns-1",
731 | "name": "a-pod-in-ns-1",
732 | "previous": true,
733 | })
734 | t.Run("pods_log with previous=true returns previous pod log", func(t *testing.T) {
735 | if err != nil {
736 | t.Fatalf("call tool failed %v", err)
737 | return
738 | }
739 | if podsPreviousLogInNamespace.IsError {
740 | t.Fatalf("call tool failed")
741 | return
742 | }
743 | })
744 | podsPreviousLogFalse, err := c.callTool("pods_log", map[string]interface{}{
745 | "namespace": "ns-1",
746 | "name": "a-pod-in-ns-1",
747 | "previous": false,
748 | })
749 | t.Run("pods_log with previous=false returns current pod log", func(t *testing.T) {
750 | if err != nil {
751 | t.Fatalf("call tool failed %v", err)
752 | return
753 | }
754 | if podsPreviousLogFalse.IsError {
755 | t.Fatalf("call tool failed")
756 | return
757 | }
758 | })
759 |
760 | // Test with tail parameter
761 | podsTailLines, err := c.callTool("pods_log", map[string]interface{}{
762 | "namespace": "ns-1",
763 | "name": "a-pod-in-ns-1",
764 | "tail": 50,
765 | })
766 | t.Run("pods_log with tail=50 returns pod log", func(t *testing.T) {
767 | if err != nil {
768 | t.Fatalf("call tool failed %v", err)
769 | return
770 | }
771 | if podsTailLines.IsError {
772 | t.Fatalf("call tool failed")
773 | return
774 | }
775 | })
776 |
777 | // Test with invalid tail parameter
778 | podsInvalidTailLines, _ := c.callTool("pods_log", map[string]interface{}{
779 | "namespace": "ns-1",
780 | "name": "a-pod-in-ns-1",
781 | "tail": "invalid",
782 | })
783 | t.Run("pods_log with invalid tail returns error", func(t *testing.T) {
784 | if !podsInvalidTailLines.IsError {
785 | t.Fatalf("call tool should fail")
786 | return
787 | }
788 | expectedErrorMsg := "failed to parse tail parameter: expected integer"
789 | if errMsg := podsInvalidTailLines.Content[0].(mcp.TextContent).Text; !strings.Contains(errMsg, expectedErrorMsg) {
790 | t.Fatalf("unexpected error message, expected to contain '%s', got '%s'", expectedErrorMsg, errMsg)
791 | return
792 | }
793 | })
794 | })
795 | }
796 |
797 | func TestPodsLogDenied(t *testing.T) {
798 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
799 | denied_resources = [ { version = "v1", kind = "Pod" } ]
800 | `)))
801 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
802 | c.withEnvTest()
803 | podsLog, _ := c.callTool("pods_log", map[string]interface{}{"name": "a-pod-in-default"})
804 | t.Run("pods_log has error", func(t *testing.T) {
805 | if !podsLog.IsError {
806 | t.Fatalf("call tool should fail")
807 | }
808 | })
809 | t.Run("pods_log describes denial", func(t *testing.T) {
810 | expectedMessage := "failed to get pod a-pod-in-default log in namespace : resource not allowed: /v1, Kind=Pod"
811 | if podsLog.Content[0].(mcp.TextContent).Text != expectedMessage {
812 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsLog.Content[0].(mcp.TextContent).Text)
813 | }
814 | })
815 | })
816 | }
817 |
818 | func TestPodsRun(t *testing.T) {
819 | testCase(t, func(c *mcpContext) {
820 | c.withEnvTest()
821 | t.Run("pods_run with nil image returns error", func(t *testing.T) {
822 | toolResult, _ := c.callTool("pods_run", map[string]interface{}{})
823 | if toolResult.IsError != true {
824 | t.Errorf("call tool should fail")
825 | return
826 | }
827 | if toolResult.Content[0].(mcp.TextContent).Text != "failed to run pod, missing argument image" {
828 | t.Errorf("invalid error message, got %v", toolResult.Content[0].(mcp.TextContent).Text)
829 | return
830 | }
831 | })
832 | podsRunNilNamespace, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
833 | t.Run("pods_run with image and nil namespace runs pod", func(t *testing.T) {
834 | if err != nil {
835 | t.Errorf("call tool failed %v", err)
836 | return
837 | }
838 | if podsRunNilNamespace.IsError {
839 | t.Errorf("call tool failed")
840 | return
841 | }
842 | })
843 | var decodedNilNamespace []unstructured.Unstructured
844 | err = yaml.Unmarshal([]byte(podsRunNilNamespace.Content[0].(mcp.TextContent).Text), &decodedNilNamespace)
845 | t.Run("pods_run with image and nil namespace has yaml content", func(t *testing.T) {
846 | if err != nil {
847 | t.Errorf("invalid tool result content %v", err)
848 | return
849 | }
850 | })
851 | t.Run("pods_run with image and nil namespace returns 1 item (Pod)", func(t *testing.T) {
852 | if len(decodedNilNamespace) != 1 {
853 | t.Errorf("invalid pods count, expected 1, got %v", len(decodedNilNamespace))
854 | return
855 | }
856 | if decodedNilNamespace[0].GetKind() != "Pod" {
857 | t.Errorf("invalid pod kind, expected Pod, got %v", decodedNilNamespace[0].GetKind())
858 | return
859 | }
860 | })
861 | t.Run("pods_run with image and nil namespace returns pod in default", func(t *testing.T) {
862 | if decodedNilNamespace[0].GetNamespace() != "default" {
863 | t.Errorf("invalid pod namespace, expected default, got %v", decodedNilNamespace[0].GetNamespace())
864 | return
865 | }
866 | })
867 | t.Run("pods_run with image and nil namespace returns pod with random name", func(t *testing.T) {
868 | if !strings.HasPrefix(decodedNilNamespace[0].GetName(), "kubernetes-mcp-server-run-") {
869 | t.Errorf("invalid pod name, expected random, got %v", decodedNilNamespace[0].GetName())
870 | return
871 | }
872 | })
873 | t.Run("pods_run with image and nil namespace returns pod with labels", func(t *testing.T) {
874 | labels := decodedNilNamespace[0].Object["metadata"].(map[string]interface{})["labels"].(map[string]interface{})
875 | if labels["app.kubernetes.io/name"] == "" {
876 | t.Errorf("invalid labels, expected app.kubernetes.io/name, got %v", labels)
877 | return
878 | }
879 | if labels["app.kubernetes.io/component"] == "" {
880 | t.Errorf("invalid labels, expected app.kubernetes.io/component, got %v", labels)
881 | return
882 | }
883 | if labels["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
884 | t.Errorf("invalid labels, expected app.kubernetes.io/managed-by, got %v", labels)
885 | return
886 | }
887 | if labels["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
888 | t.Errorf("invalid labels, expected app.kubernetes.io/part-of, got %v", labels)
889 | return
890 | }
891 | })
892 | t.Run("pods_run with image and nil namespace returns pod with nginx container", func(t *testing.T) {
893 | containers := decodedNilNamespace[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
894 | if containers[0].(map[string]interface{})["image"] != "nginx" {
895 | t.Errorf("invalid container name, expected nginx, got %v", containers[0].(map[string]interface{})["image"])
896 | return
897 | }
898 | })
899 |
900 | podsRunNamespaceAndPort, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
901 | t.Run("pods_run with image, namespace, and port runs pod", func(t *testing.T) {
902 | if err != nil {
903 | t.Errorf("call tool failed %v", err)
904 | return
905 | }
906 | if podsRunNamespaceAndPort.IsError {
907 | t.Errorf("call tool failed")
908 | return
909 | }
910 | })
911 | var decodedNamespaceAndPort []unstructured.Unstructured
912 | err = yaml.Unmarshal([]byte(podsRunNamespaceAndPort.Content[0].(mcp.TextContent).Text), &decodedNamespaceAndPort)
913 | t.Run("pods_run with image, namespace, and port has yaml content", func(t *testing.T) {
914 | if err != nil {
915 | t.Errorf("invalid tool result content %v", err)
916 | return
917 | }
918 | })
919 | t.Run("pods_run with image, namespace, and port returns 2 items (Pod + Service)", func(t *testing.T) {
920 | if len(decodedNamespaceAndPort) != 2 {
921 | t.Errorf("invalid pods count, expected 2, got %v", len(decodedNamespaceAndPort))
922 | return
923 | }
924 | if decodedNamespaceAndPort[0].GetKind() != "Pod" {
925 | t.Errorf("invalid pod kind, expected Pod, got %v", decodedNamespaceAndPort[0].GetKind())
926 | return
927 | }
928 | if decodedNamespaceAndPort[1].GetKind() != "Service" {
929 | t.Errorf("invalid service kind, expected Service, got %v", decodedNamespaceAndPort[1].GetKind())
930 | return
931 | }
932 | })
933 | t.Run("pods_run with image, namespace, and port returns pod with port", func(t *testing.T) {
934 | containers := decodedNamespaceAndPort[0].Object["spec"].(map[string]interface{})["containers"].([]interface{})
935 | ports := containers[0].(map[string]interface{})["ports"].([]interface{})
936 | if ports[0].(map[string]interface{})["containerPort"] != int64(80) {
937 | t.Errorf("invalid container port, expected 80, got %v", ports[0].(map[string]interface{})["containerPort"])
938 | return
939 | }
940 | })
941 | t.Run("pods_run with image, namespace, and port returns service with port and selector", func(t *testing.T) {
942 | ports := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["ports"].([]interface{})
943 | if ports[0].(map[string]interface{})["port"] != int64(80) {
944 | t.Errorf("invalid service port, expected 80, got %v", ports[0].(map[string]interface{})["port"])
945 | return
946 | }
947 | if ports[0].(map[string]interface{})["targetPort"] != int64(80) {
948 | t.Errorf("invalid service target port, expected 80, got %v", ports[0].(map[string]interface{})["targetPort"])
949 | return
950 | }
951 | selector := decodedNamespaceAndPort[1].Object["spec"].(map[string]interface{})["selector"].(map[string]interface{})
952 | if selector["app.kubernetes.io/name"] == "" {
953 | t.Errorf("invalid service selector, expected app.kubernetes.io/name, got %v", selector)
954 | return
955 | }
956 | if selector["app.kubernetes.io/managed-by"] != "kubernetes-mcp-server" {
957 | t.Errorf("invalid service selector, expected app.kubernetes.io/managed-by, got %v", selector)
958 | return
959 | }
960 | if selector["app.kubernetes.io/part-of"] != "kubernetes-mcp-server-run-sandbox" {
961 | t.Errorf("invalid service selector, expected app.kubernetes.io/part-of, got %v", selector)
962 | return
963 | }
964 | })
965 | })
966 | }
967 |
968 | func TestPodsRunDenied(t *testing.T) {
969 | deniedResourcesServer := test.Must(config.ReadToml([]byte(`
970 | denied_resources = [ { version = "v1", kind = "Pod" } ]
971 | `)))
972 | testCaseWithContext(t, &mcpContext{staticConfig: deniedResourcesServer}, func(c *mcpContext) {
973 | c.withEnvTest()
974 | podsRun, _ := c.callTool("pods_run", map[string]interface{}{"image": "nginx"})
975 | t.Run("pods_run has error", func(t *testing.T) {
976 | if !podsRun.IsError {
977 | t.Fatalf("call tool should fail")
978 | }
979 | })
980 | t.Run("pods_run describes denial", func(t *testing.T) {
981 | expectedMessage := "failed to run pod in namespace : resource not allowed: /v1, Kind=Pod"
982 | if podsRun.Content[0].(mcp.TextContent).Text != expectedMessage {
983 | t.Fatalf("expected descriptive error '%s', got %v", expectedMessage, podsRun.Content[0].(mcp.TextContent).Text)
984 | }
985 | })
986 | })
987 | }
988 |
989 | func TestPodsRunInOpenShift(t *testing.T) {
990 | testCaseWithContext(t, &mcpContext{before: inOpenShift, after: inOpenShiftClear}, func(c *mcpContext) {
991 | t.Run("pods_run with image, namespace, and port returns route with port", func(t *testing.T) {
992 | podsRunInOpenShift, err := c.callTool("pods_run", map[string]interface{}{"image": "nginx", "port": 80})
993 | if err != nil {
994 | t.Errorf("call tool failed %v", err)
995 | return
996 | }
997 | if podsRunInOpenShift.IsError {
998 | t.Errorf("call tool failed")
999 | return
1000 | }
1001 | var decodedPodServiceRoute []unstructured.Unstructured
1002 | err = yaml.Unmarshal([]byte(podsRunInOpenShift.Content[0].(mcp.TextContent).Text), &decodedPodServiceRoute)
1003 | if err != nil {
1004 | t.Errorf("invalid tool result content %v", err)
1005 | return
1006 | }
1007 | if len(decodedPodServiceRoute) != 3 {
1008 | t.Errorf("invalid pods count, expected 3, got %v", len(decodedPodServiceRoute))
1009 | return
1010 | }
1011 | if decodedPodServiceRoute[2].GetKind() != "Route" {
1012 | t.Errorf("invalid route kind, expected Route, got %v", decodedPodServiceRoute[2].GetKind())
1013 | return
1014 | }
1015 | targetPort := decodedPodServiceRoute[2].Object["spec"].(map[string]interface{})["port"].(map[string]interface{})["targetPort"].(int64)
1016 | if targetPort != 80 {
1017 | t.Errorf("invalid route target port, expected 80, got %v", targetPort)
1018 | return
1019 | }
1020 | })
1021 | })
1022 | }
1023 |
1024 | func TestPodsListWithLabelSelector(t *testing.T) {
1025 | testCase(t, func(c *mcpContext) {
1026 | c.withEnvTest()
1027 | kc := c.newKubernetesClient()
1028 | // Create pods with labels
1029 | _, _ = kc.CoreV1().Pods("default").Create(c.ctx, &corev1.Pod{
1030 | ObjectMeta: metav1.ObjectMeta{
1031 | Name: "pod-with-labels",
1032 | Labels: map[string]string{"app": "test", "env": "dev"},
1033 | },
1034 | Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
1035 | }, metav1.CreateOptions{})
1036 | _, _ = kc.CoreV1().Pods("ns-1").Create(c.ctx, &corev1.Pod{
1037 | ObjectMeta: metav1.ObjectMeta{
1038 | Name: "another-pod-with-labels",
1039 | Labels: map[string]string{"app": "test", "env": "prod"},
1040 | },
1041 | Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "nginx", Image: "nginx"}}},
1042 | }, metav1.CreateOptions{})
1043 |
1044 | // Test pods_list with label selector
1045 | t.Run("pods_list with label selector returns filtered pods", func(t *testing.T) {
1046 | toolResult, err := c.callTool("pods_list", map[string]interface{}{
1047 | "labelSelector": "app=test",
1048 | })
1049 | if err != nil {
1050 | t.Fatalf("call tool failed %v", err)
1051 | return
1052 | }
1053 | if toolResult.IsError {
1054 | t.Fatalf("call tool failed")
1055 | return
1056 | }
1057 | var decoded []unstructured.Unstructured
1058 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
1059 | if err != nil {
1060 | t.Fatalf("invalid tool result content %v", err)
1061 | return
1062 | }
1063 | if len(decoded) != 2 {
1064 | t.Fatalf("invalid pods count, expected 2, got %v", len(decoded))
1065 | return
1066 | }
1067 | })
1068 |
1069 | // Test pods_list_in_namespace with label selector
1070 | t.Run("pods_list_in_namespace with label selector returns filtered pods", func(t *testing.T) {
1071 | toolResult, err := c.callTool("pods_list_in_namespace", map[string]interface{}{
1072 | "namespace": "ns-1",
1073 | "labelSelector": "env=prod",
1074 | })
1075 | if err != nil {
1076 | t.Fatalf("call tool failed %v", err)
1077 | return
1078 | }
1079 | if toolResult.IsError {
1080 | t.Fatalf("call tool failed")
1081 | return
1082 | }
1083 | var decoded []unstructured.Unstructured
1084 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
1085 | if err != nil {
1086 | t.Fatalf("invalid tool result content %v", err)
1087 | return
1088 | }
1089 | if len(decoded) != 1 {
1090 | t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
1091 | return
1092 | }
1093 | if decoded[0].GetName() != "another-pod-with-labels" {
1094 | t.Fatalf("invalid pod name, expected another-pod-with-labels, got %v", decoded[0].GetName())
1095 | return
1096 | }
1097 | })
1098 |
1099 | // Test multiple label selectors
1100 | t.Run("pods_list with multiple label selectors returns filtered pods", func(t *testing.T) {
1101 | toolResult, err := c.callTool("pods_list", map[string]interface{}{
1102 | "labelSelector": "app=test,env=prod",
1103 | })
1104 | if err != nil {
1105 | t.Fatalf("call tool failed %v", err)
1106 | return
1107 | }
1108 | if toolResult.IsError {
1109 | t.Fatalf("call tool failed")
1110 | return
1111 | }
1112 | var decoded []unstructured.Unstructured
1113 | err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
1114 | if err != nil {
1115 | t.Fatalf("invalid tool result content %v", err)
1116 | return
1117 | }
1118 | if len(decoded) != 1 {
1119 | t.Fatalf("invalid pods count, expected 1, got %v", len(decoded))
1120 | return
1121 | }
1122 | if decoded[0].GetName() != "another-pod-with-labels" {
1123 | t.Fatalf("invalid pod name, expected another-pod-with-labels, got %v", decoded[0].GetName())
1124 | return
1125 | }
1126 | })
1127 | })
1128 | }
1129 |
```
--------------------------------------------------------------------------------
/pkg/http/http_test.go:
--------------------------------------------------------------------------------
```go
1 | package http
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "crypto/rand"
8 | "crypto/rsa"
9 | "flag"
10 | "fmt"
11 | "io"
12 | "net"
13 | "net/http"
14 | "net/http/httptest"
15 | "os"
16 | "regexp"
17 | "strconv"
18 | "strings"
19 | "testing"
20 | "time"
21 |
22 | "github.com/containers/kubernetes-mcp-server/internal/test"
23 | "github.com/coreos/go-oidc/v3/oidc"
24 | "github.com/coreos/go-oidc/v3/oidc/oidctest"
25 | "golang.org/x/sync/errgroup"
26 | "k8s.io/klog/v2"
27 | "k8s.io/klog/v2/textlogger"
28 |
29 | "github.com/containers/kubernetes-mcp-server/pkg/config"
30 | "github.com/containers/kubernetes-mcp-server/pkg/mcp"
31 | )
32 |
33 | type httpContext struct {
34 | klogState klog.State
35 | mockServer *test.MockServer
36 | LogBuffer bytes.Buffer
37 | HttpAddress string // HTTP server address
38 | timeoutCancel context.CancelFunc // Release resources if test completes before the timeout
39 | StopServer context.CancelFunc
40 | WaitForShutdown func() error
41 | StaticConfig *config.StaticConfig
42 | OidcProvider *oidc.Provider
43 | }
44 |
45 | const tokenReviewSuccessful = `
46 | {
47 | "kind": "TokenReview",
48 | "apiVersion": "authentication.k8s.io/v1",
49 | "spec": {"token": "valid-token"},
50 | "status": {
51 | "authenticated": true,
52 | "user": {
53 | "username": "test-user",
54 | "groups": ["system:authenticated"]
55 | }
56 | }
57 | }`
58 |
59 | func (c *httpContext) beforeEach(t *testing.T) {
60 | t.Helper()
61 | http.DefaultClient.Timeout = 10 * time.Second
62 | if c.StaticConfig == nil {
63 | c.StaticConfig = config.Default()
64 | }
65 | c.mockServer = test.NewMockServer()
66 | // Fake Kubernetes configuration
67 | c.StaticConfig.KubeConfig = c.mockServer.KubeconfigFile(t)
68 | // Capture logging
69 | c.klogState = klog.CaptureState()
70 | flags := flag.NewFlagSet("test", flag.ContinueOnError)
71 | klog.InitFlags(flags)
72 | _ = flags.Set("v", "5")
73 | klog.SetLogger(textlogger.NewLogger(textlogger.NewConfig(textlogger.Verbosity(5), textlogger.Output(&c.LogBuffer))))
74 | // Start server in random port
75 | ln, err := net.Listen("tcp", "0.0.0.0:0")
76 | if err != nil {
77 | t.Fatalf("Failed to find random port for HTTP server: %v", err)
78 | }
79 | c.HttpAddress = ln.Addr().String()
80 | if randomPortErr := ln.Close(); randomPortErr != nil {
81 | t.Fatalf("Failed to close random port listener: %v", randomPortErr)
82 | }
83 | c.StaticConfig.Port = fmt.Sprintf("%d", ln.Addr().(*net.TCPAddr).Port)
84 | mcpServer, err := mcp.NewServer(mcp.Configuration{StaticConfig: c.StaticConfig})
85 | if err != nil {
86 | t.Fatalf("Failed to create MCP server: %v", err)
87 | }
88 | var timeoutCtx, cancelCtx context.Context
89 | timeoutCtx, c.timeoutCancel = context.WithTimeout(t.Context(), 10*time.Second)
90 | group, gc := errgroup.WithContext(timeoutCtx)
91 | cancelCtx, c.StopServer = context.WithCancel(gc)
92 | group.Go(func() error { return Serve(cancelCtx, mcpServer, c.StaticConfig, c.OidcProvider, nil) })
93 | c.WaitForShutdown = group.Wait
94 | // Wait for HTTP server to start (using net)
95 | for i := 0; i < 10; i++ {
96 | conn, err := net.Dial("tcp", c.HttpAddress)
97 | if err == nil {
98 | _ = conn.Close()
99 | break
100 | }
101 | time.Sleep(50 * time.Millisecond) // Wait before retrying
102 | }
103 | }
104 |
105 | func (c *httpContext) afterEach(t *testing.T) {
106 | t.Helper()
107 | c.mockServer.Close()
108 | c.StopServer()
109 | err := c.WaitForShutdown()
110 | if err != nil {
111 | t.Errorf("HTTP server did not shut down gracefully: %v", err)
112 | }
113 | c.timeoutCancel()
114 | c.klogState.Restore()
115 | _ = os.Setenv("KUBECONFIG", "")
116 | }
117 |
118 | func testCase(t *testing.T, test func(c *httpContext)) {
119 | testCaseWithContext(t, &httpContext{}, test)
120 | }
121 |
122 | func testCaseWithContext(t *testing.T, httpCtx *httpContext, test func(c *httpContext)) {
123 | httpCtx.beforeEach(t)
124 | t.Cleanup(func() { httpCtx.afterEach(t) })
125 | test(httpCtx)
126 | }
127 |
128 | type OidcTestServer struct {
129 | *rsa.PrivateKey
130 | *oidc.Provider
131 | *httptest.Server
132 | TokenEndpointHandler http.HandlerFunc
133 | }
134 |
135 | func NewOidcTestServer(t *testing.T) (oidcTestServer *OidcTestServer) {
136 | t.Helper()
137 | var err error
138 | oidcTestServer = &OidcTestServer{}
139 | oidcTestServer.PrivateKey, err = rsa.GenerateKey(rand.Reader, 2048)
140 | if err != nil {
141 | t.Fatalf("failed to generate private key for oidc: %v", err)
142 | }
143 | oidcServer := &oidctest.Server{
144 | Algorithms: []string{oidc.RS256, oidc.ES256},
145 | PublicKeys: []oidctest.PublicKey{
146 | {
147 | PublicKey: oidcTestServer.Public(),
148 | KeyID: "test-oidc-key-id",
149 | Algorithm: oidc.RS256,
150 | },
151 | },
152 | }
153 | oidcTestServer.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
154 | if r.URL.Path == "/token" && oidcTestServer.TokenEndpointHandler != nil {
155 | oidcTestServer.TokenEndpointHandler.ServeHTTP(w, r)
156 | return
157 | }
158 | oidcServer.ServeHTTP(w, r)
159 | }))
160 | oidcServer.SetIssuer(oidcTestServer.URL)
161 | oidcTestServer.Provider, err = oidc.NewProvider(t.Context(), oidcTestServer.URL)
162 | if err != nil {
163 | t.Fatalf("failed to create OIDC provider: %v", err)
164 | }
165 | return
166 | }
167 |
168 | func TestGracefulShutdown(t *testing.T) {
169 | testCase(t, func(ctx *httpContext) {
170 | ctx.StopServer()
171 | err := ctx.WaitForShutdown()
172 | t.Run("Stops gracefully", func(t *testing.T) {
173 | if err != nil {
174 | t.Errorf("Expected graceful shutdown, but got error: %v", err)
175 | }
176 | })
177 | t.Run("Stops on context cancel", func(t *testing.T) {
178 | if !strings.Contains(ctx.LogBuffer.String(), "Context cancelled, initiating graceful shutdown") {
179 | t.Errorf("Context cancelled, initiating graceful shutdown, got: %s", ctx.LogBuffer.String())
180 | }
181 | })
182 | t.Run("Starts server shutdown", func(t *testing.T) {
183 | if !strings.Contains(ctx.LogBuffer.String(), "Shutting down HTTP server gracefully") {
184 | t.Errorf("Expected graceful shutdown log, got: %s", ctx.LogBuffer.String())
185 | }
186 | })
187 | t.Run("Server shutdown completes", func(t *testing.T) {
188 | if !strings.Contains(ctx.LogBuffer.String(), "HTTP server shutdown complete") {
189 | t.Errorf("Expected HTTP server shutdown completed log, got: %s", ctx.LogBuffer.String())
190 | }
191 | })
192 | })
193 | }
194 |
195 | func TestSseTransport(t *testing.T) {
196 | testCase(t, func(ctx *httpContext) {
197 | sseResp, sseErr := http.Get(fmt.Sprintf("http://%s/sse", ctx.HttpAddress))
198 | t.Cleanup(func() { _ = sseResp.Body.Close() })
199 | t.Run("Exposes SSE endpoint at /sse", func(t *testing.T) {
200 | if sseErr != nil {
201 | t.Fatalf("Failed to get SSE endpoint: %v", sseErr)
202 | }
203 | if sseResp.StatusCode != http.StatusOK {
204 | t.Errorf("Expected HTTP 200 OK, got %d", sseResp.StatusCode)
205 | }
206 | })
207 | t.Run("SSE endpoint returns text/event-stream content type", func(t *testing.T) {
208 | if sseResp.Header.Get("Content-Type") != "text/event-stream" {
209 | t.Errorf("Expected Content-Type text/event-stream, got %s", sseResp.Header.Get("Content-Type"))
210 | }
211 | })
212 | responseReader := bufio.NewReader(sseResp.Body)
213 | event, eventErr := responseReader.ReadString('\n')
214 | endpoint, endpointErr := responseReader.ReadString('\n')
215 | t.Run("SSE endpoint returns stream with messages endpoint", func(t *testing.T) {
216 | if eventErr != nil {
217 | t.Fatalf("Failed to read SSE response body (event): %v", eventErr)
218 | }
219 | if event != "event: endpoint\n" {
220 | t.Errorf("Expected SSE event 'endpoint', got %s", event)
221 | }
222 | if endpointErr != nil {
223 | t.Fatalf("Failed to read SSE response body (endpoint): %v", endpointErr)
224 | }
225 | if !strings.HasPrefix(endpoint, "data: /message?sessionId=") {
226 | t.Errorf("Expected SSE data: '/message', got %s", endpoint)
227 | }
228 | })
229 | messageResp, messageErr := http.Post(
230 | fmt.Sprintf("http://%s/message?sessionId=%s", ctx.HttpAddress, strings.TrimSpace(endpoint[25:])),
231 | "application/json",
232 | bytes.NewBufferString("{}"),
233 | )
234 | t.Cleanup(func() { _ = messageResp.Body.Close() })
235 | t.Run("Exposes message endpoint at /message", func(t *testing.T) {
236 | if messageErr != nil {
237 | t.Fatalf("Failed to get message endpoint: %v", messageErr)
238 | }
239 | if messageResp.StatusCode != http.StatusAccepted {
240 | t.Errorf("Expected HTTP 202 OK, got %d", messageResp.StatusCode)
241 | }
242 | })
243 | })
244 | }
245 |
246 | func TestStreamableHttpTransport(t *testing.T) {
247 | testCase(t, func(ctx *httpContext) {
248 | mcpGetResp, mcpGetErr := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
249 | t.Cleanup(func() { _ = mcpGetResp.Body.Close() })
250 | t.Run("Exposes MCP GET endpoint at /mcp", func(t *testing.T) {
251 | if mcpGetErr != nil {
252 | t.Fatalf("Failed to get MCP endpoint: %v", mcpGetErr)
253 | }
254 | if mcpGetResp.StatusCode != http.StatusOK {
255 | t.Errorf("Expected HTTP 200 OK, got %d", mcpGetResp.StatusCode)
256 | }
257 | })
258 | t.Run("MCP GET endpoint returns text/event-stream content type", func(t *testing.T) {
259 | if mcpGetResp.Header.Get("Content-Type") != "text/event-stream" {
260 | t.Errorf("Expected Content-Type text/event-stream (GET), got %s", mcpGetResp.Header.Get("Content-Type"))
261 | }
262 | })
263 | mcpPostResp, mcpPostErr := http.Post(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), "application/json", bytes.NewBufferString("{}"))
264 | t.Cleanup(func() { _ = mcpPostResp.Body.Close() })
265 | t.Run("Exposes MCP POST endpoint at /mcp", func(t *testing.T) {
266 | if mcpPostErr != nil {
267 | t.Fatalf("Failed to post to MCP endpoint: %v", mcpPostErr)
268 | }
269 | if mcpPostResp.StatusCode != http.StatusOK {
270 | t.Errorf("Expected HTTP 200 OK, got %d", mcpPostResp.StatusCode)
271 | }
272 | })
273 | t.Run("MCP POST endpoint returns application/json content type", func(t *testing.T) {
274 | if mcpPostResp.Header.Get("Content-Type") != "application/json" {
275 | t.Errorf("Expected Content-Type application/json (POST), got %s", mcpPostResp.Header.Get("Content-Type"))
276 | }
277 | })
278 | })
279 | }
280 |
281 | func TestHealthCheck(t *testing.T) {
282 | testCase(t, func(ctx *httpContext) {
283 | t.Run("Exposes health check endpoint at /healthz", func(t *testing.T) {
284 | resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress))
285 | if err != nil {
286 | t.Fatalf("Failed to get health check endpoint: %v", err)
287 | }
288 | t.Cleanup(func() { _ = resp.Body.Close })
289 | if resp.StatusCode != http.StatusOK {
290 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
291 | }
292 | })
293 | })
294 | // Health exposed even when require Authorization
295 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
296 | resp, err := http.Get(fmt.Sprintf("http://%s/healthz", ctx.HttpAddress))
297 | if err != nil {
298 | t.Fatalf("Failed to get health check endpoint with OAuth: %v", err)
299 | }
300 | t.Cleanup(func() { _ = resp.Body.Close() })
301 | t.Run("Health check with OAuth returns HTTP 200 OK", func(t *testing.T) {
302 | if resp.StatusCode != http.StatusOK {
303 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
304 | }
305 | })
306 | })
307 | }
308 |
309 | func TestWellKnownReverseProxy(t *testing.T) {
310 | cases := []string{
311 | ".well-known/oauth-authorization-server",
312 | ".well-known/oauth-protected-resource",
313 | ".well-known/openid-configuration",
314 | }
315 | // With No Authorization URL configured
316 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
317 | for _, path := range cases {
318 | resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
319 | t.Cleanup(func() { _ = resp.Body.Close() })
320 | t.Run("Protected resource '"+path+"' without Authorization URL returns 404 - Not Found", func(t *testing.T) {
321 | if err != nil {
322 | t.Fatalf("Failed to get %s endpoint: %v", path, err)
323 | }
324 | if resp.StatusCode != http.StatusNotFound {
325 | t.Errorf("Expected HTTP 404 Not Found, got %d", resp.StatusCode)
326 | }
327 | })
328 | }
329 | })
330 | // With Authorization URL configured but invalid payload
331 | invalidPayloadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
332 | w.Header().Set("Content-Type", "application/json")
333 | _, _ = w.Write([]byte(`NOT A JSON PAYLOAD`))
334 | }))
335 | t.Cleanup(invalidPayloadServer.Close)
336 | invalidPayloadConfig := &config.StaticConfig{
337 | AuthorizationURL: invalidPayloadServer.URL,
338 | RequireOAuth: true,
339 | ValidateToken: true,
340 | ClusterProviderStrategy: config.ClusterProviderKubeConfig,
341 | }
342 | testCaseWithContext(t, &httpContext{StaticConfig: invalidPayloadConfig}, func(ctx *httpContext) {
343 | for _, path := range cases {
344 | resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
345 | t.Cleanup(func() { _ = resp.Body.Close() })
346 | t.Run("Protected resource '"+path+"' with invalid Authorization URL payload returns 500 - Internal Server Error", func(t *testing.T) {
347 | if err != nil {
348 | t.Fatalf("Failed to get %s endpoint: %v", path, err)
349 | }
350 | if resp.StatusCode != http.StatusInternalServerError {
351 | t.Errorf("Expected HTTP 500 Internal Server Error, got %d", resp.StatusCode)
352 | }
353 | })
354 | }
355 | })
356 | // With Authorization URL configured and valid payload
357 | testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
358 | if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
359 | http.NotFound(w, r)
360 | return
361 | }
362 | w.Header().Set("Content-Type", "application/json")
363 | _, _ = w.Write([]byte(`{"issuer": "https://example.com","scopes_supported":["mcp-server"]}`))
364 | }))
365 | t.Cleanup(testServer.Close)
366 | staticConfig := &config.StaticConfig{
367 | AuthorizationURL: testServer.URL,
368 | RequireOAuth: true,
369 | ValidateToken: true,
370 | ClusterProviderStrategy: config.ClusterProviderKubeConfig,
371 | }
372 | testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) {
373 | for _, path := range cases {
374 | resp, err := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
375 | t.Cleanup(func() { _ = resp.Body.Close() })
376 | t.Run("Exposes "+path+" endpoint", func(t *testing.T) {
377 | if err != nil {
378 | t.Fatalf("Failed to get %s endpoint: %v", path, err)
379 | }
380 | if resp.StatusCode != http.StatusOK {
381 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
382 | }
383 | })
384 | t.Run(path+" returns application/json content type", func(t *testing.T) {
385 | if resp.Header.Get("Content-Type") != "application/json" {
386 | t.Errorf("Expected Content-Type application/json, got %s", resp.Header.Get("Content-Type"))
387 | }
388 | })
389 | }
390 | })
391 | }
392 |
393 | func TestWellKnownHeaderPropagation(t *testing.T) {
394 | cases := []string{
395 | ".well-known/oauth-authorization-server",
396 | ".well-known/oauth-protected-resource",
397 | ".well-known/openid-configuration",
398 | }
399 | var receivedRequestHeaders http.Header
400 | testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
401 | if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
402 | http.NotFound(w, r)
403 | return
404 | }
405 | // Capture headers received from the proxy
406 | receivedRequestHeaders = r.Header.Clone()
407 | // Set response headers that should be propagated back
408 | w.Header().Set("Content-Type", "application/json")
409 | w.Header().Set("Access-Control-Allow-Origin", "https://example.com")
410 | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
411 | w.Header().Set("Cache-Control", "no-cache")
412 | w.Header().Set("X-Custom-Backend-Header", "backend-value")
413 | _, _ = w.Write([]byte(`{"issuer": "https://example.com"}`))
414 | }))
415 | t.Cleanup(testServer.Close)
416 | staticConfig := &config.StaticConfig{
417 | AuthorizationURL: testServer.URL,
418 | RequireOAuth: true,
419 | ValidateToken: true,
420 | ClusterProviderStrategy: config.ClusterProviderKubeConfig,
421 | }
422 | testCaseWithContext(t, &httpContext{StaticConfig: staticConfig}, func(ctx *httpContext) {
423 | for _, path := range cases {
424 | receivedRequestHeaders = nil
425 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path), nil)
426 | if err != nil {
427 | t.Fatalf("Failed to create request: %v", err)
428 | }
429 | // Add various headers to test propagation
430 | req.Header.Set("Origin", "https://example.com")
431 | req.Header.Set("User-Agent", "Test-Agent/1.0")
432 | req.Header.Set("Accept", "application/json")
433 | req.Header.Set("Accept-Language", "en-US")
434 | req.Header.Set("X-Custom-Header", "custom-value")
435 | req.Header.Set("Referer", "https://example.com/page")
436 |
437 | resp, err := http.DefaultClient.Do(req)
438 | if err != nil {
439 | t.Fatalf("Failed to get %s endpoint: %v", path, err)
440 | }
441 | t.Cleanup(func() { _ = resp.Body.Close() })
442 |
443 | t.Run("Well-known proxy propagates Origin header to backend for "+path, func(t *testing.T) {
444 | if receivedRequestHeaders == nil {
445 | t.Fatal("Backend did not receive any headers")
446 | }
447 | if receivedRequestHeaders.Get("Origin") != "https://example.com" {
448 | t.Errorf("Expected Origin header 'https://example.com', got '%s'", receivedRequestHeaders.Get("Origin"))
449 | }
450 | })
451 |
452 | t.Run("Well-known proxy propagates User-Agent header to backend for "+path, func(t *testing.T) {
453 | if receivedRequestHeaders.Get("User-Agent") != "Test-Agent/1.0" {
454 | t.Errorf("Expected User-Agent header 'Test-Agent/1.0', got '%s'", receivedRequestHeaders.Get("User-Agent"))
455 | }
456 | })
457 |
458 | t.Run("Well-known proxy propagates Accept header to backend for "+path, func(t *testing.T) {
459 | if receivedRequestHeaders.Get("Accept") != "application/json" {
460 | t.Errorf("Expected Accept header 'application/json', got '%s'", receivedRequestHeaders.Get("Accept"))
461 | }
462 | })
463 |
464 | t.Run("Well-known proxy propagates Accept-Language header to backend for "+path, func(t *testing.T) {
465 | if receivedRequestHeaders.Get("Accept-Language") != "en-US" {
466 | t.Errorf("Expected Accept-Language header 'en-US', got '%s'", receivedRequestHeaders.Get("Accept-Language"))
467 | }
468 | })
469 |
470 | t.Run("Well-known proxy propagates custom headers to backend for "+path, func(t *testing.T) {
471 | if receivedRequestHeaders.Get("X-Custom-Header") != "custom-value" {
472 | t.Errorf("Expected X-Custom-Header 'custom-value', got '%s'", receivedRequestHeaders.Get("X-Custom-Header"))
473 | }
474 | })
475 |
476 | t.Run("Well-known proxy propagates Referer header to backend for "+path, func(t *testing.T) {
477 | if receivedRequestHeaders.Get("Referer") != "https://example.com/page" {
478 | t.Errorf("Expected Referer header 'https://example.com/page', got '%s'", receivedRequestHeaders.Get("Referer"))
479 | }
480 | })
481 |
482 | t.Run("Well-known proxy returns Access-Control-Allow-Origin from backend for "+path, func(t *testing.T) {
483 | if resp.Header.Get("Access-Control-Allow-Origin") != "https://example.com" {
484 | t.Errorf("Expected Access-Control-Allow-Origin header 'https://example.com', got '%s'", resp.Header.Get("Access-Control-Allow-Origin"))
485 | }
486 | })
487 |
488 | t.Run("Well-known proxy returns Access-Control-Allow-Methods from backend for "+path, func(t *testing.T) {
489 | if resp.Header.Get("Access-Control-Allow-Methods") != "GET, POST, OPTIONS" {
490 | t.Errorf("Expected Access-Control-Allow-Methods header 'GET, POST, OPTIONS', got '%s'", resp.Header.Get("Access-Control-Allow-Methods"))
491 | }
492 | })
493 |
494 | t.Run("Well-known proxy returns Cache-Control from backend for "+path, func(t *testing.T) {
495 | if resp.Header.Get("Cache-Control") != "no-cache" {
496 | t.Errorf("Expected Cache-Control header 'no-cache', got '%s'", resp.Header.Get("Cache-Control"))
497 | }
498 | })
499 |
500 | t.Run("Well-known proxy returns custom response headers from backend for "+path, func(t *testing.T) {
501 | if resp.Header.Get("X-Custom-Backend-Header") != "backend-value" {
502 | t.Errorf("Expected X-Custom-Backend-Header 'backend-value', got '%s'", resp.Header.Get("X-Custom-Backend-Header"))
503 | }
504 | })
505 | }
506 | })
507 | }
508 |
509 | func TestWellKnownOverrides(t *testing.T) {
510 | cases := []string{
511 | ".well-known/oauth-authorization-server",
512 | ".well-known/oauth-protected-resource",
513 | ".well-known/openid-configuration",
514 | }
515 | testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
516 | if !strings.HasPrefix(r.URL.EscapedPath(), "/.well-known/") {
517 | http.NotFound(w, r)
518 | return
519 | }
520 | w.Header().Set("Content-Type", "application/json")
521 | _, _ = w.Write([]byte(`
522 | {
523 | "issuer": "https://localhost",
524 | "registration_endpoint": "https://localhost/clients-registrations/openid-connect",
525 | "require_request_uri_registration": true,
526 | "scopes_supported":["scope-1", "scope-2"]
527 | }`))
528 | }))
529 | t.Cleanup(testServer.Close)
530 | baseConfig := config.StaticConfig{
531 | AuthorizationURL: testServer.URL,
532 | RequireOAuth: true,
533 | ValidateToken: true,
534 | ClusterProviderStrategy: config.ClusterProviderKubeConfig,
535 | }
536 | // With Dynamic Client Registration disabled
537 | disableDynamicRegistrationConfig := baseConfig
538 | disableDynamicRegistrationConfig.DisableDynamicClientRegistration = true
539 | testCaseWithContext(t, &httpContext{StaticConfig: &disableDynamicRegistrationConfig}, func(ctx *httpContext) {
540 | for _, path := range cases {
541 | resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
542 | t.Cleanup(func() { _ = resp.Body.Close() })
543 | body, err := io.ReadAll(resp.Body)
544 | if err != nil {
545 | t.Fatalf("Failed to read response body: %v", err)
546 | }
547 | t.Run("DisableDynamicClientRegistration removes registration_endpoint field", func(t *testing.T) {
548 | if strings.Contains(string(body), "registration_endpoint") {
549 | t.Error("Expected registration_endpoint to be removed, but it was found in the response")
550 | }
551 | })
552 | t.Run("DisableDynamicClientRegistration sets require_request_uri_registration = false", func(t *testing.T) {
553 | if !strings.Contains(string(body), `"require_request_uri_registration":false`) {
554 | t.Error("Expected require_request_uri_registration to be false, but it was not found in the response")
555 | }
556 | })
557 | t.Run("DisableDynamicClientRegistration includes/preserves scopes_supported", func(t *testing.T) {
558 | if !strings.Contains(string(body), `"scopes_supported":["scope-1","scope-2"]`) {
559 | t.Error("Expected scopes_supported to be present, but it was not found in the response")
560 | }
561 | })
562 | }
563 | })
564 | // With overrides for OAuth scopes (client/frontend)
565 | oAuthScopesConfig := baseConfig
566 | oAuthScopesConfig.OAuthScopes = []string{"openid", "mcp-server"}
567 | testCaseWithContext(t, &httpContext{StaticConfig: &oAuthScopesConfig}, func(ctx *httpContext) {
568 | for _, path := range cases {
569 | resp, _ := http.Get(fmt.Sprintf("http://%s/%s", ctx.HttpAddress, path))
570 | t.Cleanup(func() { _ = resp.Body.Close() })
571 | body, err := io.ReadAll(resp.Body)
572 | if err != nil {
573 | t.Fatalf("Failed to read response body: %v", err)
574 | }
575 | t.Run("OAuthScopes overrides scopes_supported", func(t *testing.T) {
576 | if !strings.Contains(string(body), `"scopes_supported":["openid","mcp-server"]`) {
577 | t.Errorf("Expected scopes_supported to be overridden, but original was preserved, response: %s", string(body))
578 | }
579 | })
580 | t.Run("OAuthScopes preserves other fields", func(t *testing.T) {
581 | if !strings.Contains(string(body), `"issuer":"https://localhost"`) {
582 | t.Errorf("Expected issuer to be preserved, but got: %s", string(body))
583 | }
584 | if !strings.Contains(string(body), `"registration_endpoint":"https://localhost`) {
585 | t.Errorf("Expected registration_endpoint to be preserved, but got: %s", string(body))
586 | }
587 | if !strings.Contains(string(body), `"require_request_uri_registration":true`) {
588 | t.Error("Expected require_request_uri_registration to be true, but it was not found in the response")
589 | }
590 | })
591 | }
592 | })
593 | }
594 |
595 | func TestMiddlewareLogging(t *testing.T) {
596 | testCase(t, func(ctx *httpContext) {
597 | _, _ = http.Get(fmt.Sprintf("http://%s/.well-known/oauth-protected-resource", ctx.HttpAddress))
598 | t.Run("Logs HTTP requests and responses", func(t *testing.T) {
599 | if !strings.Contains(ctx.LogBuffer.String(), "GET /.well-known/oauth-protected-resource 404") {
600 | t.Errorf("Expected log entry for GET /.well-known/oauth-protected-resource, got: %s", ctx.LogBuffer.String())
601 | }
602 | })
603 | t.Run("Logs HTTP request duration", func(t *testing.T) {
604 | expected := `"GET /.well-known/oauth-protected-resource 404 (.+)"`
605 | m := regexp.MustCompile(expected).FindStringSubmatch(ctx.LogBuffer.String())
606 | if len(m) != 2 {
607 | t.Fatalf("Expected log entry to contain duration, got %s", ctx.LogBuffer.String())
608 | }
609 | duration, err := time.ParseDuration(m[1])
610 | if err != nil {
611 | t.Fatalf("Failed to parse duration from log entry: %v", err)
612 | }
613 | if duration < 0 {
614 | t.Errorf("Expected duration to be non-negative, got %v", duration)
615 | }
616 | })
617 | })
618 | }
619 |
620 | func TestAuthorizationUnauthorized(t *testing.T) {
621 | // Missing Authorization header
622 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
623 | resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
624 | if err != nil {
625 | t.Fatalf("Failed to get protected endpoint: %v", err)
626 | }
627 | t.Cleanup(func() { _ = resp.Body.Close })
628 | t.Run("Protected resource with MISSING Authorization header returns 401 - Unauthorized", func(t *testing.T) {
629 | if resp.StatusCode != 401 {
630 | t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
631 | }
632 | })
633 | t.Run("Protected resource with MISSING Authorization header returns WWW-Authenticate header", func(t *testing.T) {
634 | authHeader := resp.Header.Get("WWW-Authenticate")
635 | expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"`
636 | if authHeader != expected {
637 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
638 | }
639 | })
640 | t.Run("Protected resource with MISSING Authorization header logs error", func(t *testing.T) {
641 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - missing or invalid bearer token") {
642 | t.Errorf("Expected log entry for missing or invalid bearer token, got: %s", ctx.LogBuffer.String())
643 | }
644 | })
645 | })
646 | // Authorization header without Bearer prefix
647 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
648 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
649 | if err != nil {
650 | t.Fatalf("Failed to create request: %v", err)
651 | }
652 | req.Header.Set("Authorization", "Basic YWxhZGRpbjpvcGVuc2VzYW1l")
653 | resp, err := http.DefaultClient.Do(req)
654 | if err != nil {
655 | t.Fatalf("Failed to get protected endpoint: %v", err)
656 | }
657 | t.Cleanup(func() { _ = resp.Body.Close })
658 | t.Run("Protected resource with INCOMPATIBLE Authorization header returns WWW-Authenticate header", func(t *testing.T) {
659 | authHeader := resp.Header.Get("WWW-Authenticate")
660 | expected := `Bearer realm="Kubernetes MCP Server", error="missing_token"`
661 | if authHeader != expected {
662 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
663 | }
664 | })
665 | t.Run("Protected resource with INCOMPATIBLE Authorization header logs error", func(t *testing.T) {
666 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - missing or invalid bearer token") {
667 | t.Errorf("Expected log entry for missing or invalid bearer token, got: %s", ctx.LogBuffer.String())
668 | }
669 | })
670 | })
671 | // Invalid Authorization header
672 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
673 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
674 | if err != nil {
675 | t.Fatalf("Failed to create request: %v", err)
676 | }
677 | req.Header.Set("Authorization", "Bearer "+strings.ReplaceAll(tokenBasicNotExpired, ".", ".invalid"))
678 | resp, err := http.DefaultClient.Do(req)
679 | if err != nil {
680 | t.Fatalf("Failed to get protected endpoint: %v", err)
681 | }
682 | t.Cleanup(func() { _ = resp.Body.Close })
683 | t.Run("Protected resource with INVALID Authorization header returns 401 - Unauthorized", func(t *testing.T) {
684 | if resp.StatusCode != 401 {
685 | t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
686 | }
687 | })
688 | t.Run("Protected resource with INVALID Authorization header returns WWW-Authenticate header", func(t *testing.T) {
689 | authHeader := resp.Header.Get("WWW-Authenticate")
690 | expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"`
691 | if authHeader != expected {
692 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
693 | }
694 | })
695 | t.Run("Protected resource with INVALID Authorization header logs error", func(t *testing.T) {
696 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
697 | !strings.Contains(ctx.LogBuffer.String(), "error: failed to parse JWT token: illegal base64 data") {
698 | t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String())
699 | }
700 | })
701 | })
702 | // Expired Authorization Bearer token
703 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
704 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
705 | if err != nil {
706 | t.Fatalf("Failed to create request: %v", err)
707 | }
708 | req.Header.Set("Authorization", "Bearer "+tokenBasicExpired)
709 | resp, err := http.DefaultClient.Do(req)
710 | if err != nil {
711 | t.Fatalf("Failed to get protected endpoint: %v", err)
712 | }
713 | t.Cleanup(func() { _ = resp.Body.Close })
714 | t.Run("Protected resource with EXPIRED Authorization header returns 401 - Unauthorized", func(t *testing.T) {
715 | if resp.StatusCode != 401 {
716 | t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
717 | }
718 | })
719 | t.Run("Protected resource with EXPIRED Authorization header returns WWW-Authenticate header", func(t *testing.T) {
720 | authHeader := resp.Header.Get("WWW-Authenticate")
721 | expected := `Bearer realm="Kubernetes MCP Server", error="invalid_token"`
722 | if authHeader != expected {
723 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
724 | }
725 | })
726 | t.Run("Protected resource with EXPIRED Authorization header logs error", func(t *testing.T) {
727 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
728 | !strings.Contains(ctx.LogBuffer.String(), "validation failed, token is expired (exp)") {
729 | t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String())
730 | }
731 | })
732 | })
733 | // Invalid audience claim Bearer token
734 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "expected-audience", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
735 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
736 | if err != nil {
737 | t.Fatalf("Failed to create request: %v", err)
738 | }
739 | req.Header.Set("Authorization", "Bearer "+tokenBasicExpired)
740 | resp, err := http.DefaultClient.Do(req)
741 | if err != nil {
742 | t.Fatalf("Failed to get protected endpoint: %v", err)
743 | }
744 | t.Cleanup(func() { _ = resp.Body.Close })
745 | t.Run("Protected resource with INVALID AUDIENCE Authorization header returns 401 - Unauthorized", func(t *testing.T) {
746 | if resp.StatusCode != 401 {
747 | t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
748 | }
749 | })
750 | t.Run("Protected resource with INVALID AUDIENCE Authorization header returns WWW-Authenticate header", func(t *testing.T) {
751 | authHeader := resp.Header.Get("WWW-Authenticate")
752 | expected := `Bearer realm="Kubernetes MCP Server", audience="expected-audience", error="invalid_token"`
753 | if authHeader != expected {
754 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
755 | }
756 | })
757 | t.Run("Protected resource with INVALID AUDIENCE Authorization header logs error", func(t *testing.T) {
758 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
759 | !strings.Contains(ctx.LogBuffer.String(), "invalid audience claim (aud)") {
760 | t.Errorf("Expected log entry for JWT validation error, got: %s", ctx.LogBuffer.String())
761 | }
762 | })
763 | })
764 | // Failed OIDC validation
765 | oidcTestServer := NewOidcTestServer(t)
766 | t.Cleanup(oidcTestServer.Close)
767 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
768 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
769 | if err != nil {
770 | t.Fatalf("Failed to create request: %v", err)
771 | }
772 | req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired)
773 | resp, err := http.DefaultClient.Do(req)
774 | if err != nil {
775 | t.Fatalf("Failed to get protected endpoint: %v", err)
776 | }
777 | t.Cleanup(func() { _ = resp.Body.Close })
778 | t.Run("Protected resource with INVALID OIDC Authorization header returns 401 - Unauthorized", func(t *testing.T) {
779 | if resp.StatusCode != 401 {
780 | t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
781 | }
782 | })
783 | t.Run("Protected resource with INVALID OIDC Authorization header returns WWW-Authenticate header", func(t *testing.T) {
784 | authHeader := resp.Header.Get("WWW-Authenticate")
785 | expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
786 | if authHeader != expected {
787 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
788 | }
789 | })
790 | t.Run("Protected resource with INVALID OIDC Authorization header logs error", func(t *testing.T) {
791 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
792 | !strings.Contains(ctx.LogBuffer.String(), "OIDC token validation error: failed to verify signature") {
793 | t.Errorf("Expected log entry for OIDC validation error, got: %s", ctx.LogBuffer.String())
794 | }
795 | })
796 | })
797 | // Failed Kubernetes TokenReview
798 | rawClaims := `{
799 | "iss": "` + oidcTestServer.URL + `",
800 | "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
801 | "aud": "mcp-server"
802 | }`
803 | validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims)
804 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: true, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
805 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
806 | if err != nil {
807 | t.Fatalf("Failed to create request: %v", err)
808 | }
809 | req.Header.Set("Authorization", "Bearer "+validOidcToken)
810 | resp, err := http.DefaultClient.Do(req)
811 | if err != nil {
812 | t.Fatalf("Failed to get protected endpoint: %v", err)
813 | }
814 | t.Cleanup(func() { _ = resp.Body.Close })
815 | t.Run("Protected resource with INVALID KUBERNETES Authorization header returns 401 - Unauthorized", func(t *testing.T) {
816 | if resp.StatusCode != 401 {
817 | t.Errorf("Expected HTTP 401, got %d", resp.StatusCode)
818 | }
819 | })
820 | t.Run("Protected resource with INVALID KUBERNETES Authorization header returns WWW-Authenticate header", func(t *testing.T) {
821 | authHeader := resp.Header.Get("WWW-Authenticate")
822 | expected := `Bearer realm="Kubernetes MCP Server", audience="mcp-server", error="invalid_token"`
823 | if authHeader != expected {
824 | t.Errorf("Expected WWW-Authenticate header to be %q, got %q", expected, authHeader)
825 | }
826 | })
827 | t.Run("Protected resource with INVALID KUBERNETES Authorization header logs error", func(t *testing.T) {
828 | if !strings.Contains(ctx.LogBuffer.String(), "Authentication failed - JWT validation error") ||
829 | !strings.Contains(ctx.LogBuffer.String(), "kubernetes API token validation error: failed to create token review") {
830 | t.Errorf("Expected log entry for Kubernetes TokenReview error, got: %s", ctx.LogBuffer.String())
831 | }
832 | })
833 | })
834 | }
835 |
836 | func TestAuthorizationRequireOAuthFalse(t *testing.T) {
837 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: false, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
838 | resp, err := http.Get(fmt.Sprintf("http://%s/mcp", ctx.HttpAddress))
839 | if err != nil {
840 | t.Fatalf("Failed to get protected endpoint: %v", err)
841 | }
842 | t.Cleanup(func() { _ = resp.Body.Close() })
843 | t.Run("Protected resource with MISSING Authorization header returns 200 - OK)", func(t *testing.T) {
844 | if resp.StatusCode != http.StatusOK {
845 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
846 | }
847 | })
848 | })
849 | }
850 |
851 | func TestAuthorizationRawToken(t *testing.T) {
852 | cases := []struct {
853 | audience string
854 | validateToken bool
855 | }{
856 | {"", false}, // No audience, no validation
857 | {"", true}, // No audience, validation enabled
858 | {"mcp-server", false}, // Audience set, no validation
859 | {"mcp-server", true}, // Audience set, validation enabled
860 | }
861 | for _, c := range cases {
862 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: c.audience, ValidateToken: c.validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}}, func(ctx *httpContext) {
863 | tokenReviewed := false
864 | ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
865 | if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
866 | w.Header().Set("Content-Type", "application/json")
867 | _, _ = w.Write([]byte(tokenReviewSuccessful))
868 | tokenReviewed = true
869 | return
870 | }
871 | }))
872 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
873 | if err != nil {
874 | t.Fatalf("Failed to create request: %v", err)
875 | }
876 | req.Header.Set("Authorization", "Bearer "+tokenBasicNotExpired)
877 | resp, err := http.DefaultClient.Do(req)
878 | if err != nil {
879 | t.Fatalf("Failed to get protected endpoint: %v", err)
880 | }
881 | t.Cleanup(func() { _ = resp.Body.Close() })
882 | t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header returns 200 - OK", c.audience, c.validateToken), func(t *testing.T) {
883 | if resp.StatusCode != http.StatusOK {
884 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
885 | }
886 | })
887 | t.Run(fmt.Sprintf("Protected resource with audience = '%s' and validate-token = '%t', with VALID Authorization header performs token validation accordingly", c.audience, c.validateToken), func(t *testing.T) {
888 | if tokenReviewed == true && !c.validateToken {
889 | t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed")
890 | }
891 | if tokenReviewed == false && c.validateToken {
892 | t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped")
893 | }
894 | })
895 | })
896 | }
897 |
898 | }
899 |
900 | func TestAuthorizationOidcToken(t *testing.T) {
901 | oidcTestServer := NewOidcTestServer(t)
902 | t.Cleanup(oidcTestServer.Close)
903 | rawClaims := `{
904 | "iss": "` + oidcTestServer.URL + `",
905 | "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
906 | "aud": "mcp-server"
907 | }`
908 | validOidcToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256, rawClaims)
909 | cases := []bool{false, true}
910 | for _, validateToken := range cases {
911 | testCaseWithContext(t, &httpContext{StaticConfig: &config.StaticConfig{RequireOAuth: true, OAuthAudience: "mcp-server", ValidateToken: validateToken, ClusterProviderStrategy: config.ClusterProviderKubeConfig}, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
912 | tokenReviewed := false
913 | ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
914 | if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
915 | w.Header().Set("Content-Type", "application/json")
916 | _, _ = w.Write([]byte(tokenReviewSuccessful))
917 | tokenReviewed = true
918 | return
919 | }
920 | }))
921 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
922 | if err != nil {
923 | t.Fatalf("Failed to create request: %v", err)
924 | }
925 | req.Header.Set("Authorization", "Bearer "+validOidcToken)
926 | resp, err := http.DefaultClient.Do(req)
927 | if err != nil {
928 | t.Fatalf("Failed to get protected endpoint: %v", err)
929 | }
930 | t.Cleanup(func() { _ = resp.Body.Close() })
931 | t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header returns 200 - OK", validateToken), func(t *testing.T) {
932 | if resp.StatusCode != http.StatusOK {
933 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
934 | }
935 | })
936 | t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC Authorization header performs token validation accordingly", validateToken), func(t *testing.T) {
937 | if tokenReviewed == true && !validateToken {
938 | t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed")
939 | }
940 | if tokenReviewed == false && validateToken {
941 | t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped")
942 | }
943 | })
944 | })
945 | }
946 | }
947 |
948 | func TestAuthorizationOidcTokenExchange(t *testing.T) {
949 | oidcTestServer := NewOidcTestServer(t)
950 | t.Cleanup(oidcTestServer.Close)
951 | rawClaims := `{
952 | "iss": "` + oidcTestServer.URL + `",
953 | "exp": ` + strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + `,
954 | "aud": "%s"
955 | }`
956 | validOidcClientToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256,
957 | fmt.Sprintf(rawClaims, "mcp-server"))
958 | validOidcBackendToken := oidctest.SignIDToken(oidcTestServer.PrivateKey, "test-oidc-key-id", oidc.RS256,
959 | fmt.Sprintf(rawClaims, "backend-audience"))
960 | oidcTestServer.TokenEndpointHandler = func(w http.ResponseWriter, r *http.Request) {
961 | w.Header().Set("Content-Type", "application/json")
962 | _, _ = fmt.Fprintf(w, `{"access_token":"%s","token_type":"Bearer","expires_in":253402297199}`, validOidcBackendToken)
963 | }
964 | cases := []bool{false, true}
965 | for _, validateToken := range cases {
966 | staticConfig := &config.StaticConfig{
967 | RequireOAuth: true,
968 | OAuthAudience: "mcp-server",
969 | ValidateToken: validateToken,
970 | StsClientId: "test-sts-client-id",
971 | StsClientSecret: "test-sts-client-secret",
972 | StsAudience: "backend-audience",
973 | StsScopes: []string{"backend-scope"},
974 | ClusterProviderStrategy: config.ClusterProviderKubeConfig,
975 | }
976 | testCaseWithContext(t, &httpContext{StaticConfig: staticConfig, OidcProvider: oidcTestServer.Provider}, func(ctx *httpContext) {
977 | tokenReviewed := false
978 | ctx.mockServer.Handle(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
979 | if req.URL.EscapedPath() == "/apis/authentication.k8s.io/v1/tokenreviews" {
980 | w.Header().Set("Content-Type", "application/json")
981 | _, _ = w.Write([]byte(tokenReviewSuccessful))
982 | tokenReviewed = true
983 | return
984 | }
985 | }))
986 | req, err := http.NewRequest("GET", fmt.Sprintf("http://%s/mcp", ctx.HttpAddress), nil)
987 | if err != nil {
988 | t.Fatalf("Failed to create request: %v", err)
989 | }
990 | req.Header.Set("Authorization", "Bearer "+validOidcClientToken)
991 | resp, err := http.DefaultClient.Do(req)
992 | if err != nil {
993 | t.Fatalf("Failed to get protected endpoint: %v", err)
994 | }
995 | t.Cleanup(func() { _ = resp.Body.Close() })
996 | t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header returns 200 - OK", validateToken), func(t *testing.T) {
997 | if resp.StatusCode != http.StatusOK {
998 | t.Errorf("Expected HTTP 200 OK, got %d", resp.StatusCode)
999 | }
1000 | })
1001 | t.Run(fmt.Sprintf("Protected resource with validate-token='%t' with VALID OIDC EXCHANGE Authorization header performs token validation accordingly", validateToken), func(t *testing.T) {
1002 | if tokenReviewed == true && !validateToken {
1003 | t.Errorf("Expected token review to be skipped when validate-token is false, but it was performed")
1004 | }
1005 | if tokenReviewed == false && validateToken {
1006 | t.Errorf("Expected token review to be performed when validate-token is true, but it was skipped")
1007 | }
1008 | })
1009 | })
1010 | }
1011 | }
1012 |
```