Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,9 +301,11 @@ In case multi-cluster support is enabled (default) and you have access to multip
<summary>core</summary>

- **events_list** - List Kubernetes events (warnings, errors, state changes) for debugging and troubleshooting in the current cluster from all namespaces
- `fieldSelector` (`string`) - Optional Kubernetes field selector to filter events by field values (e.g. 'type=Warning', 'involvedObject.name=my-pod'). Supported fields: involvedObject.kind, involvedObject.name, involvedObject.namespace, involvedObject.uid, involvedObject.apiVersion, involvedObject.resourceVersion, involvedObject.fieldPath, reason, reportingComponent, source, type. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/
- `namespace` (`string`) - Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces

- **namespaces_list** - List all the Kubernetes namespaces in the current cluster
- `fieldSelector` (`string`) - Optional Kubernetes field selector to filter namespaces by field values (e.g. 'metadata.name=default', 'status.phase=Active'). Supported fields: metadata.name, status.phase. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/

- **projects_list** - List all the OpenShift projects in the current cluster

Expand Down
2 changes: 2 additions & 0 deletions evals/tasks/core/filter-events-by-type/cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
kubectl delete namespace events-filter-test --ignore-not-found
28 changes: 28 additions & 0 deletions evals/tasks/core/filter-events-by-type/filter-events-by-type.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
kind: Task
apiVersion: mcpchecker/v1alpha2
metadata:
labels:
suite: core
name: filter-events-by-type
difficulty: easy
spec:
setup:
- script:
file: setup.sh
verify:
- script:
env:
AGENTS_OUTPUT: "{agent.output}"
inline: |-
bad=$(echo "$AGENTS_OUTPUT" | grep 'Warning events' | grep 'bad-pod' | grep '.events-filter-test. namespace')
good=$(echo "$AGENTS_OUTPUT" | grep "good-pod")
if [ -n "$bad" ] && [ -z "$good" ]; then
exit 0
else
exit 1
fi
cleanup:
- script:
file: cleanup.sh
prompt:
inline: "List only the Warning events for the pod named 'bad-pod' in the 'events-filter-test' namespace"
51 changes: 51 additions & 0 deletions evals/tasks/core/filter-events-by-type/setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

NAMESPACE=events-filter-test

kubectl delete namespace ${NAMESPACE} --ignore-not-found
kubectl create namespace ${NAMESPACE}

# Create a pod with a bad image to generate Warning events
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: bad-pod
namespace: ${NAMESPACE}
spec:
containers:
- name: bad
image: quay.io/this-image-does-not-exist:latest
imagePullPolicy: Always
EOF

# Create a healthy pod to generate Normal events
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: good-pod
namespace: ${NAMESPACE}
spec:
containers:
- name: good
image: quay.io/nginx/nginx-unprivileged:latest
EOF

# Wait for the good-pod to be ready (will generate a Normal event)
kubectl wait --for=condition=Ready pod/good-pod -n ${NAMESPACE} --timeout=120s

# Wait for Warning events to appear from the bad image pod
for i in {1..30}; do
if kubectl get events -n ${NAMESPACE} --field-selector=type=Warning --no-headers 2>/dev/null | grep -q "."; then
exit 0
fi
sleep 2
done

echo "Warning events did not appear in time"
exit 1
2 changes: 2 additions & 0 deletions evals/tasks/core/filter-namespace-by-name/cleanup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
kubectl delete namespace ns-alpha ns-beta ns-gamma --ignore-not-found
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
kind: Task
apiVersion: mcpchecker/v1alpha2
metadata:
labels:
suite: core
name: filter-namespace-by-name
difficulty: easy
spec:
setup:
- script:
file: setup.sh
verify:
- script:
env:
AGENTS_OUTPUT: "{agent.output}"
inline: |-
[ X"$(echo "$AGENTS_OUTPUT" | grep -v 'ns-beta')" = X"" ] && exit 0 || exit 1
cleanup:
- script:
file: cleanup.sh
prompt:
inline: "Using a field selector, list only the namespace 'ns-beta' from the cluster"
10 changes: 10 additions & 0 deletions evals/tasks/core/filter-namespace-by-name/setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash

set -o errexit
set -o nounset
set -o pipefail

kubectl delete namespace ns-alpha ns-beta ns-gamma --ignore-not-found
kubectl create namespace ns-alpha
kubectl create namespace ns-beta
kubectl create namespace ns-gamma
4 changes: 2 additions & 2 deletions pkg/kubernetes/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
)

func (c *Core) EventsList(ctx context.Context, namespace string) ([]map[string]any, error) {
func (c *Core) EventsList(ctx context.Context, namespace string, options api.ListOptions) ([]map[string]any, error) {
var eventMap []map[string]any
raw, err := c.ResourcesList(ctx, &schema.GroupVersionKind{
Group: "", Version: "v1", Kind: "Event",
}, namespace, api.ListOptions{})
}, namespace, options)
if err != nil {
return eventMap, err
}
Expand Down
142 changes: 142 additions & 0 deletions pkg/mcp/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,148 @@ func (s *EventsSuite) TestEventsListForbidden() {
})
}

