feat: support for kubernetes events

This commit is contained in:
Marc Nuri
2025-03-21 10:55:43 +01:00
parent 8b3ddab9dd
commit 9248c5d734
7 changed files with 218 additions and 15 deletions

View File

@@ -5,7 +5,7 @@
[![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/manusa/kubernetes-mcp-server?sort=semver)](https://github.com/manusa/kubernetes-mcp-server/releases/latest)
[![Build](https://github.com/manusa/kubernetes-mcp-server/actions/workflows/build.yaml/badge.svg)](https://github.com/manusa/kubernetes-mcp-server/actions/workflows/build.yaml)
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration)
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🧑‍💻 Development](#development)
https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
@@ -23,6 +23,7 @@ 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.
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
## 🚀 Getting Started <a id="getting-started"></a>
@@ -95,3 +96,16 @@ npx kubernetes-mcp-server@latest --help
| Option | Description |
|--------------|------------------------------------------------------------------------------------------|
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
## 🧑‍💻 Development <a id="development"></a>
### Running with mcp-inspector
Compile the project and run the Kubernetes MCP server with [mcp-inspector](https://modelcontextprotocol.io/docs/tools/inspector) to inspect the MCP server.
```shell
# Compile the project
make build
# Run the Kubernetes MCP server with mcp-inspector
npx @modelcontextprotocol/inspector@latest $(pwd)/kubernetes-mcp-server
```

54
pkg/kubernetes/events.go Normal file
View File

@@ -0,0 +1,54 @@
package kubernetes
import (
"context"
"fmt"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"strings"
)
func (k *Kubernetes) EventsList(ctx context.Context, namespace string) (string, error) {
unstructuredList, err := k.resourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Event",
}, namespace)
if err != nil {
return "", err
}
if len(unstructuredList.Items) == 0 {
return "No events found", nil
}
var eventMap []map[string]any
for _, item := range unstructuredList.Items {
event := &v1.Event{}
if err = runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, event); err != nil {
return "", err
}
timestamp := event.EventTime.Time
if timestamp.IsZero() && event.Series != nil {
timestamp = event.Series.LastObservedTime.Time
} else if timestamp.IsZero() && event.Count > 1 {
timestamp = event.LastTimestamp.Time
} else if timestamp.IsZero() {
timestamp = event.FirstTimestamp.Time
}
eventMap = append(eventMap, map[string]any{
"Namespace": event.Namespace,
"Timestamp": timestamp.String(),
"Type": event.Type,
"Reason": event.Reason,
"InvolvedObject": map[string]string{
"apiVersion": event.InvolvedObject.APIVersion,
"Kind": event.InvolvedObject.Kind,
"Name": event.InvolvedObject.Name,
},
"Message": strings.TrimSpace(event.Message),
})
}
yamlEvents, err := marshal(eventMap)
if err != nil {
return "", err
}
return fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), nil
}

View File

@@ -20,16 +20,7 @@ const (
)
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string) (string, error) {
gvr, err := k.resourceFor(gvk)
if err != nil {
return "", err
}
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = configuredNamespace()
}
rl, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
rl, err := k.resourcesList(ctx, gvk, namespace)
if err != nil {
return "", err
}
@@ -78,6 +69,19 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
}
func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string) (*unstructured.UnstructuredList, error) {
gvr, err := k.resourceFor(gvk)
if err != nil {
return nil, err
}
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
isNamespaced, _ := k.isNamespaced(gvk)
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
namespace = configuredNamespace()
}
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
}
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) {
for i, obj := range resources {
gvk := obj.GroupVersionKind()
@@ -101,11 +105,11 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
k.deferredDiscoveryRESTMapper.Reset()
}
}
yaml, err := marshal(resources)
marshalledYaml, err := marshal(resources)
if err != nil {
return "", err
}
return "# The following resources (YAML) have been created or updated successfully\n" + yaml, nil
return "# The following resources (YAML) have been created or updated successfully\n" + marshalledYaml, nil
}
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {

30
pkg/mcp/events.go Normal file
View File

@@ -0,0 +1,30 @@
package mcp
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func (s *Server) initEvents() []server.ServerTool {
return []server.ServerTool{
{mcp.NewTool("events_list",
mcp.WithDescription("List all the Kubernetes events in the current cluster from all namespaces"),
mcp.WithString("namespace",
mcp.Description("Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces")),
), s.eventsList},
}
}
func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
namespace := ctr.Params.Arguments["namespace"]
if namespace == nil {
namespace = ""
}
ret, err := s.k.EventsList(ctx, namespace.(string))
if err != nil {
return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
}
return NewTextResult(ret, err), nil
}

95
pkg/mcp/events_test.go Normal file
View File

@@ -0,0 +1,95 @@
package mcp
import (
"github.com/mark3labs/mcp-go/mcp"
v1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"testing"
)
func TestEventsList(t *testing.T) {
testCase(t, func(c *mcpContext) {
c.withEnvTest()
toolResult, err := c.callTool("events_list", map[string]interface{}{})
t.Run("events_list with no events returns OK", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "No events found" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
client := c.newKubernetesClient()
for _, ns := range []string{"default", "ns-1"} {
_, _ = client.CoreV1().Events(ns).Create(c.ctx, &v1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "an-event-in-" + ns,
},
InvolvedObject: v1.ObjectReference{
APIVersion: "v1",
Kind: "Pod",
Name: "a-pod",
Namespace: ns,
},
Type: "Normal",
Message: "The event message",
}, metav1.CreateOptions{})
}
toolResult, err = c.callTool("events_list", map[string]interface{}{})
t.Run("events_list with events returns all OK", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: default\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: ns-1\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
toolResult, err = c.callTool("events_list", map[string]interface{}{
"namespace": "ns-1",
})
t.Run("events_list in namespace with events returns from namespace OK", func(t *testing.T) {
if err != nil {
t.Fatalf("call tool failed %v", err)
}
if toolResult.IsError {
t.Fatalf("call tool failed")
}
if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: a-pod\n"+
" apiVersion: v1\n"+
" Message: The event message\n"+
" Namespace: ns-1\n"+
" Reason: \"\"\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n" {
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
}
})
})
}

View File

@@ -29,6 +29,7 @@ func NewSever() (*Server, error) {
}
s.server.AddTools(slices.Concat(
s.initConfiguration(),
s.initEvents(),
s.initPods(),
s.initResources(),
)...)

View File

@@ -2,6 +2,7 @@ package mcp
import (
"github.com/mark3labs/mcp-go/mcp"
"slices"
"strings"
"testing"
)
@@ -9,6 +10,7 @@ import (
func TestTools(t *testing.T) {
expectedNames := []string{
"configuration_view",
"events_list",
"pods_list",
"pods_list_in_namespace",
"pods_get",
@@ -55,11 +57,14 @@ func TestToolsInOpenShift(t *testing.T) {
}
})
t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) {
if tools.Tools[10].Name != "resources_list" {
idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool {
return tool.Name == "resources_list"
})
if idx == -1 {
t.Fatalf("tool resources_list not found")
return
}
if !strings.Contains(tools.Tools[10].Description, ", route.openshift.io/v1 Route") {
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
}