mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
feat: support for kubernetes events
This commit is contained in:
16
README.md
16
README.md
@@ -5,7 +5,7 @@
|
||||
[](https://github.com/manusa/kubernetes-mcp-server/releases/latest)
|
||||
[](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
54
pkg/kubernetes/events.go
Normal 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
|
||||
}
|
||||
@@ -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
30
pkg/mcp/events.go
Normal 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
95
pkg/mcp/events_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -29,6 +29,7 @@ func NewSever() (*Server, error) {
|
||||
}
|
||||
s.server.AddTools(slices.Concat(
|
||||
s.initConfiguration(),
|
||||
s.initEvents(),
|
||||
s.initPods(),
|
||||
s.initResources(),
|
||||
)...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user