Skip to content

Commit 9248c5d

Browse files
committed
feat: support for kubernetes events
1 parent 8b3ddab commit 9248c5d

File tree

7 files changed

+218
-15
lines changed

7 files changed

+218
-15
lines changed

Diff for: README.md

+15-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![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)
66
[![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)
77

8-
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration)
8+
[✨ Features](#features) | [🚀 Getting Started](#getting-started) | [🎥 Demos](#demos) | [⚙️ Configuration](#configuration) | [🧑‍💻 Development](#development)
99

1010
https://github.com/user-attachments/assets/be2b67b3-fc1c-4d11-ae46-93deba8ed98e
1111

@@ -23,6 +23,7 @@ A powerful and flexible Kubernetes [Model Context Protocol (MCP)](https://blog.m
2323
- **Delete** a pod by name from the specified namespace.
2424
- **Show logs** for a pod by name from the specified namespace.
2525
- **Run** a container image in a pod and optionally expose it.
26+
- **✅ Events**: View Kubernetes events in all namespaces or in a specific namespace.
2627

2728
## 🚀 Getting Started <a id="getting-started"></a>
2829

@@ -95,3 +96,16 @@ npx kubernetes-mcp-server@latest --help
9596
| Option | Description |
9697
|--------------|------------------------------------------------------------------------------------------|
9798
| `--sse-port` | Starts the MCP server in Server-Sent Event (SSE) mode and listens on the specified port. |
99+
100+
## 🧑‍💻 Development <a id="development"></a>
101+
102+
### Running with mcp-inspector
103+
104+
Compile the project and run the Kubernetes MCP server with [mcp-inspector](https://modelcontextprotocol.io/docs/tools/inspector) to inspect the MCP server.
105+
106+
```shell
107+
# Compile the project
108+
make build
109+
# Run the Kubernetes MCP server with mcp-inspector
110+
npx @modelcontextprotocol/inspector@latest $(pwd)/kubernetes-mcp-server
111+
```

Diff for: pkg/kubernetes/events.go

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package kubernetes
2+
3+
import (
4+
"context"
5+
"fmt"
6+
v1 "k8s.io/api/core/v1"
7+
"k8s.io/apimachinery/pkg/runtime"
8+
"k8s.io/apimachinery/pkg/runtime/schema"
9+
"strings"
10+
)
11+
12+
func (k *Kubernetes) EventsList(ctx context.Context, namespace string) (string, error) {
13+
unstructuredList, err := k.resourcesList(ctx, &schema.GroupVersionKind{
14+
Group: "", Version: "v1", Kind: "Event",
15+
}, namespace)
16+
if err != nil {
17+
return "", err
18+
}
19+
if len(unstructuredList.Items) == 0 {
20+
return "No events found", nil
21+
}
22+
var eventMap []map[string]any
23+
for _, item := range unstructuredList.Items {
24+
event := &v1.Event{}
25+
if err = runtime.DefaultUnstructuredConverter.FromUnstructured(item.Object, event); err != nil {
26+
return "", err
27+
}
28+
timestamp := event.EventTime.Time
29+
if timestamp.IsZero() && event.Series != nil {
30+
timestamp = event.Series.LastObservedTime.Time
31+
} else if timestamp.IsZero() && event.Count > 1 {
32+
timestamp = event.LastTimestamp.Time
33+
} else if timestamp.IsZero() {
34+
timestamp = event.FirstTimestamp.Time
35+
}
36+
eventMap = append(eventMap, map[string]any{
37+
"Namespace": event.Namespace,
38+
"Timestamp": timestamp.String(),
39+
"Type": event.Type,
40+
"Reason": event.Reason,
41+
"InvolvedObject": map[string]string{
42+
"apiVersion": event.InvolvedObject.APIVersion,
43+
"Kind": event.InvolvedObject.Kind,
44+
"Name": event.InvolvedObject.Name,
45+
},
46+
"Message": strings.TrimSpace(event.Message),
47+
})
48+
}
49+
yamlEvents, err := marshal(eventMap)
50+
if err != nil {
51+
return "", err
52+
}
53+
return fmt.Sprintf("The following events (YAML format) were found:\n%s", yamlEvents), nil
54+
}

Diff for: pkg/kubernetes/resources.go

+16-12
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,7 @@ const (
2020
)
2121

2222
func (k *Kubernetes) ResourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string) (string, error) {
23-
gvr, err := k.resourceFor(gvk)
24-
if err != nil {
25-
return "", err
26-
}
27-
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
28-
isNamespaced, _ := k.isNamespaced(gvk)
29-
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
30-
namespace = configuredNamespace()
31-
}
32-
rl, err := k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
23+
rl, err := k.resourcesList(ctx, gvk, namespace)
3324
if err != nil {
3425
return "", err
3526
}
@@ -78,6 +69,19 @@ func (k *Kubernetes) ResourcesDelete(ctx context.Context, gvk *schema.GroupVersi
7869
return k.dynamicClient.Resource(*gvr).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{})
7970
}
8071

72+
func (k *Kubernetes) resourcesList(ctx context.Context, gvk *schema.GroupVersionKind, namespace string) (*unstructured.UnstructuredList, error) {
73+
gvr, err := k.resourceFor(gvk)
74+
if err != nil {
75+
return nil, err
76+
}
77+
// Check if operation is allowed for all namespaces (applicable for namespaced resources)
78+
isNamespaced, _ := k.isNamespaced(gvk)
79+
if isNamespaced && !k.canIUse(ctx, gvr, namespace, "list") && namespace == "" {
80+
namespace = configuredNamespace()
81+
}
82+
return k.dynamicClient.Resource(*gvr).Namespace(namespace).List(ctx, metav1.ListOptions{})
83+
}
84+
8185
func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*unstructured.Unstructured) (string, error) {
8286
for i, obj := range resources {
8387
gvk := obj.GroupVersionKind()
@@ -101,11 +105,11 @@ func (k *Kubernetes) resourcesCreateOrUpdate(ctx context.Context, resources []*u
101105
k.deferredDiscoveryRESTMapper.Reset()
102106
}
103107
}
104-
yaml, err := marshal(resources)
108+
marshalledYaml, err := marshal(resources)
105109
if err != nil {
106110
return "", err
107111
}
108-
return "# The following resources (YAML) have been created or updated successfully\n" + yaml, nil
112+
return "# The following resources (YAML) have been created or updated successfully\n" + marshalledYaml, nil
109113
}
110114

