diff --git a/README.md b/README.md index ee592bd5..c760d9cb 100644 --- a/README.md +++ b/README.md @@ -240,6 +240,11 @@ In case multi-cluster support is enabled (default) and you have access to multip - **events_list** - List all the Kubernetes events in the current cluster from all namespaces - `namespace` (`string`) - Optional Namespace to retrieve the events from. If not provided, will list events from all namespaces +- **cluster_health_check** - Perform comprehensive health check on Kubernetes/OpenShift cluster and report issues. Examines cluster operators (OpenShift), nodes, deployments, pods, persistent volumes, and events to identify problems affecting cluster stability or workload availability. + - `check_events` (`boolean`) - Include recent warning events in the health check (may increase execution time) + - `output_format` (`string`) - Output format for results: 'text' (human-readable) or 'json' (machine-readable) + - `verbose` (`boolean`) - Enable detailed output with additional context and resource-level details + - **namespaces_list** - List all the Kubernetes namespaces in the current cluster - **projects_list** - List all the OpenShift projects in the current cluster diff --git a/pkg/api/prompts.go b/pkg/api/prompts.go new file mode 100644 index 00000000..5cd5b436 --- /dev/null +++ b/pkg/api/prompts.go @@ -0,0 +1,36 @@ +package api + +import ( + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +// ServerPrompt represents a prompt that can be provided to the MCP server +type ServerPrompt struct { + Name string + Description string + Arguments []PromptArgument + GetMessages func(arguments map[string]string) []PromptMessage +} + +// PromptArgument defines an argument that can be passed to a prompt +type PromptArgument struct { + Name string + Description string + Required bool +} + +// PromptMessage represents a message in a prompt +type PromptMessage struct { + Role string // "user" or "assistant" + Content string +} + +// PromptSet groups related prompts together +type PromptSet interface { + // GetName returns the name of the prompt set + GetName() string + // GetDescription returns a description of what this prompt set provides + GetDescription() string + // GetPrompts returns all prompts in this set + GetPrompts(o internalk8s.Openshift) []ServerPrompt +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 81bec2b7..bddc2868 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,6 +31,7 @@ type StaticConfig struct { // When true, disable tools annotated with destructiveHint=true DisableDestructive bool `toml:"disable_destructive,omitempty"` Toolsets []string `toml:"toolsets,omitempty"` + Promptsets []string `toml:"promptsets,omitempty"` EnabledTools []string `toml:"enabled_tools,omitempty"` DisabledTools []string `toml:"disabled_tools,omitempty"` diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index f64d4104..140c46e0 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -17,6 +17,7 @@ import ( "github.com/containers/kubernetes-mcp-server/pkg/config" internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" "github.com/containers/kubernetes-mcp-server/pkg/output" + "github.com/containers/kubernetes-mcp-server/pkg/promptsets" "github.com/containers/kubernetes-mcp-server/pkg/toolsets" "github.com/containers/kubernetes-mcp-server/pkg/version" ) @@ -29,6 +30,7 @@ type Configuration struct { *config.StaticConfig listOutput output.Output toolsets []api.Toolset + promptsets []api.PromptSet } func (c *Configuration) Toolsets() []api.Toolset { @@ -40,6 +42,23 @@ func (c *Configuration) Toolsets() []api.Toolset { return c.toolsets } +func (c *Configuration) Promptsets() []api.PromptSet { + if c.promptsets == nil { + // Default to core if no promptsets configured + promptsetNames := c.StaticConfig.Promptsets + if len(promptsetNames) == 0 { + promptsetNames = []string{"core"} + } + for _, promptset := range promptsetNames { + ps := promptsets.PromptSetFromString(promptset) + if ps != nil { + c.promptsets = append(c.promptsets, ps) + } + } + } + return c.promptsets +} + func (c *Configuration) ListOutput() output.Output { if c.listOutput == nil { c.listOutput = output.FromString(c.StaticConfig.ListOutput) @@ -148,11 +167,37 @@ func (s *Server) reloadKubernetesClusterProvider() error { s.server.SetTools(m3labsServerTools...) + // Register prompts + if err := s.registerPrompts(p); err != nil { + klog.Warningf("Failed to register prompts: %v", err) + // Don't fail the whole reload if prompts fail + } + // start new watch s.p.WatchTargets(s.reloadKubernetesClusterProvider) return nil } +// registerPrompts loads and registers all prompts with the MCP server +func (s *Server) registerPrompts(p internalk8s.Provider) error { + allPrompts := make([]api.ServerPrompt, 0) + for _, ps := range s.configuration.Promptsets() { + prompts := ps.GetPrompts(p) + allPrompts = append(allPrompts, prompts...) + klog.V(5).Infof("Loaded %d prompts from promptset '%s'", len(prompts), ps.GetName()) + } + + m3labsPrompts, err := ServerPromptToM3LabsPrompt(allPrompts) + if err != nil { + return fmt.Errorf("failed to convert prompts: %v", err) + } + + s.server.SetPrompts(m3labsPrompts...) + klog.V(3).Infof("Registered %d prompts", len(m3labsPrompts)) + + return nil +} + func (s *Server) ServeStdio() error { return server.ServeStdio(s.server) } diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 9dca88e4..c187c31d 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -32,15 +32,21 @@ func (s *WatchKubeConfigSuite) TestNotifiesToolsChange() { s.InitMcpClient() withTimeout, cancel := context.WithTimeout(s.T().Context(), 5*time.Second) defer cancel() - var notification *mcp.JSONRPCNotification + var toolsNotification *mcp.JSONRPCNotification + var promptsNotification *mcp.JSONRPCNotification s.OnNotification(func(n mcp.JSONRPCNotification) { - notification = &n + if n.Method == "notifications/tools/list_changed" { + toolsNotification = &n + } + if n.Method == "notifications/prompts/list_changed" { + promptsNotification = &n + } }) // When f, _ := os.OpenFile(s.Cfg.KubeConfig, os.O_APPEND|os.O_WRONLY, 0644) _, _ = f.WriteString("\n") _ = f.Close() - for notification == nil { + for toolsNotification == nil || promptsNotification == nil { select { case <-withTimeout.Done(): s.FailNow("timeout waiting for WatchKubeConfig notification") @@ -49,8 +55,10 @@ func (s *WatchKubeConfigSuite) TestNotifiesToolsChange() { } } // Then - s.NotNil(notification, "WatchKubeConfig did not notify") - s.Equal("notifications/tools/list_changed", notification.Method, "WatchKubeConfig did not notify tools change") + s.NotNil(toolsNotification, "WatchKubeConfig did not notify tools change") + s.Equal("notifications/tools/list_changed", toolsNotification.Method, "WatchKubeConfig did not notify tools change") + s.NotNil(promptsNotification, "WatchKubeConfig did not notify prompts change") + s.Equal("notifications/prompts/list_changed", promptsNotification.Method, "WatchKubeConfig did not notify prompts change") } func TestWatchKubeConfig(t *testing.T) { diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index 3295d72b..af7bcea0 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -1,5 +1,6 @@ package mcp +import _ "github.com/containers/kubernetes-mcp-server/pkg/promptsets/core" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core" import _ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm" diff --git a/pkg/mcp/prompts.go b/pkg/mcp/prompts.go new file mode 100644 index 00000000..8c42659e --- /dev/null +++ b/pkg/mcp/prompts.go @@ -0,0 +1,72 @@ +package mcp + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +// ServerPromptToM3LabsPrompt converts our internal ServerPrompt to mcp-go ServerPrompt format +func ServerPromptToM3LabsPrompt(prompts []api.ServerPrompt) ([]server.ServerPrompt, error) { + m3labsPrompts := make([]server.ServerPrompt, 0, len(prompts)) + + for _, prompt := range prompts { + // Convert arguments + arguments := make([]mcp.PromptArgument, 0, len(prompt.Arguments)) + for _, arg := range prompt.Arguments { + arguments = append(arguments, mcp.PromptArgument{ + Name: arg.Name, + Description: arg.Description, + Required: arg.Required, + }) + } + + // Create the prompt handler + handler := createPromptHandler(prompt) + + m3labsPrompts = append(m3labsPrompts, server.ServerPrompt{ + Prompt: mcp.Prompt{ + Name: prompt.Name, + Description: prompt.Description, + Arguments: arguments, + }, + Handler: handler, + }) + } + + return m3labsPrompts, nil +} + +// createPromptHandler creates a handler function for a prompt +func createPromptHandler(prompt api.ServerPrompt) server.PromptHandlerFunc { + return func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + // Get arguments from the request (already a map[string]string) + arguments := request.Params.Arguments + if arguments == nil { + arguments = make(map[string]string) + } + + // Get messages from the prompt + promptMessages := prompt.GetMessages(arguments) + + // Convert to mcp-go format + messages := make([]mcp.PromptMessage, 0, len(promptMessages)) + for _, msg := range promptMessages { + messages = append(messages, mcp.PromptMessage{ + Role: mcp.Role(msg.Role), + Content: mcp.TextContent{ + Type: "text", + Text: msg.Content, + }, + }) + } + + return &mcp.GetPromptResult{ + Description: prompt.Description, + Messages: messages, + }, nil + } +} diff --git a/pkg/mcp/prompts_test.go b/pkg/mcp/prompts_test.go new file mode 100644 index 00000000..2be59ac9 --- /dev/null +++ b/pkg/mcp/prompts_test.go @@ -0,0 +1,185 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func TestServerPromptToM3LabsPrompt(t *testing.T) { + t.Run("Converts empty prompt list", func(t *testing.T) { + // Given + prompts := []api.ServerPrompt{} + + // When + result, err := ServerPromptToM3LabsPrompt(prompts) + + // Then + require.NoError(t, err) + assert.Empty(t, result) + }) + + t.Run("Converts single prompt correctly", func(t *testing.T) { + // Given + prompts := []api.ServerPrompt{ + { + Name: "test_prompt", + Description: "Test prompt description", + Arguments: []api.PromptArgument{ + { + Name: "arg1", + Description: "Argument 1", + Required: true, + }, + }, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + return []api.PromptMessage{ + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there"}, + } + }, + }, + } + + // When + result, err := ServerPromptToM3LabsPrompt(prompts) + + // Then + require.NoError(t, err) + require.Len(t, result, 1) + + assert.Equal(t, "test_prompt", result[0].Prompt.Name) + assert.Equal(t, "Test prompt description", result[0].Prompt.Description) + require.Len(t, result[0].Prompt.Arguments, 1) + assert.Equal(t, "arg1", result[0].Prompt.Arguments[0].Name) + assert.Equal(t, "Argument 1", result[0].Prompt.Arguments[0].Description) + assert.True(t, result[0].Prompt.Arguments[0].Required) + }) + + t.Run("Converts multiple prompts correctly", func(t *testing.T) { + // Given + prompts := []api.ServerPrompt{ + { + Name: "prompt1", + Description: "First prompt", + Arguments: []api.PromptArgument{}, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + return []api.PromptMessage{{Role: "user", Content: "test1"}} + }, + }, + { + Name: "prompt2", + Description: "Second prompt", + Arguments: []api.PromptArgument{}, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + return []api.PromptMessage{{Role: "user", Content: "test2"}} + }, + }, + } + + // When + result, err := ServerPromptToM3LabsPrompt(prompts) + + // Then + require.NoError(t, err) + assert.Len(t, result, 2) + assert.Equal(t, "prompt1", result[0].Prompt.Name) + assert.Equal(t, "prompt2", result[1].Prompt.Name) + }) +} + +func TestCreatePromptHandler(t *testing.T) { + t.Run("Handler returns correct messages", func(t *testing.T) { + // Given + prompt := api.ServerPrompt{ + Name: "test", + Description: "Test prompt", + Arguments: []api.PromptArgument{}, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + return []api.PromptMessage{ + {Role: "user", Content: "Test message"}, + {Role: "assistant", Content: "Test response"}, + } + }, + } + + handler := createPromptHandler(prompt) + + // When + result, err := handler(context.Background(), mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{ + Arguments: map[string]string{}, + }, + }) + + // Then + require.NoError(t, err) + assert.Equal(t, "Test prompt", result.Description) + require.Len(t, result.Messages, 2) + assert.Equal(t, mcp.Role("user"), result.Messages[0].Role) + assert.Equal(t, "Test message", result.Messages[0].Content.(mcp.TextContent).Text) + assert.Equal(t, mcp.Role("assistant"), result.Messages[1].Role) + assert.Equal(t, "Test response", result.Messages[1].Content.(mcp.TextContent).Text) + }) + + t.Run("Handler uses provided arguments", func(t *testing.T) { + // Given + prompt := api.ServerPrompt{ + Name: "test", + Description: "Test prompt", + Arguments: []api.PromptArgument{ + {Name: "param1", Description: "Parameter 1", Required: false}, + }, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + value := arguments["param1"] + return []api.PromptMessage{ + {Role: "user", Content: "Value is: " + value}, + } + }, + } + + handler := createPromptHandler(prompt) + + // When + result, err := handler(context.Background(), mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{ + Arguments: map[string]string{"param1": "test_value"}, + }, + }) + + // Then + require.NoError(t, err) + require.Len(t, result.Messages, 1) + assert.Equal(t, "Value is: test_value", result.Messages[0].Content.(mcp.TextContent).Text) + }) + + t.Run("Handler handles nil arguments", func(t *testing.T) { + // Given + prompt := api.ServerPrompt{ + Name: "test", + Description: "Test prompt", + Arguments: []api.PromptArgument{}, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + return []api.PromptMessage{{Role: "user", Content: "test"}} + }, + } + + handler := createPromptHandler(prompt) + + // When + result, err := handler(context.Background(), mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{ + Arguments: nil, + }, + }) + + // Then + require.NoError(t, err) + require.Len(t, result.Messages, 1) + }) +} diff --git a/pkg/promptsets/core/health_check.go b/pkg/promptsets/core/health_check.go new file mode 100644 index 00000000..3050a26e --- /dev/null +++ b/pkg/promptsets/core/health_check.go @@ -0,0 +1,215 @@ +package core + +import ( + "fmt" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +const ( + // Health check configuration constants + defaultRestartThreshold = 5 + eventLookbackMinutes = 30 + maxWarningEvents = 20 +) + +// isVerboseEnabled checks if the verbose flag is enabled. +// It accepts "true", "1", "yes", or "y" (case-insensitive) as truthy values. +func isVerboseEnabled(value string) bool { + switch strings.ToLower(value) { + case "true", "1", "yes", "y": + return true + default: + return false + } +} + +// initHealthCheckPrompts creates prompts for cluster health diagnostics. +// These prompts guide LLMs to systematically check cluster components using existing tools. +func initHealthCheckPrompts() []api.ServerPrompt { + return []api.ServerPrompt{ + { + Name: "cluster_health_check", + Description: "Guide for performing comprehensive health check on Kubernetes/OpenShift clusters. Provides step-by-step instructions for examining cluster operators, nodes, pods, workloads, storage, and events to identify issues affecting cluster stability.", + Arguments: []api.PromptArgument{ + { + Name: "verbose", + Description: "Whether to include detailed diagnostics and resource-level information", + Required: false, + }, + { + Name: "namespace", + Description: "Limit health check to specific namespace (optional, defaults to all namespaces)", + Required: false, + }, + }, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + verbose := isVerboseEnabled(arguments["verbose"]) + namespace := arguments["namespace"] + + return buildHealthCheckPromptMessages(verbose, namespace) + }, + }, + } +} + +// buildHealthCheckPromptMessages constructs the prompt messages for cluster health checks. +// It adapts the instructions based on verbose mode and namespace filtering. +func buildHealthCheckPromptMessages(verbose bool, namespace string) []api.PromptMessage { + scopeMsg := "across all namespaces" + podListInstruction := "- Use pods_list to get all pods" + + if namespace != "" { + scopeMsg = fmt.Sprintf("in namespace '%s'", namespace) + podListInstruction = fmt.Sprintf("- Use pods_list_in_namespace with namespace '%s'", namespace) + } + + verboseMsg := "" + if verbose { + verboseMsg = "\n\nFor verbose mode, include additional details such as:\n" + + "- Specific error messages from conditions\n" + + "- Resource-level details (CPU/memory pressure types)\n" + + "- Individual pod and deployment names\n" + + "- Event messages and timestamps" + } + + // Construct the event display range dynamically using maxWarningEvents + eventDisplayRange := fmt.Sprintf("10-%d", maxWarningEvents) + + userMessage := fmt.Sprintf(`Please perform a comprehensive health check on the Kubernetes cluster %s. + +Follow these steps systematically: + +## 1. Check Cluster-Level Components + +### For OpenShift Clusters: +- Use resources_list with apiVersion=config.openshift.io/v1 and kind=ClusterOperator to check cluster operator health +- Look for operators with: + * Degraded=True (CRITICAL) + * Available=False (CRITICAL) + * Progressing=True (WARNING) + +### For All Kubernetes Clusters: +- Verify if this is an OpenShift cluster by checking for OpenShift-specific resources +- Note the cluster type in your report + +## 2. Check Node Health +- Use resources_list with apiVersion=v1 and kind=Node to examine all nodes +- Check each node for: + * Ready condition != True (CRITICAL) + * Unschedulable spec field = true (WARNING) + * MemoryPressure, DiskPressure, or PIDPressure conditions = True (WARNING) +- Count total nodes and categorize issues + +## 3. Check Pod Health +%s +- Identify problematic pods: + * Phase = Failed or Pending (CRITICAL) + * Container state waiting with reason: + - CrashLoopBackOff (CRITICAL) + - ImagePullBackOff or ErrImagePull (CRITICAL) + * RestartCount > %d (WARNING - configurable threshold) +- Group issues by type and count occurrences + +## 4. Check Workload Controllers +- Use resources_list for each workload type: + * apiVersion=apps/v1, kind=Deployment + * apiVersion=apps/v1, kind=StatefulSet + * apiVersion=apps/v1, kind=DaemonSet +- For each controller, compare: + * spec.replicas vs status.readyReplicas (Deployment/StatefulSet) + * status.desiredNumberScheduled vs status.numberReady (DaemonSet) + * Report mismatches as WARNINGs + +## 5. Check Storage +- Use resources_list with apiVersion=v1 and kind=PersistentVolumeClaim +- Identify PVCs not in Bound phase (WARNING) +- Note namespace and PVC name for each issue + +## 6. Check Recent Events (Optional) +- Use events_list to get cluster events +- Filter for: + * Type = Warning + * Timestamp within last %d minutes +- Limit to %s most recent warnings +- Include event message and involved object%s + +## Output Format + +Structure your health check report as follows: + +`+"```"+` +================================================ +Cluster Health Check Report +================================================ +Cluster Type: [Kubernetes/OpenShift] +Cluster Version: [if determinable] +Check Time: [current timestamp] +Scope: [all namespaces / specific namespace] + +### Cluster Operators (OpenShift only) +[Status with counts and specific issues] + +### Node Health +[Status with counts: total, not ready, unschedulable, under pressure] + +### Pod Health +[Status with counts: total, failed, crash looping, image pull errors, high restarts] + +### Workload Controllers +[Status for Deployments, StatefulSets, DaemonSets] + +### Storage +[PVC status: total, bound, pending/other] + +### Recent Events +[Warning events from last %d minutes] + +================================================ +Summary +================================================ +Critical Issues: [count] +Warnings: [count] + +[Overall assessment: healthy / has warnings / has critical issues] +`+"```"+` + +## Health Status Definitions + +- **CRITICAL**: Issues requiring immediate attention (e.g., pods failing, nodes not ready, degraded operators) +- **WARNING**: Issues that should be monitored (e.g., high restarts, progressing operators, resource pressure) +- **HEALTHY**: No issues detected + +## Important Notes + +- Use the existing tools (resources_list, pods_list, events_list, etc.) +- Be efficient: don't call the same tool multiple times unnecessarily +- If a resource type doesn't exist (e.g., ClusterOperator on vanilla K8s), skip it gracefully +- Provide clear, actionable insights in your summary +- Use emojis for visual clarity: ✅ (healthy), ⚠️ (warning), ❌ (critical) + +### Common apiVersion Values + +When using resources_list, specify the correct apiVersion for each resource type: +- Core resources: apiVersion=v1 (Pod, Service, Node, PersistentVolumeClaim, ConfigMap, Secret, Namespace) +- Apps: apiVersion=apps/v1 (Deployment, StatefulSet, DaemonSet, ReplicaSet) +- Batch: apiVersion=batch/v1 (Job, CronJob) +- RBAC: apiVersion=rbac.authorization.k8s.io/v1 (Role, RoleBinding, ClusterRole, ClusterRoleBinding) +- Networking: apiVersion=networking.k8s.io/v1 (Ingress, NetworkPolicy) +- OpenShift Config: apiVersion=config.openshift.io/v1 (ClusterOperator, ClusterVersion) +- OpenShift Routes: apiVersion=route.openshift.io/v1 (Route)`, scopeMsg, podListInstruction, defaultRestartThreshold, eventLookbackMinutes, eventDisplayRange, verboseMsg, eventLookbackMinutes) + + assistantMessage := `I'll perform a comprehensive cluster health check following the systematic approach outlined. Let me start by gathering information about the cluster components.` + + return []api.PromptMessage{ + { + Role: "user", + Content: userMessage, + }, + { + Role: "assistant", + Content: assistantMessage, + }, + } +} diff --git a/pkg/promptsets/core/health_check_test.go b/pkg/promptsets/core/health_check_test.go new file mode 100644 index 00000000..2968ad29 --- /dev/null +++ b/pkg/promptsets/core/health_check_test.go @@ -0,0 +1,371 @@ +package core + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsVerboseEnabled(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + {"true lowercase", "true", true}, + {"true capitalized", "True", true}, + {"true uppercase", "TRUE", true}, + {"numeric 1", "1", true}, + {"yes lowercase", "yes", true}, + {"yes capitalized", "Yes", true}, + {"yes uppercase", "YES", true}, + {"y lowercase", "y", true}, + {"y uppercase", "Y", true}, + {"false", "false", false}, + {"0", "0", false}, + {"no", "no", false}, + {"empty string", "", false}, + {"random string", "random", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isVerboseEnabled(tt.input) + assert.Equal(t, tt.expected, result, "isVerboseEnabled(%q) should return %v", tt.input, tt.expected) + }) + } +} + +func TestInitHealthCheckPrompts(t *testing.T) { + // When + prompts := initHealthCheckPrompts() + + // Then + require.Len(t, prompts, 1) + assert.Equal(t, "cluster_health_check", prompts[0].Name) + assert.Contains(t, prompts[0].Description, "comprehensive health check") + assert.Len(t, prompts[0].Arguments, 2) + + // Check arguments + assert.Equal(t, "verbose", prompts[0].Arguments[0].Name) + assert.False(t, prompts[0].Arguments[0].Required) + + assert.Equal(t, "namespace", prompts[0].Arguments[1].Name) + assert.False(t, prompts[0].Arguments[1].Required) +} + +func TestBuildHealthCheckPromptMessages(t *testing.T) { + t.Run("Default messages with no arguments", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + require.Len(t, messages, 2) + assert.Equal(t, "user", messages[0].Role) + assert.Equal(t, "assistant", messages[1].Role) + + // Check user message content + userContent := messages[0].Content + assert.Contains(t, userContent, "across all namespaces") + assert.Contains(t, userContent, "Use pods_list to get all pods") + assert.Contains(t, userContent, "resources_list") + assert.Contains(t, userContent, "events_list") + assert.NotContains(t, userContent, "pods_list_in_namespace") + + // Check assistant message + assert.Contains(t, messages[1].Content, "comprehensive cluster health check") + }) + + t.Run("Messages with namespace filter", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "test-namespace") + + // Then + require.Len(t, messages, 2) + + userContent := messages[0].Content + assert.Contains(t, userContent, "in namespace 'test-namespace'") + assert.NotContains(t, userContent, "across all namespaces") + assert.Contains(t, userContent, "Use pods_list_in_namespace with namespace 'test-namespace'") + assert.NotContains(t, userContent, "Use pods_list to get all pods") + }) + + t.Run("Messages with verbose mode", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(true, "") + + // Then + require.Len(t, messages, 2) + + userContent := messages[0].Content + assert.Contains(t, userContent, "For verbose mode") + assert.Contains(t, userContent, "Specific error messages") + assert.Contains(t, userContent, "Resource-level details") + assert.Contains(t, userContent, "Individual pod and deployment names") + }) + + t.Run("Messages with both verbose and namespace", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(true, "prod") + + // Then + require.Len(t, messages, 2) + + userContent := messages[0].Content + assert.Contains(t, userContent, "in namespace 'prod'") + assert.Contains(t, userContent, "For verbose mode") + }) + + t.Run("User message contains all required sections", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + userContent := messages[0].Content + + // Check for all main sections + sections := []string{ + "## 1. Check Cluster-Level Components", + "## 2. Check Node Health", + "## 3. Check Pod Health", + "## 4. Check Workload Controllers", + "## 5. Check Storage", + "## 6. Check Recent Events", + "## Output Format", + "## Health Status Definitions", + "## Important Notes", + } + + for _, section := range sections { + assert.Contains(t, userContent, section, "Missing section: %s", section) + } + }) + + t.Run("User message contains critical tool references", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + userContent := messages[0].Content + + // Check for tool names + tools := []string{ + "resources_list", + "pods_list", + "events_list", + } + + for _, tool := range tools { + assert.Contains(t, userContent, tool, "Missing tool reference: %s", tool) + } + }) + + t.Run("User message contains health check criteria", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + userContent := messages[0].Content + + // Check for critical conditions + criteria := []string{ + "Degraded=True (CRITICAL)", + "Available=False (CRITICAL)", + "Ready condition != True (CRITICAL)", + "CrashLoopBackOff (CRITICAL)", + "ImagePullBackOff", + "RestartCount > 5 (WARNING", + "MemoryPressure", + "DiskPressure", + } + + for _, criterion := range criteria { + assert.Contains(t, userContent, criterion, "Missing criterion: %s", criterion) + } + }) + + t.Run("User message contains workload types with apiVersions", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + userContent := messages[0].Content + + // Check for apiVersion + kind pairs + resourceSpecs := []string{ + "apiVersion=apps/v1, kind=Deployment", + "apiVersion=apps/v1, kind=StatefulSet", + "apiVersion=apps/v1, kind=DaemonSet", + "apiVersion=config.openshift.io/v1 and kind=ClusterOperator", + "apiVersion=v1 and kind=Node", + "apiVersion=v1 and kind=PersistentVolumeClaim", + } + + for _, spec := range resourceSpecs { + assert.Contains(t, userContent, spec, "Missing resource spec: %s", spec) + } + }) + + t.Run("User message contains output format template", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + userContent := messages[0].Content + + // Check for report structure + reportElements := []string{ + "Cluster Health Check Report", + "Cluster Type:", + "### Cluster Operators", + "### Node Health", + "### Pod Health", + "### Workload Controllers", + "### Storage", + "### Recent Events", + "Summary", + "Critical Issues:", + "Warnings:", + } + + for _, element := range reportElements { + assert.Contains(t, userContent, element, "Missing report element: %s", element) + } + }) + + t.Run("User message does not reference non-existent tools", func(t *testing.T) { + // When + messages := buildHealthCheckPromptMessages(false, "") + + // Then + userContent := messages[0].Content + + // Make sure we're not referencing the old tool name + assert.NotContains(t, userContent, "pods_list_in_all_namespaces") + }) +} + +func TestGetMessagesWithArguments(t *testing.T) { + // Given + prompts := initHealthCheckPrompts() + require.Len(t, prompts, 1) + + getMessages := prompts[0].GetMessages + + t.Run("With no arguments", func(t *testing.T) { + // When + messages := getMessages(map[string]string{}) + + // Then + require.Len(t, messages, 2) + userContent := messages[0].Content + assert.Contains(t, userContent, "across all namespaces") + assert.NotContains(t, userContent, "For verbose mode") + }) + + t.Run("With verbose=true", func(t *testing.T) { + // When + messages := getMessages(map[string]string{"verbose": "true"}) + + // Then + require.Len(t, messages, 2) + userContent := messages[0].Content + assert.Contains(t, userContent, "For verbose mode") + }) + + t.Run("With verbose=false", func(t *testing.T) { + // When + messages := getMessages(map[string]string{"verbose": "false"}) + + // Then + require.Len(t, messages, 2) + userContent := messages[0].Content + assert.NotContains(t, userContent, "For verbose mode") + }) + + t.Run("With namespace", func(t *testing.T) { + // When + messages := getMessages(map[string]string{"namespace": "kube-system"}) + + // Then + require.Len(t, messages, 2) + userContent := messages[0].Content + assert.Contains(t, userContent, "in namespace 'kube-system'") + }) + + t.Run("With both arguments", func(t *testing.T) { + // When + messages := getMessages(map[string]string{ + "verbose": "true", + "namespace": "default", + }) + + // Then + require.Len(t, messages, 2) + userContent := messages[0].Content + assert.Contains(t, userContent, "For verbose mode") + assert.Contains(t, userContent, "in namespace 'default'") + }) +} + +func TestHealthCheckPromptCompleteness(t *testing.T) { + // This test ensures the prompt covers all essential aspects + + messages := buildHealthCheckPromptMessages(false, "") + userContent := messages[0].Content + + t.Run("Covers all Kubernetes resource types", func(t *testing.T) { + resourceTypes := []string{ + "Node", + "Pod", + "Deployment", + "StatefulSet", + "DaemonSet", + "PersistentVolumeClaim", + "ClusterOperator", // OpenShift specific + } + + for _, rt := range resourceTypes { + assert.Contains(t, userContent, rt, "Missing resource type: %s", rt) + } + }) + + t.Run("Provides clear severity levels", func(t *testing.T) { + assert.Contains(t, userContent, "CRITICAL") + assert.Contains(t, userContent, "WARNING") + assert.Contains(t, userContent, "HEALTHY") + }) + + t.Run("Includes efficiency guidelines", func(t *testing.T) { + assert.Contains(t, userContent, "Be efficient") + assert.Contains(t, userContent, "don't call the same tool multiple times unnecessarily") + }) + + t.Run("Handles OpenShift gracefully", func(t *testing.T) { + assert.Contains(t, userContent, "For OpenShift Clusters") + assert.Contains(t, userContent, "For All Kubernetes Clusters") + assert.Contains(t, userContent, "skip it gracefully") + }) + + t.Run("Instructions are clear and actionable", func(t *testing.T) { + // Check that the prompt uses imperative language + imperativeVerbs := []string{"Use", "Check", "Look for", "Verify", "Identify", "Compare"} + foundVerbs := 0 + for _, verb := range imperativeVerbs { + if strings.Contains(userContent, verb) { + foundVerbs++ + } + } + assert.Greater(t, foundVerbs, 3, "Prompt should use clear imperative language") + }) + + t.Run("Includes apiVersion reference section", func(t *testing.T) { + assert.Contains(t, userContent, "Common apiVersion Values") + assert.Contains(t, userContent, "apiVersion=config.openshift.io/v1") + assert.Contains(t, userContent, "apiVersion=apps/v1") + assert.Contains(t, userContent, "apiVersion=v1") + assert.Contains(t, userContent, "ClusterOperator, ClusterVersion") + }) +} diff --git a/pkg/promptsets/core/promptset.go b/pkg/promptsets/core/promptset.go new file mode 100644 index 00000000..8bd35785 --- /dev/null +++ b/pkg/promptsets/core/promptset.go @@ -0,0 +1,39 @@ +package core + +import ( + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" + "github.com/containers/kubernetes-mcp-server/pkg/promptsets" +) + +const ( + Name = "core" + Description = "Core prompts for common Kubernetes/OpenShift operations including cluster health diagnostics" +) + +type PromptSet struct{} + +func (t *PromptSet) GetName() string { + return Name +} + +func (t *PromptSet) GetDescription() string { + return Description +} + +func (t *PromptSet) GetPrompts(o internalk8s.Openshift) []api.ServerPrompt { + prompts := make([]api.ServerPrompt, 0) + + // Health check prompts + prompts = append(prompts, initHealthCheckPrompts()...) + + // Future: Add more prompts here + // prompts = append(prompts, initTroubleshootingPrompts(o)...) + // prompts = append(prompts, initDeploymentPrompts(o)...) + + return prompts +} + +func init() { + promptsets.Register(&PromptSet{}) +} diff --git a/pkg/promptsets/promptsets.go b/pkg/promptsets/promptsets.go new file mode 100644 index 00000000..e140aa0d --- /dev/null +++ b/pkg/promptsets/promptsets.go @@ -0,0 +1,50 @@ +package promptsets + +import ( + "slices" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +var promptsets []api.PromptSet + +// Clear removes all registered promptsets, TESTING PURPOSES ONLY. +func Clear() { + promptsets = []api.PromptSet{} +} + +// Register adds a promptset to the registry +func Register(promptset api.PromptSet) { + promptsets = append(promptsets, promptset) +} + +// PromptSets returns all registered promptsets +func PromptSets() []api.PromptSet { + return promptsets +} + +// PromptSetFromString returns a PromptSet by name, or nil if not found +func PromptSetFromString(name string) api.PromptSet { + for _, ps := range PromptSets() { + if ps.GetName() == strings.TrimSpace(name) { + return ps + } + } + return nil +} + +// AllPromptSets returns all available promptsets +func AllPromptSets() []api.PromptSet { + return PromptSets() +} + +// GetPromptSetNames returns names of all registered promptsets +func GetPromptSetNames() []string { + names := make([]string, 0, len(promptsets)) + for _, ps := range promptsets { + names = append(names, ps.GetName()) + } + slices.Sort(names) + return names +} diff --git a/pkg/promptsets/promptsets_test.go b/pkg/promptsets/promptsets_test.go new file mode 100644 index 00000000..31764361 --- /dev/null +++ b/pkg/promptsets/promptsets_test.go @@ -0,0 +1,138 @@ +package promptsets + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +type PromptSetsSuite struct { + suite.Suite +} + +func (s *PromptSetsSuite) SetupTest() { + // Clear the registry before each test + Clear() +} + +func (s *PromptSetsSuite) TestRegister() { + // Given + testPS := &testPromptSet{name: "test"} + + // When + Register(testPS) + + // Then + assert.Equal(s.T(), 1, len(PromptSets())) + assert.Equal(s.T(), testPS, PromptSets()[0]) +} + +func (s *PromptSetsSuite) TestPromptSetFromString() { + s.Run("Returns nil if promptset not found", func() { + // When + ps := PromptSetFromString("nonexistent") + + // Then + assert.Nil(s.T(), ps) + }) + + s.Run("Returns the correct promptset if found", func() { + // Given + testPS := &testPromptSet{name: "test"} + Register(testPS) + + // When + ps := PromptSetFromString("test") + + // Then + assert.Equal(s.T(), testPS, ps) + assert.Equal(s.T(), "test", ps.GetName()) + }) + + s.Run("Returns the correct promptset if found after trimming spaces", func() { + // Given + testPS := &testPromptSet{name: "test"} + Register(testPS) + + // When + ps := PromptSetFromString(" test ") + + // Then + assert.Equal(s.T(), testPS, ps) + }) +} + +func (s *PromptSetsSuite) TestAllPromptSets() { + // Given + testPS1 := &testPromptSet{name: "test1"} + testPS2 := &testPromptSet{name: "test2"} + Register(testPS1) + Register(testPS2) + + // When + all := AllPromptSets() + + // Then + assert.Equal(s.T(), 2, len(all)) + assert.Contains(s.T(), all, testPS1) + assert.Contains(s.T(), all, testPS2) +} + +func (s *PromptSetsSuite) TestGetPromptSetNames() { + s.Run("Returns empty slice when no promptsets registered", func() { + // When + names := GetPromptSetNames() + + // Then + assert.Empty(s.T(), names) + }) + + s.Run("Returns sorted names of all registered promptsets", func() { + // Given + Register(&testPromptSet{name: "zebra"}) + Register(&testPromptSet{name: "alpha"}) + Register(&testPromptSet{name: "beta"}) + + // When + names := GetPromptSetNames() + + // Then + assert.Equal(s.T(), []string{"alpha", "beta", "zebra"}, names) + }) +} + +func TestPromptSets(t *testing.T) { + suite.Run(t, new(PromptSetsSuite)) +} + +// Test helper +type testPromptSet struct { + name string +} + +func (t *testPromptSet) GetName() string { + return t.name +} + +func (t *testPromptSet) GetDescription() string { + return "Test promptset" +} + +func (t *testPromptSet) GetPrompts(o internalk8s.Openshift) []api.ServerPrompt { + return []api.ServerPrompt{ + { + Name: "test_prompt", + Description: "Test prompt", + Arguments: []api.PromptArgument{}, + GetMessages: func(arguments map[string]string) []api.PromptMessage { + return []api.PromptMessage{ + {Role: "user", Content: "test"}, + } + }, + }, + } +}