diff --git a/README.md b/README.md index d098a12d0..489c1f32c 100644 --- a/README.md +++ b/README.md @@ -301,9 +301,11 @@ In case multi-cluster support is enabled (default) and you have access to multip core - **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 diff --git a/evals/tasks/core/filter-events-by-type/cleanup.sh b/evals/tasks/core/filter-events-by-type/cleanup.sh new file mode 100755 index 000000000..6c9ee4969 --- /dev/null +++ b/evals/tasks/core/filter-events-by-type/cleanup.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +kubectl delete namespace events-filter-test --ignore-not-found diff --git a/evals/tasks/core/filter-events-by-type/filter-events-by-type.yaml b/evals/tasks/core/filter-events-by-type/filter-events-by-type.yaml new file mode 100644 index 000000000..4b1833125 --- /dev/null +++ b/evals/tasks/core/filter-events-by-type/filter-events-by-type.yaml @@ -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" diff --git a/evals/tasks/core/filter-events-by-type/setup.sh b/evals/tasks/core/filter-events-by-type/setup.sh new file mode 100755 index 000000000..ef0fdbd97 --- /dev/null +++ b/evals/tasks/core/filter-events-by-type/setup.sh @@ -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 </dev/null | grep -q "."; then + exit 0 + fi + sleep 2 +done + +echo "Warning events did not appear in time" +exit 1 diff --git a/evals/tasks/core/filter-namespace-by-name/cleanup.sh b/evals/tasks/core/filter-namespace-by-name/cleanup.sh new file mode 100755 index 000000000..02c3e487e --- /dev/null +++ b/evals/tasks/core/filter-namespace-by-name/cleanup.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +kubectl delete namespace ns-alpha ns-beta ns-gamma --ignore-not-found diff --git a/evals/tasks/core/filter-namespace-by-name/filter-namespace-by-name.yaml b/evals/tasks/core/filter-namespace-by-name/filter-namespace-by-name.yaml new file mode 100644 index 000000000..822447f8d --- /dev/null +++ b/evals/tasks/core/filter-namespace-by-name/filter-namespace-by-name.yaml @@ -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" diff --git a/evals/tasks/core/filter-namespace-by-name/setup.sh b/evals/tasks/core/filter-namespace-by-name/setup.sh new file mode 100755 index 000000000..351c21c61 --- /dev/null +++ b/evals/tasks/core/filter-namespace-by-name/setup.sh @@ -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 diff --git a/pkg/kubernetes/events.go b/pkg/kubernetes/events.go index 73133087e..c66fe9215 100644 --- a/pkg/kubernetes/events.go +++ b/pkg/kubernetes/events.go @@ -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 } diff --git a/pkg/mcp/events_test.go b/pkg/mcp/events_test.go index dc64ab7d5..21e749482 100644 --- a/pkg/mcp/events_test.go +++ b/pkg/mcp/events_test.go @@ -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)) } diff --git a/pkg/mcp/namespaces_test.go b/pkg/mcp/namespaces_test.go index 522bcc7d6..b540266c8 100644 --- a/pkg/mcp/namespaces_test.go +++ b/pkg/mcp/namespaces_test.go @@ -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() { diff --git a/pkg/mcp/testdata/toolsets-core-tools.json b/pkg/mcp/testdata/toolsets-core-tools.json index 4244a9534..e763fddf4 100644 --- a/pkg/mcp/testdata/toolsets-core-tools.json +++ b/pkg/mcp/testdata/toolsets-core-tools.json @@ -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" @@ -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", @@ -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": { @@ -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": { @@ -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": { diff --git a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json index 1af5692a2..ecfe6fd4d 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-multicluster.json +++ b/pkg/mcp/testdata/toolsets-full-tools-multicluster.json @@ -49,6 +49,11 @@ "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", "type": "string" }, + "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" @@ -72,6 +77,11 @@ "context": { "description": "Optional parameter selecting which context to run the tool in. Defaults to fake-context if not set", "type": "string" + }, + "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" @@ -294,7 +304,7 @@ }, "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": { @@ -324,7 +334,7 @@ }, "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": { @@ -600,7 +610,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": { diff --git a/pkg/mcp/testdata/toolsets-full-tools-openshift.json b/pkg/mcp/testdata/toolsets-full-tools-openshift.json index b3094c839..fffb273ba 100644 --- a/pkg/mcp/testdata/toolsets-full-tools-openshift.json +++ b/pkg/mcp/testdata/toolsets-full-tools-openshift.json @@ -29,6 +29,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" @@ -48,7 +53,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", @@ -241,7 +252,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": { @@ -267,7 +278,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": { @@ -530,7 +541,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": { diff --git a/pkg/mcp/testdata/toolsets-full-tools.json b/pkg/mcp/testdata/toolsets-full-tools.json index 69bcbd5c4..1ce4475a2 100644 --- a/pkg/mcp/testdata/toolsets-full-tools.json +++ b/pkg/mcp/testdata/toolsets-full-tools.json @@ -29,6 +29,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" @@ -48,7 +53,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", @@ -241,7 +252,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": { @@ -267,7 +278,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": { @@ -515,7 +526,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": { diff --git a/pkg/mcp/toolsets_test.go b/pkg/mcp/toolsets_test.go index b83dbf278..0b0e1579c 100644 --- a/pkg/mcp/toolsets_test.go +++ b/pkg/mcp/toolsets_test.go @@ -200,26 +200,27 @@ func (s *ToolsetsSuite) TestGranularToolsetsTools() { func (s *ToolsetsSuite) TestInputSchemaEdgeCases() { //https://github.com/containers/kubernetes-mcp-server/issues/340 s.Run("InputSchema for no-arg tool is object with empty properties", func() { + s.Handle(test.NewInOpenShiftHandler()) s.InitMcpClient() tools, err := s.ListTools() s.Run("ListTools returns tools", func() { s.NotNil(tools, "Expected tools from ListTools") s.NoError(err, "Expected no error from ListTools") }) - var namespacesList *mcp.Tool + var projectsList *mcp.Tool for _, t := range tools.Tools { - if t.Name == "namespaces_list" { - namespacesList = t + if t.Name == "projects_list" { + projectsList = t break } } - s.Require().NotNil(namespacesList, "Expected namespaces_list from ListTools") - schema, ok := namespacesList.InputSchema.(map[string]any) + s.Require().NotNil(projectsList, "Expected projects_list from ListTools") + schema, ok := projectsList.InputSchema.(map[string]any) s.Require().True(ok, "Expected InputSchema to be map[string]any") - s.NotNil(schema["properties"], "Expected namespaces_list.InputSchema.properties not to be nil") + s.NotNil(schema["properties"], "Expected projects_list.InputSchema.properties not to be nil") properties, ok := schema["properties"].(map[string]any) s.Require().True(ok, "Expected properties to be map[string]any") - s.Empty(properties, "Expected namespaces_list.InputSchema.properties to be empty") + s.Empty(properties, "Expected projects_list.InputSchema.properties to be empty") }) // https://github.com/containers/kubernetes-mcp-server/issues/717 // Verifies ALL tools have Properties initialized (not just cluster-aware ones) diff --git a/pkg/toolsets/core/events.go b/pkg/toolsets/core/events.go index c429f3a19..546c2ddac 100644 --- a/pkg/toolsets/core/events.go +++ b/pkg/toolsets/core/events.go @@ -23,6 +23,11 @@ func initEvents() []api.ServerTool { Type: "string", Description: "Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces", }, + "fieldSelector": { + Type: "string", + 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: REGEX_FIELDSELECTOR, + }, }, }, Annotations: api.ToolAnnotations{ @@ -38,10 +43,12 @@ func initEvents() []api.ServerTool { func eventsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { p := api.WrapParams(params) namespace := p.OptionalString("namespace", "") + options := api.ListOptions{} + options.FieldSelector = p.OptionalString("fieldSelector", "") if err := p.Err(); err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %w", err)), nil } - eventMap, err := kubernetes.NewCore(params).EventsList(params, namespace) + eventMap, err := kubernetes.NewCore(params).EventsList(params, namespace, options) if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list events in all namespaces: %w", err)), nil } diff --git a/pkg/toolsets/core/namespaces.go b/pkg/toolsets/core/namespaces.go index 1538cbe0e..9e6e9ce22 100644 --- a/pkg/toolsets/core/namespaces.go +++ b/pkg/toolsets/core/namespaces.go @@ -19,6 +19,13 @@ func initNamespaces(o api.Openshift) []api.ServerTool { Description: "List all the Kubernetes namespaces in the current cluster", InputSchema: &jsonschema.Schema{ Type: "object", + Properties: map[string]*jsonschema.Schema{ + "fieldSelector": { + Type: "string", + 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: REGEX_FIELDSELECTOR, + }, + }, }, Annotations: api.ToolAnnotations{ Title: "Namespaces: List", @@ -49,7 +56,13 @@ func initNamespaces(o api.Openshift) []api.ServerTool { } func namespacesList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { - ret, err := kubernetes.NewCore(params).NamespacesList(params, api.ListOptions{AsTable: params.ListOutput.AsTable()}) + p := api.WrapParams(params) + options := api.ListOptions{AsTable: params.ListOutput.AsTable()} + options.FieldSelector = p.OptionalString("fieldSelector", "") + if err := p.Err(); err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %w", err)), nil + } + ret, err := kubernetes.NewCore(params).NamespacesList(params, options) if err != nil { return api.NewToolCallResult("", fmt.Errorf("failed to list namespaces: %w", err)), nil } diff --git a/pkg/toolsets/core/regex_test.go b/pkg/toolsets/core/regex_test.go index 3811a7214..7ad0e8d83 100644 --- a/pkg/toolsets/core/regex_test.go +++ b/pkg/toolsets/core/regex_test.go @@ -38,6 +38,8 @@ var validFieldSelectors = []struct{ selector string }{ {"status.phase=Running"}, {"metadata.namespace!=default"}, {"metadata.name=my-service"}, + {"involvedObject.apiVersion=apps/v1"}, + {"status.phase=Running,metadata.name=my-pod"}, } func Test_FieldSelectorRegex_is_valid(t *testing.T) { @@ -49,3 +51,24 @@ func Test_FieldSelectorRegex_is_valid(t *testing.T) { }) } } + +var invalidFieldSelectors = []struct{ selector string }{ + {""}, + {" "}, + {"metadata.name"}, + {"=my-service"}, + {"metadata.name =my-service"}, + {"metadata.name= my-service"}, + {"metadata.name=my service"}, + {"!!!"}, +} + +func Test_FieldSelectorRegex_is_invalid(t *testing.T) { + for _, tc := range invalidFieldSelectors { + t.Run(fmt.Sprint("Selector should be invalid: ", tc.selector), func(t *testing.T) { + if match, _ := regexp.MatchString(core.REGEX_FIELDSELECTOR, tc.selector); match { + t.Errorf("Pattern %s should not match invalid selector: %s", core.REGEX_FIELDSELECTOR, tc.selector) + } + }) + } +} diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go index c8d9197d6..ec74b73f2 100644 --- a/pkg/toolsets/core/toolset.go +++ b/pkg/toolsets/core/toolset.go @@ -11,7 +11,7 @@ import ( const REGEX_LABELSELECTOR_VALID_CHARS = "^([/_.\\-A-Za-z0-9=, ()!])+$" // Details: https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/ -const REGEX_FIELDSELECTOR = "^[.\\-A-Za-z0-9]+([=!,]{1,2}[.\\-A-Za-z0-9]+)+$" +const REGEX_FIELDSELECTOR = "^[.\\-A-Za-z0-9]+([=!,]{1,2}[./\\-A-Za-z0-9]+)+$" type Toolset struct{} diff --git a/pkg/toolsets/kubevirt/vm_troubleshoot.go b/pkg/toolsets/kubevirt/vm_troubleshoot.go index b9467bf8b..ef9ce1fa4 100644 --- a/pkg/toolsets/kubevirt/vm_troubleshoot.go +++ b/pkg/toolsets/kubevirt/vm_troubleshoot.go @@ -358,31 +358,40 @@ func fetchVirtLauncherPodLogs(ctx context.Context, client api.KubernetesClient, // fetchEvents fetches events related to the VM and returns them formatted func fetchEvents(ctx context.Context, client api.KubernetesClient, namespace, vmName string) string { core := kubernetes.NewCore(client) - eventMap, err := core.EventsList(ctx, namespace) + + // Server-side filter: events whose involvedObject.name exactly matches the VM name + vmEvents, err := core.EventsList(ctx, namespace, api.ListOptions{ + ListOptions: metav1.ListOptions{FieldSelector: "involvedObject.name=" + vmName}, + }) if err != nil { return fmt.Sprintf("### Events\n\n*Error listing events: %v*", err) } - if len(eventMap) == 0 { - return "### Events\n\n*No events found in namespace*" - } - - // Filter events related to the VM var relatedEvents []map[string]any - for _, event := range eventMap { + for _, event := range vmEvents { involvedObj, ok := event["InvolvedObject"].(map[string]string) if !ok { continue } - objName := involvedObj["Name"] objKind := involvedObj["Kind"] - // Include events for VM, VMI - if objName == vmName && (objKind == "VirtualMachine" || objKind == "VirtualMachineInstance") { + if objKind == "VirtualMachine" || objKind == "VirtualMachineInstance" { relatedEvents = append(relatedEvents, event) - continue } + } + // Prefix-based matches (virt-launcher pods, vmName-suffixed objects) can't be expressed + // as a field selector; list all events in the namespace and filter client-side. + allEvents, err := core.EventsList(ctx, namespace, api.ListOptions{}) + if err != nil { + return fmt.Sprintf("### Events\n\n*Error listing events: %v*", err) + } + for _, event := range allEvents { + involvedObj, ok := event["InvolvedObject"].(map[string]string) + if !ok { + continue + } + objName := involvedObj["Name"] // Include events for pods with VM name prefix if strings.HasPrefix(objName, vmName+"-") || strings.HasPrefix(objName, "virt-launcher-"+vmName) { relatedEvents = append(relatedEvents, event)