111115
func (k *Kubernetes) resourceFor(gvk *schema.GroupVersionKind) (*schema.GroupVersionResource, error) {

Diff for: pkg/mcp/events.go

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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) initEvents() []server.ServerTool {
11+
return []server.ServerTool{
12+
{mcp.NewTool("events_list",
13+
mcp.WithDescription("List all the Kubernetes events in the current cluster from all namespaces"),
14+
mcp.WithString("namespace",
15+
mcp.Description("Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces")),
16+
), s.eventsList},
17+
}
18+
}
19+
20+
func (s *Server) eventsList(ctx context.Context, ctr mcp.CallToolRequest) (*mcp.CallToolResult, error) {
21+
namespace := ctr.Params.Arguments["namespace"]
22+
if namespace == nil {
23+
namespace = ""
24+
}
25+
ret, err := s.k.EventsList(ctx, namespace.(string))
26+
if err != nil {
27+
return NewTextResult("", fmt.Errorf("failed to list events in all namespaces: %v", err)), nil
28+
}
29+
return NewTextResult(ret, err), nil
30+
}

Diff for: pkg/mcp/events_test.go

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package mcp
2+
3+
import (
4+
"github.com/mark3labs/mcp-go/mcp"
5+
v1 "k8s.io/api/core/v1"
6+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
7+
"testing"
8+
)
9+
10+
func TestEventsList(t *testing.T) {
11+
testCase(t, func(c *mcpContext) {
12+
c.withEnvTest()
13+
toolResult, err := c.callTool("events_list", map[string]interface{}{})
14+
t.Run("events_list with no events returns OK", func(t *testing.T) {
15+
if err != nil {
16+
t.Fatalf("call tool failed %v", err)
17+
}
18+
if toolResult.IsError {
19+
t.Fatalf("call tool failed")
20+
}
21+
if toolResult.Content[0].(mcp.TextContent).Text != "No events found" {
22+
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
23+
}
24+
})
25+
client := c.newKubernetesClient()
26+
for _, ns := range []string{"default", "ns-1"} {
27+
_, _ = client.CoreV1().Events(ns).Create(c.ctx, &v1.Event{
28+
ObjectMeta: metav1.ObjectMeta{
29+
Name: "an-event-in-" + ns,
30+
},
31+
InvolvedObject: v1.ObjectReference{
32+
APIVersion: "v1",
33+
Kind: "Pod",
34+
Name: "a-pod",
35+
Namespace: ns,
36+
},
37+
Type: "Normal",
38+
Message: "The event message",
39+
}, metav1.CreateOptions{})
40+
}
41+
toolResult, err = c.callTool("events_list", map[string]interface{}{})
42+
t.Run("events_list with events returns all OK", func(t *testing.T) {
43+
if err != nil {
44+
t.Fatalf("call tool failed %v", err)
45+
}
46+
if toolResult.IsError {
47+
t.Fatalf("call tool failed")
48+
}
49+
if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+
50+
"- InvolvedObject:\n"+
51+
" Kind: Pod\n"+
52+
" Name: a-pod\n"+
53+
" apiVersion: v1\n"+
54+
" Message: The event message\n"+
55+
" Namespace: default\n"+
56+
" Reason: \"\"\n"+
57+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
58+
" Type: Normal\n"+
59+
"- InvolvedObject:\n"+
60+
" Kind: Pod\n"+
61+
" Name: a-pod\n"+
62+
" apiVersion: v1\n"+
63+
" Message: The event message\n"+
64+
" Namespace: ns-1\n"+
65+
" Reason: \"\"\n"+
66+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
67+
" Type: Normal\n" {
68+
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
69+
}
70+
})
71+
toolResult, err = c.callTool("events_list", map[string]interface{}{
72+
"namespace": "ns-1",
73+
})
74+
t.Run("events_list in namespace with events returns from namespace OK", func(t *testing.T) {
75+
if err != nil {
76+
t.Fatalf("call tool failed %v", err)
77+
}
78+
if toolResult.IsError {
79+
t.Fatalf("call tool failed")
80+
}
81+
if toolResult.Content[0].(mcp.TextContent).Text != "The following events (YAML format) were found:\n"+
82+
"- InvolvedObject:\n"+
83+
" Kind: Pod\n"+
84+
" Name: a-pod\n"+
85+
" apiVersion: v1\n"+
86+
" Message: The event message\n"+
87+
" Namespace: ns-1\n"+
88+
" Reason: \"\"\n"+
89+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
90+
" Type: Normal\n" {
91+
t.Fatalf("unexpected result %v", toolResult.Content[0].(mcp.TextContent).Text)
92+
}
93+
})
94+
})
95+
}