func (s *EventsSuite) TestEventsListWithFieldSelector() {
s.InitMcpClient()
client := kubernetes.NewForConfigOrDie(envTestRestConfig)

// Create events with different types to verify fieldSelector filtering
_, err := client.CoreV1().Events("default").Create(s.T().Context(), &v1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "warning-event",
},
InvolvedObject: v1.ObjectReference{
APIVersion: "v1",
Kind: "Pod",
Name: "crashing-pod",
Namespace: "default",
},
Type: "Warning",
Reason: "BackOff",
Message: "Back-off restarting failed container",
}, metav1.CreateOptions{})
s.Require().NoError(err, "failed to create warning event")
_, err = client.CoreV1().Events("default").Create(s.T().Context(), &v1.Event{
ObjectMeta: metav1.ObjectMeta{
Name: "normal-event",
},
InvolvedObject: v1.ObjectReference{
APIVersion: "v1",
Kind: "Pod",
Name: "healthy-pod",
Namespace: "default",
},
Type: "Normal",
Reason: "Started",
Message: "Started container successfully",
}, metav1.CreateOptions{})
s.Require().NoError(err, "failed to create normal event")

s.Run("events_list(fieldSelector=type=Warning) returns only warning events", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{
"fieldSelector": "type=Warning",
})
s.Run("no error", func() {
s.Nil(err, "call tool failed %v", err)
s.False(toolResult.IsError, "call tool failed")
})
s.Run("has yaml comment indicating output format", func() {
s.True(strings.HasPrefix(toolResult.Content[0].(*mcp.TextContent).Text, "# The following events (YAML format) were found:\n"), "unexpected result %v", toolResult.Content[0].(*mcp.TextContent).Text)
})
var decoded []v1.Event
err = yaml.Unmarshal([]byte(toolResult.Content[0].(*mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nil(err, "unmarshal failed %v", err)
})
s.Run("returns only warning event", func() {
s.YAMLEq(""+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: crashing-pod\n"+
" apiVersion: v1\n"+
" Message: Back-off restarting failed container\n"+
" Namespace: default\n"+
" Reason: BackOff\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Warning\n",
toolResult.Content[0].(*mcp.TextContent).Text,
"unexpected result %v", toolResult.Content[0].(*mcp.TextContent).Text)
})
})

s.Run("events_list(fieldSelector=involvedObject.name=healthy-pod) returns only matching event", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{
"fieldSelector": "involvedObject.name=healthy-pod",
})
s.Run("no error", func() {
s.Nil(err, "call tool failed %v", err)
s.False(toolResult.IsError, "call tool failed")
})
var decoded []v1.Event
err = yaml.Unmarshal([]byte(toolResult.Content[0].(*mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nil(err, "unmarshal failed %v", err)
})
s.Run("returns only healthy-pod event", func() {
s.YAMLEq(""+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: healthy-pod\n"+
" apiVersion: v1\n"+
" Message: Started container successfully\n"+
" Namespace: default\n"+
" Reason: Started\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Normal\n",
toolResult.Content[0].(*mcp.TextContent).Text,
"unexpected result %v", toolResult.Content[0].(*mcp.TextContent).Text)
})
})

s.Run("events_list(fieldSelector=type=Warning, namespace=default) combines fieldSelector and namespace", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{
"namespace": "default",
"fieldSelector": "type=Warning",
})
s.Run("no error", func() {
s.Nil(err, "call tool failed %v", err)
s.False(toolResult.IsError, "call tool failed")
})
var decoded []v1.Event
err = yaml.Unmarshal([]byte(toolResult.Content[0].(*mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nil(err, "unmarshal failed %v", err)
})
s.Run("returns only warning event from default namespace", func() {
s.YAMLEq(""+
"- InvolvedObject:\n"+
" Kind: Pod\n"+
" Name: crashing-pod\n"+
" apiVersion: v1\n"+
" Message: Back-off restarting failed container\n"+
" Namespace: default\n"+
" Reason: BackOff\n"+
" Timestamp: 0001-01-01 00:00:00 +0000 UTC\n"+
" Type: Warning\n",
toolResult.Content[0].(*mcp.TextContent).Text,
"unexpected result %v", toolResult.Content[0].(*mcp.TextContent).Text)
})
})

s.Run("events_list(fieldSelector=type=Warning, namespace=ns-1) returns no events when none match", func() {
toolResult, err := s.CallTool("events_list", map[string]interface{}{
"namespace": "ns-1",
"fieldSelector": "type=Warning",
})
s.Run("no error", func() {
s.Nil(err, "call tool failed %v", err)
s.False(toolResult.IsError, "call tool failed")
})
s.Run("returns no events message", func() {
s.Equal("# No events found", toolResult.Content[0].(*mcp.TextContent).Text)
})
})
}

func TestEvents(t *testing.T) {
suite.Run(t, new(EventsSuite))
}
31 changes: 31 additions & 0 deletions pkg/mcp/namespaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,37 @@ func (s *NamespacesSuite) TestNamespacesListAsTable() {
})
}

