diff --git a/pkg/k8s/k8s.go b/pkg/k8s/k8s.go index f84a047..9a562f3 100644 --- a/pkg/k8s/k8s.go +++ b/pkg/k8s/k8s.go @@ -571,6 +571,28 @@ func (k *K8sTool) handleGenerateResource(ctx context.Context, request mcp.CallTo return mcp.NewToolResultText(responseText), nil } +func (k *K8sTool) handleWaitForCondition(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + resourceType := mcp.ParseString(request, "resource_type", "") + resourceName := mcp.ParseString(request, "resource_name", "") + condition := mcp.ParseString(request, "condition", "") + namespace := mcp.ParseString(request, "namespace", "default") + timeoutSeconds := mcp.ParseInt(request, "timeout_seconds", 60) + + if resourceType == "" || resourceName == "" || condition == "" { + return mcp.NewToolResultError("resource_type, resource_name, and condition are required"), nil + } + if timeoutSeconds <= 0 { + return mcp.NewToolResultError("timeout_seconds must be greater than zero"), nil + } + + return k.runKubectlCommand(ctx, request.Header, + "wait", resourceType+"/"+resourceName, + "--for=condition="+condition, + "-n", namespace, + "--timeout="+fmt.Sprintf("%ds", timeoutSeconds), + ) +} + // extractBearerToken extracts the Bearer token from the Authorization header func extractBearerToken(headers http.Header) string { if auth := headers.Get("Authorization"); auth != "" { @@ -707,6 +729,15 @@ func RegisterTools(s *server.MCPServer, llm llms.Model, kubeconfig string, readO mcp.WithString("resource_type", mcp.Description(fmt.Sprintf("Type of resource to generate (%s)", strings.Join(slices.Collect(resourceTypes), ", "))), mcp.Required()), ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_generate_resource", k8sTool.handleGenerateResource))) + s.AddTool(mcp.NewTool("k8s_wait_for_condition", + mcp.WithDescription("Wait until a Kubernetes resource reaches a specific condition. Uses kubectl wait under the hood and blocks until the condition is met or the timeout expires. Avoids polling loops and saves LLM turns."), + mcp.WithString("resource_type", mcp.Description("Type of resource (deployment, pod, job, etc.)"), mcp.Required()), + mcp.WithString("resource_name", mcp.Description("Name of the resource"), mcp.Required()), + mcp.WithString("condition", mcp.Description("Condition to wait for (Available, Ready, Complete, etc.)"), mcp.Required()), + mcp.WithString("namespace", mcp.Description("Namespace of the resource (default: default)")), + mcp.WithNumber("timeout_seconds", mcp.Description("Maximum time to wait in seconds (default: 60)")), + ), telemetry.AdaptToolHandler(telemetry.WithTracing("k8s_wait_for_condition", k8sTool.handleWaitForCondition))) + // Write tools - only registered when write operations are enabled if !readOnly { s.AddTool(mcp.NewTool("k8s_scale", diff --git a/pkg/k8s/k8s_test.go b/pkg/k8s/k8s_test.go index 44df8a9..0b35c1c 100644 --- a/pkg/k8s/k8s_test.go +++ b/pkg/k8s/k8s_test.go @@ -1585,3 +1585,71 @@ metadata: assert.NotContains(t, callLog[0].Args, "--token") }) } + +func TestHandleWaitForCondition(t *testing.T) { + k8sTool := newTestK8sTool() + + tests := []struct { + name string + args map[string]interface{} + mock []string + mockErr error + wantErr bool + }{ + { + name: "success with defaults", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "myapp", "condition": "Available"}, + mock: []string{"wait", "deployment/myapp", "--for=condition=Available", "-n", "default", "--timeout=60s"}, + }, + { + name: "success with explicit namespace and timeout", + args: map[string]interface{}{"resource_type": "pod", "resource_name": "mypod", "condition": "Ready", "namespace": "kube-system", "timeout_seconds": float64(120)}, + mock: []string{"wait", "pod/mypod", "--for=condition=Ready", "-n", "kube-system", "--timeout=120s"}, + }, + { + name: "missing resource_type", + args: map[string]interface{}{"resource_name": "myapp", "condition": "Available"}, + wantErr: true, + }, + { + name: "missing resource_name", + args: map[string]interface{}{"resource_type": "deployment", "condition": "Available"}, + wantErr: true, + }, + { + name: "missing condition", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "myapp"}, + wantErr: true, + }, + { + name: "zero timeout", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "myapp", "condition": "Available", "timeout_seconds": float64(0)}, + wantErr: true, + }, + { + name: "kubectl error propagated", + args: map[string]interface{}{"resource_type": "deployment", "resource_name": "slow-app", "condition": "Available", "timeout_seconds": float64(5)}, + mock: []string{"wait", "deployment/slow-app", "--for=condition=Available", "-n", "default", "--timeout=5s"}, + mockErr: assert.AnError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := cmd.NewMockShellExecutor() + if tt.mock != nil { + mock.AddCommandString("kubectl", tt.mock, "", tt.mockErr) + } + ctx := cmd.WithShellExecutor(context.Background(), mock) + + req := mcp.CallToolRequest{} + req.Params.Arguments = tt.args + + result, err := k8sTool.handleWaitForCondition(ctx, req) + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, tt.wantErr, result.IsError) + }) + } +}