Skip to content
Open
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
31 changes: 31 additions & 0 deletions pkg/k8s/k8s.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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",
Expand Down
68 changes: 68 additions & 0 deletions pkg/k8s/k8s_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}