feat: support for listing namespaces and OpenShift projects

This commit is contained in:
Marc Nuri
2025-03-27 16:50:13 +01:00
parent 868e5fc636
commit d74398f85b
7 changed files with 167 additions and 6 deletions

View File

@@ -24,7 +24,9 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
- **Delete** a pod by name from the specified namespace.
- **Show logs** for a pod by name from the specified namespace.
- **Run** a container image in a pod and optionally expose it.
- **✅ Namespaces**: List Kubernetes Namespaces.
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
- **✅ Projects**: List OpenShift Projects.
Unlike other Kubernetes MCP server implementations, this IS NOT just a wrapper around `kubectl` or `helm` command-line tools.
There is no need for external dependencies or tools to be installed on the system.

View File

@@ -0,0 +1,18 @@
package kubernetes
import (
"context"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func (k *Kubernetes) NamespacesList(ctx context.Context) (string, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Namespace",
}, "")
}
func (k *Kubernetes) ProjectsList(ctx context.Context) (string, error) {
return k.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "project.openshift.io", Version: "v1", Kind: "Project",
}, "")
}

View File

@@ -192,14 +192,14 @@ func (c *mcpContext) inOpenShift() func() {
"name": "v1","served": true,"storage": true,
"schema": {"openAPIV3Schema": {"type": "object","x-kubernetes-preserve-unknown-fields": true}}
}],
"scope": "Namespaced",
"scope": "%s",
"names": {"plural": "%s","singular": "%s","kind": "%s"}
}
}`
removeProjects := c.crdApply(fmt.Sprintf(crdTemplate, "projects.project.openshift.io", "project.openshift.io",
"projects", "project", "Project"))
"Cluster", "projects", "project", "Project"))
removeRoutes := c.crdApply(fmt.Sprintf(crdTemplate, "routes.route.openshift.io", "route.openshift.io",
"routes", "route", "Route"))
"Namespaced", "routes", "route", "Route"))
return func() {
removeProjects()
removeRoutes()

View File

@@ -40,6 +40,7 @@ func (s *Server) reloadKubernetesClient() error {
s.server.SetTools(slices.Concat(
s.initConfiguration(),
s.initEvents(),
s.initNamespaces(),
s.initPods(),
s.initResources(),
)...)

View File

@@ -50,6 +50,7 @@ func TestTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
"namespaces_list",
"pods_list",
"pods_list_in_namespace",
"pods_get",
@@ -87,12 +88,20 @@ func TestTools(t *testing.T) {
func TestToolsInOpenShift(t *testing.T) {
testCase(t, func(c *mcpContext) {
defer c.inOpenShift()() // n.b. two sets of parentheses to invoke the first function
c.mcpServer.server.AddTools(c.mcpServer.initNamespaces()...)
c.mcpServer.server.AddTools(c.mcpServer.initResources()...)
tools, err := c.mcpClient.ListTools(c.ctx, mcp.ListToolsRequest{})
t.Run("ListTools returns tools", func(t *testing.T) {
if err != nil {
t.Fatalf("call ListTools failed %v", err)
return
}
})
t.Run("ListTools contains projects_list tool", func(t *testing.T) {
idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool {
return tool.Name == "projects_list"
})
if idx == -1 {
t.Fatalf("tool projects_list not found")
}
})
t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) {
@@ -101,11 +110,9 @@ func TestToolsInOpenShift(t *testing.T) {
})
if idx == -1 {
t.Fatalf("tool resources_list not found")
return
}
if !strings.Contains(tools.Tools[idx].Description, ", route.openshift.io/v1 Route") {
t.Fatalf("tool resources_list does not have OpenShift hint, got %s", tools.Tools[9].Description)
return
}
})
})

42
pkg/mcp/namespaces.go Normal file
View File

@@ -0,0 +1,42 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func (s *Server) initNamespaces() []server.ServerTool {
ret := make([]server.ServerTool, 0)
if s.k.IsOpenShift(context.Background()) {
ret = append(ret, server.ServerTool{
Tool: mcp.NewTool("projects_list",
mcp.WithDescription("List all the OpenShift projects in the current cluster"),
), Handler: s.projectsList,
})
} else {
ret = append(ret, server.ServerTool{
Tool: mcp.NewTool("namespaces_list",
mcp.WithDescription("List all the Kubernetes namespaces in the current cluster"),
), Handler: s.namespacesList,
})
}
return ret
}
func (s *Server) namespacesList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ret, err := s.k.NamespacesList(ctx)
if err != nil {
err = fmt.Errorf("failed to list namespaces: %v", err)
}
return NewTextResult(ret, err), nil
}
func (s *Server) projectsList(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
ret, err := s.k.ProjectsList(ctx)
if err != nil {
err = fmt.Errorf("failed to list projects: %v", err)
}
return NewTextResult(ret, err), nil
}

View File

@@ -0,0 +1,91 @@
package mcp
import (
"github.com/mark3labs/mcp-go/mcp"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"sigs.k8s.io/yaml"
"slices"
"testing"
)
func TestNamespacesList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
toolResult, err := c.callTool("namespaces_list", map[string]interface{}{})
t.Run("namespaces_list returns namespace list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
})
var decoded []unstructured.Unstructured
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("namespaces_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
})
t.Run("namespaces_list returns at least 3 items", func(t *testing.T) {
if len(decoded) < 3 {
t.Errorf("invalid namespace count, expected at least 3, got %v", len(decoded))
}
for _, expectedNamespace := range []string{"default", "ns-1", "ns-2"} {
idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
return ns.GetName() == expectedNamespace
})
if idx == -1 {
t.Errorf("namespace %s not found in the list", expectedNamespace)
}
}
})
})
}
func TestProjectsListInOpenShift(t *testing.T) {
testCase(t, func(c *mcpContext) {
defer c.inOpenShift()() // n.b. two sets of parentheses to invoke the first function
c.mcpServer.server.AddTools(c.mcpServer.initNamespaces()...)
dynamicClient := dynamic.NewForConfigOrDie(envTestRestConfig)
_, err := dynamicClient.Resource(schema.GroupVersionResource{Group: "project.openshift.io", Version: "v1", Resource: "projects"}).
Create(c.ctx, &unstructured.Unstructured{Object: map[string]interface{}{
"apiVersion": "project.openshift.io/v1",
"kind": "Project",
"metadata": map[string]interface{}{
"name": "an-openshift-project",
},
}}, metav1.CreateOptions{})
println(err)
toolResult, err := c.callTool("projects_list", map[string]interface{}{})
t.Run("projects_list returns project list", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
})
var decoded []unstructured.Unstructured
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
t.Run("projects_list has yaml content", func(t *testing.T) {
if err != nil {
t.Fatalf("invalid tool result content %v", err)
}
})
t.Run("projects_list returns at least 1 items", func(t *testing.T) {
if len(decoded) < 1 {
t.Errorf("invalid project count, expected at least 1, got %v", len(decoded))
}
idx := slices.IndexFunc(decoded, func(ns unstructured.Unstructured) bool {
return ns.GetName() == "an-openshift-project"
})
if idx == -1 {
t.Errorf("namespace %s not found in the list", "an-openshift-project")
}
})
})
}