func (s *NamespacesSuite) TestNamespacesListWithFieldSelector() {
s.InitMcpClient()

s.Run("namespaces_list(fieldSelector=metadata.name=ns-1) returns only matching namespace", func() {
toolResult, err := s.CallTool("namespaces_list", map[string]interface{}{
"fieldSelector": "metadata.name=ns-1",
})
s.Run("no error", func() {
s.Nil(err, "call tool failed %v", err)
s.False(toolResult.IsError, "call tool failed")
})
var decoded []unstructured.Unstructured
err = yaml.Unmarshal([]byte(toolResult.Content[0].(*mcp.TextContent).Text), &decoded)
s.Run("has yaml content", func() {
s.Nil(err, "invalid tool result content %v", err)
})
s.Run("returns exactly 1 namespace", func() {
s.Lenf(decoded, 1, "expected exactly 1 namespace, got %v", len(decoded))
})
s.Run("returns ns-1", func() {
s.Equalf("ns-1", decoded[0].GetName(), "expected ns-1, got %v", decoded[0].GetName())
})
s.Run("excludes other namespaces", func() {
for _, ns := range decoded {
s.NotEqualf("default", ns.GetName(), "default should have been filtered out by fieldSelector")
s.NotEqualf("ns-2", ns.GetName(), "ns-2 should have been filtered out by fieldSelector")
}
})
})
}

func (s *NamespacesSuite) TestProjectsListInOpenShift() {
s.Require().NoError(EnvTestInOpenShift(s.T().Context()), "Expected to configure test for OpenShift")
s.T().Cleanup(func() {
Expand Down
19 changes: 15 additions & 4 deletions pkg/mcp/testdata/toolsets-core-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"description": "List Kubernetes events (warnings, errors, state changes) for debugging and troubleshooting in the current cluster from all namespaces",
"inputSchema": {
"properties": {
"fieldSelector": {
"description": "Optional Kubernetes field selector to filter events by field values (e.g. 'type=Warning', 'involvedObject.name=my-pod'). Supported fields: involvedObject.kind, involvedObject.name, involvedObject.namespace, involvedObject.uid, involvedObject.apiVersion, involvedObject.resourceVersion, involvedObject.fieldPath, reason, reportingComponent, source, type. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[./\\-A-Za-z0-9]+)+$",
"type": "string"
},
"namespace": {
"description": "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces",
"type": "string"
Expand All @@ -28,7 +33,13 @@
},
"description": "List all the Kubernetes namespaces in the current cluster",
"inputSchema": {
"properties": {},
"properties": {
"fieldSelector": {
"description": "Optional Kubernetes field selector to filter namespaces by field values (e.g. 'metadata.name=default', 'status.phase=Active'). Supported fields: metadata.name, status.phase. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[./\\-A-Za-z0-9]+)+$",
"type": "string"
}
},
"type": "object"
},
"name": "namespaces_list",
Expand Down Expand Up @@ -221,7 +232,7 @@
"properties": {
"fieldSelector": {
"description": "Optional Kubernetes field selector to filter pods by field values (e.g. 'status.phase=Running', 'spec.nodeName=node1'). Supported fields: metadata.name, metadata.namespace, spec.nodeName, spec.restartPolicy, spec.schedulerName, spec.serviceAccountName, status.phase (Pending/Running/Succeeded/Failed/Unknown), status.podIP, status.nominatedNodeName. Note: CrashLoopBackOff is a container state, not a pod phase, so it cannot be filtered directly. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[.\\-A-Za-z0-9]+)+$",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[./\\-A-Za-z0-9]+)+$",
"type": "string"
},
"labelSelector": {
Expand All @@ -247,7 +258,7 @@
"properties": {
"fieldSelector": {
"description": "Optional Kubernetes field selector to filter pods by field values (e.g. 'status.phase=Running', 'spec.nodeName=node1'). Supported fields: metadata.name, metadata.namespace, spec.nodeName, spec.restartPolicy, spec.schedulerName, spec.serviceAccountName, status.phase (Pending/Running/Succeeded/Failed/Unknown), status.podIP, status.nominatedNodeName. Note: CrashLoopBackOff is a container state, not a pod phase, so it cannot be filtered directly. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[.\\-A-Za-z0-9]+)+$",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[./\\-A-Za-z0-9]+)+$",
"type": "string"
},
"labelSelector": {
Expand Down Expand Up @@ -495,7 +506,7 @@
},
"fieldSelector": {
"description": "Optional Kubernetes field selector to filter resources by field values (e.g. 'status.phase=Running', 'metadata.name=myresource'). Supported fields vary by resource type. For Pods: metadata.name, metadata.namespace, spec.nodeName, spec.restartPolicy, spec.schedulerName, spec.serviceAccountName, status.phase (Pending/Running/Succeeded/Failed/Unknown), status.podIP, status.nominatedNodeName. See https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[.\\-A-Za-z0-9]+)+$",
"pattern": "^[.\\-A-Za-z0-9]+([=!,]{1,2}[./\\-A-Za-z0-9]+)+$",
"type": "string"
},
"kind": {
Expand Down
Loading
Loading