Diff for: pkg/mcp/mcp.go

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func NewSever() (*Server, error) {
2929
}
3030
s.server.AddTools(slices.Concat(
3131
s.initConfiguration(),
32+
s.initEvents(),
3233
s.initPods(),
3334
s.initResources(),
3435
)...)

Diff for: pkg/mcp/mcp_test.go

+7-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ package mcp
22

33
import (
44
"github.com/mark3labs/mcp-go/mcp"
5+
"slices"
56
"strings"
67
"testing"
78
)
89

910
func TestTools(t *testing.T) {
1011
expectedNames := []string{
1112
"configuration_view",
13+
"events_list",
1214
"pods_list",
1315
"pods_list_in_namespace",
1416
"pods_get",
@@ -55,11 +57,14 @@ func TestToolsInOpenShift(t *testing.T) {
5557
}
5658
})
5759
t.Run("ListTools has resources_list tool with OpenShift hint", func(t *testing.T) {
58-
if tools.Tools[10].Name != "resources_list" {
60+
idx := slices.IndexFunc(tools.Tools, func(tool mcp.Tool) bool {
61+
return tool.Name == "resources_list"
62+
})
63+
if idx == -1 {
5964
t.Fatalf("tool resources_list not found")
6065
return
6166
}
62-
if !strings.Contains(tools.Tools[10].Description, ", route.openshift.io/v1 Route") {
67+
if !strings.Contains(tools.Tools[idx].Description, ", route.openshift.io/v1 Route") {
6368
t.Fatalf("tool resources_list does not have OpenShift hint, got %s", tools.Tools[9].Description)
6469
return
6570
}

0 commit comments

Comments
 (0)