Skip to content

Commit d74398f

Browse files
committed
feat: support for listing namespaces and OpenShift projects
1 parent 868e5fc commit d74398f

File tree

7 files changed

+167
-6
lines changed

7 files changed

+167
-6
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
2424
- **Delete** a pod by name from the specified namespace.
2525
- **Show logs** for a pod by name from the specified namespace.
2626
- **Run** a container image in a pod and optionally expose it.
27+
- **✅ Namespaces**: List Kubernetes Namespaces.
2728
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
29+
- **✅ Projects**: List OpenShift Projects.
2830

2931
Unlike other Kubernetes MCP server implementations, this IS NOT just a wrapper around `kubectl` or `helm` command-line tools.
3032
There is no need for external dependencies or tools to be installed on the system.

pkg/kubernetes/namespaces.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package kubernetes
2+
3+
import (
4+
"context"
5+
"k8s.io/apimachinery/pkg/runtime/schema"
6+
)
7+
8+
func (k *Kubernetes) NamespacesList(ctx context.Context) (string, error) {
9+
return k.ResourcesList(ctx, &schema.GroupVersionKind{
10+
Group: "", Version: "v1", Kind: "Namespace",
11+
}, "")
12+
}
13+
14+
func (k *Kubernetes) ProjectsList(ctx context.Context) (string, error) {
15+
return k.ResourcesList(ctx, &schema.GroupVersionKind{
16+
Group: "project.openshift.io", Version: "v1", Kind: "Project",
17+
}, "")
18+
}

