From ac7a53d5f76730d912989ea1ce1dcf20e6551450 Mon Sep 17 00:00:00 2001 From: Golden Garlic <148346166+garlicKim21@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:50:49 +0000 Subject: [PATCH] fix(k8s): describe the scope and image columns k8s_get_resources actually has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description is one line — "Get Kubernetes resources using kubectl" — and leaves out two behaviours an agent cannot infer. Both produced wrong answers for me this week with a third-party agent. Default scope is the tool's own namespace, not the cluster. Asked to check the cluster, the agent called k8s_get_resources{resource_type: "pod"} and got back only the namespace kagent runs in, reported that as the cluster, and concluded nothing needed attention. "Query all namespaces (true/false)" says what the flag does, never what omitting it means. `-o wide` shows images for workloads but not for pods. After the agent's discovery instructions were widened it surveyed pods across all namespaces — a listing whose columns are NAME/READY/STATUS/RESTARTS/AGE/IP/NODE and no image. With no version in anything it had read, the model supplied a plausible one for a CNI running a quite different version, and every comparison after that was confidently wrong with no signal until a human noticed. helm/agents/k8s/templates/agent.yaml in the main repo already tells its own agent "always prefer wide output unless specified otherwise". That knowledge lives in one agent's prompt; every other agent rediscovers it by failing. all_namespaces is declared as a string and was compared with == "true", so a model emitting a JSON boolean had --all-namespaces dropped silently and got a single-namespace answer with no error. The repo's own e2e mock in the main repo passes it as a boolean. mcp.ParseBoolean uses cast.ToBool and accepts both, so the string spelling keeps working. Signed-off-by: Golden Garlic <148346166+garlicKim21@users.noreply.github.com> --- pkg/k8s/k8s.go | 21 +++++++++++++++----- pkg/k8s/k8s_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index 6def2f2..bd07889 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -59,7 +59,11 @@ func (k *K8sTool) handleKubectlGetEnhanced(ctx context.Context, request mcp.Call resourceType := mcp.ParseString(request, "resource_type", "") resourceName := mcp.ParseString(request, "resource_name", "") namespace := mcp.ParseString(request, "namespace", "") - allNamespaces := mcp.ParseString(request, "all_namespaces", "") == "true" + // The schema declares all_namespaces as a string, but models routinely send a + // JSON boolean instead. Comparing only against the string "true" dropped + // --all-namespaces silently: the caller asked for the cluster and got a single + // namespace back with no error. ParseBoolean accepts both spellings. + allNamespaces := mcp.ParseBoolean(request, "all_namespaces", false) output := mcp.ParseString(request, "output", "wide") if resourceType == "" { @@ -647,12 +651,19 @@ func RegisterTools(s *server.MCPServer, llm llms.Model, kubeconfig string, readO // Read-only tools - always registered s.AddTool(mcp.NewTool("k8s_get_resources", - mcp.WithDescription("Get Kubernetes resources using kubectl"), + mcp.WithDescription("List Kubernetes resources with kubectl. "+ + "Scope: with neither all_namespaces nor namespace, this queries ONLY the namespace this tool "+ + "runs in, not the cluster. "+ + "Images: the default wide output has a CONTAINERS/IMAGES column for workloads "+ + "(deployment, daemonset, statefulset, replicaset, job, cronjob) but NOT for pods, so a pod "+ + "listing is never a source of image versions. Read versions from a workload listing, or use "+ + "k8s_get_resource_yaml for static pods (the control plane) and for the authoritative spec. "+ + "Node versions (kubelet, container runtime): resource_type=node."), mcp.WithString("resource_type", mcp.Description("Type of resource (pod, service, deployment, etc.)"), mcp.Required()), mcp.WithString("resource_name", mcp.Description("Name of specific resource (optional)")), - mcp.WithString("namespace", mcp.Description("Namespace to query (optional)")), - mcp.WithString("all_namespaces", mcp.Description("Query all namespaces (true/false)")), - mcp.WithString("output", mcp.Description("Output format (json, yaml, wide)"), mcp.DefaultString("wide")), + mcp.WithString("namespace", mcp.Description("Namespace to query. Omitted, and without all_namespaces, the tool queries its own namespace")), + mcp.WithString("all_namespaces", mcp.Description(`Query all namespaces ("true"/"false")`)), + mcp.WithString("output", mcp.Description("Output format (json, yaml, wide). Defaults to wide"), mcp.DefaultString("wide")), ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_get_resources", k8sTool.handleKubectlGetEnhanced))) s.AddTool(mcp.NewTool("k8s_get_pod_logs", diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index 6c008a2..c22d559 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -561,6 +561,53 @@ func TestHandleKubectlGetEnhanced(t *testing.T) { assert.NotNil(t, result) assert.False(t, result.IsError) }) + + // all_namespaces is declared as a string, but models send a JSON boolean just + // as readily. Both must reach kubectl as --all-namespaces; silently dropping + // the flag returns one namespace to a caller who asked for the cluster. + for _, tc := range []struct { + name string + value interface{} + }{ + {"all_namespaces as string", "true"}, + {"all_namespaces as boolean", true}, + } { + t.Run(tc.name, func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("kubectl", []string{"get", "pods", "--all-namespaces", "-o", "wide"}, `NAMESPACE NAME`, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"resource_type": "pods", "all_namespaces": tc.value} + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + assert.Len(t, callLog, 1) + assert.Contains(t, callLog[0].Args, "--all-namespaces") + }) + } + + t.Run("all_namespaces false keeps the query namespaced", func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + mock.AddCommandString("kubectl", []string{"get", "pods", "-n", "kube-system", "-o", "wide"}, `NAME`, nil) + ctx := cmd.WithShellExecutor(ctx, mock) + + k8sTool := newTestK8sTool() + req := mcp.CallToolRequest{} + req.Params.Arguments = map[string]interface{}{"resource_type": "pods", "all_namespaces": false, "namespace": "kube-system"} + result, err := k8sTool.handleKubectlGetEnhanced(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsError) + + callLog := mock.GetCallLog() + assert.Len(t, callLog, 1) + assert.NotContains(t, callLog[0].Args, "--all-namespaces") + }) } func TestHandleKubectlLogsEnhanced(t *testing.T) {