pkg/mcp/common_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,14 +192,14 @@ func (c *mcpContext) inOpenShift() func() {
192192
"name": "v1","served": true,"storage": true,
193193
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
194194
}],
195-
"scope": "Namespaced",
195+
"scope": "%s",
196196
"names": {"plural": "%s","singular": "%s","kind": "%s"}
197197
}
198198
}`
199199
removeProjects := c.crdApply(fmt.Sprintf(crdTemplate, "projects.project.openshift.io", "project.openshift.io",
200-
"projects", "project", "Project"))
200+
"Cluster", "projects", "project", "Project"))
201201
removeRoutes := c.crdApply(fmt.Sprintf(crdTemplate, "routes.route.openshift.io", "route.openshift.io",
202-
"routes", "route", "Route"))
202+
"Namespaced", "routes", "route", "Route"))
203203
return func() {
204204
removeProjects()
205205
removeRoutes()

pkg/mcp/mcp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ func (s *Server) reloadKubernetesClient() error {
4040
s.server.SetTools(slices.Concat(
4141
s.initConfiguration(),
4242
s.initEvents(),
43+
s.initNamespaces(),
4344
s.initPods(),
4445
s.initResources(),
4546
)...)

pkg/mcp/mcp_test.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func TestTools(t *testing.T) {
5050
expectedNames := []string{
5151
"configuration_view",
5252
"events_list",
53+
"namespaces_list",
5354
"pods_list",
5455
"pods_list_in_namespace",
5556
"pods_get",
@@ -87,12 +88,20 @@ func TestTools(t *testing.T) {
8788
func TestToolsInOpenShift(t *testing.T) {
8889
testCase(t, func(c *mcpContext) {
8990
defer c.inOpenShift()() // n.b. two sets of parentheses to invoke the first function
91+
c.mcpServer.server.AddTools(c.mcpServer.initNamespaces()...)
9092
c.mcpServer.server.AddTools(c.mcpServer.initResources()...)
9193
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
9294
t.Run("ListTools returns tools", func(t *testing.T) {
9395
if err != nil {
9496
t.Fatalf("call ListTools failed %v", err)
95-
return
97+
}
98+
})
99+
t.Run("ListTools contains projects_list tool", func(t *testing.T) {
100+
idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool {
101+
return tool.Name == "projects_list"
102+
})
103+
if idx == -1 {
104+
t.Fatalf("tool projects_list not found")
96105
}
97106
})
98107
t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) {
@@ -101,11 +110,9 @@ func TestToolsInOpenShift(t *testing.T) {
101110
})
102111
if idx == -1 {
103112
t.Fatalf("tool resources_list not found")
104-
return
105113
}
106114
if !strings.Contains(tools.Tools[idx].Description, ", route.openshift.io/v1 Route") {
107115
t.Fatalf("tool resources_list does not have OpenShift hint, got %s", tools.Tools[9].Description)
108-
return
109116
}
110117
})
111118
})

pkg/mcp/namespaces.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package mcp
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"github.com/mark3labs/mcp-go/mcp"
7+
"github.com/mark3labs/mcp-go/server"
8+
)
9+
10+
func (s *Server) initNamespaces() []server.ServerTool {
11+
ret := make([]server.ServerTool, 0)
12+
if s.k.IsOpenShift(context.Background()) {
13+
ret = append(ret, server.ServerTool{
14+
Tool: mcp.NewTool("projects_list",
15+
mcp.WithDescription("List all the OpenShift projects in the current cluster"),
16+
), Handler: s.projectsList,
17+
})
18+
} else {
19+
ret = append(ret, server.ServerTool{
20+
Tool: mcp.NewTool("namespaces_list",
21+
mcp.WithDescription("List all the Kubernetes namespaces in the current cluster"),
22+
), Handler: s.namespacesList,
23+
})
24+
}
25+
return ret
26+
}
27+
28+
func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
29+
ret, err := s.k.NamespacesList(ctx)
30+
if err != nil {
31+
err = fmt.Errorf("failed to list namespaces: %v", err)
32+
}
33+
return NewTextResult(ret, err), nil
34+
}
35+
36+
func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
37+
ret, err := s.k.ProjectsList(ctx)
38+
if err != nil {
39+
err = fmt.Errorf("failed to list projects: %v", err)
40+
}
41+
return NewTextResult(ret, err), nil
42+
}

pkg/mcp/namespaces_test.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package mcp
2+
3+
import (
4+
"github.com/mark3labs/mcp-go/mcp"
5+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
6+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
7+
"k8s.io/apimachinery/pkg/runtime/schema"
8+
"k8s.io/client-go/dynamic"
9+
"sigs.k8s.io/yaml"
10+
"slices"
11+
"testing"
12+
)
13+
14+
func TestNamespacesList(t *testing.T) {
15+
testCase(t, func(c *mcpContext) {
16+
c.withEnvTest()
17+
toolResult, err := c.callTool("namespaces_list", map[string]interface{}{})
18+
t.Run("namespaces_list returns namespace list", func(t *testing.T) {
19+
if err != nil {
20+
t.Fatalf("call tool failed %v", err)
21+
}
22+
if toolResult.IsError {
23+
t.Fatalf("call tool failed")
24+
}
25+
})
26+
var decoded []unstructured.Unstructured
27+
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
28+
t.Run("namespaces_list has yaml content", func(t *testing.T) {
29+
if err != nil {
30+
t.Fatalf("invalid tool result content %v", err)
31+
}
32+
})
33+
t.Run("namespaces_list returns at least 3 items", func(t *testing.T) {
34+
if len(decoded) < 3 {
35+
t.Errorf("invalid namespace count, expected at least 3, got %v", len(decoded))
36+
}
37+
for _, expectedNamespace := range []string{"default", "ns-1", "ns-2"} {
38+
idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
39+
return ns.GetName() == expectedNamespace
40+
})
41+
if idx == -1 {
42+
t.Errorf("namespace %s not found in the list", expectedNamespace)
43+
}
44+
}
45+
})
46+
})
47+
}
48+
49+
func TestProjectsListInOpenShift(t *testing.T) {
50+
testCase(t, func(c *mcpContext) {
51+
defer c.inOpenShift()() // n.b. two sets of parentheses to invoke the first function
52+
c.mcpServer.server.AddTools(c.mcpServer.initNamespaces()...)
53+
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
54+
_, err := dynamicClient.Resource(schema.GroupVersionResource{Group: "project.openshift.io", Version: "v1", Resource: "projects"}).
55+
Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
56+
"apiVersion": "project.openshift.io/v1",
57+
"kind": "Project",
58+
"metadata": map[string]interface{}{
59+
"name": "an-openshift-project",
60+
},
61+
}}, metav1.CreateOptions{})
62+
println(err)
63+
toolResult, err := c.callTool("projects_list", map[string]interface{}{})
64+
t.Run("projects_list returns project list", func(t *testing.T) {
65+
if err != nil {
66+
t.Fatalf("call tool failed %v", err)
67+
}
68+
if toolResult.IsError {
69+
t.Fatalf("call tool failed")
70+
}
71+
})
72+
var decoded []unstructured.Unstructured
73+
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
74+
t.Run("projects_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("projects_list returns at least 1 items", func(t *testing.T) {
80+
if len(decoded) < 1 {
81+
t.Errorf("invalid project count, expected at least 1, got %v", len(decoded))
82+
}
83+
idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
84+
return ns.GetName() == "an-openshift-project"
85+
})
86+
if idx == -1 {
87+
t.Errorf("namespace %s not found in the list", "an-openshift-project")
88+
}
89+
})
90+
})
91+
}

0 commit comments

Comments
 (0)