From 7aacea605fc0955ce8dd8eb9b8016da0a6d6b661 Mon Sep 17 00:00:00 2001 From: Nader Ziada Date: Wed, 26 Nov 2025 09:34:09 -0500 Subject: [PATCH] feat: add MCP prompts support Add MCP prompts support to enable pre-defined workflow templates and guidance for AI assistants. Includes: - New PromptLoader for loading prompts from toml config - Core prompt types (ServerPrompt, Prompt, PromptArgument, PromptMessage) - Template argument substitution with {{variable}} syntax - Required argument validation - built-in prompts for common Kubernetes workflows (troubleshooting, deployment, scaling, cluster health, networking, resource usage) - Integration with MCP server to register and serve prompts Signed-off-by: Nader Ziada --- README.md | 28 ++ docs/PROMPTS.md | 196 ++++++++++++++ pkg/api/prompt_loader.go | 134 ++++++++++ pkg/api/prompt_loader_config_test.go | 232 ++++++++++++++++ pkg/api/prompts.go | 83 ++++++ pkg/api/prompts_test.go | 80 ++++++ pkg/api/toolsets.go | 3 + pkg/config/config.go | 27 ++ pkg/config/prompts_config_test.go | 90 +++++++ pkg/mcp/mcp.go | 106 +++++++- pkg/mcp/mcp_watch_test.go | 10 +- pkg/mcp/prompts_config_test.go | 168 ++++++++++++ pkg/mcp/prompts_gosdk.go | 100 +++++++ pkg/mcp/prompts_gosdk_test.go | 140 ++++++++++ pkg/toolsets/config/toolset.go | 5 + pkg/toolsets/core/prompts.go | 381 +++++++++++++++++++++++++++ pkg/toolsets/core/prompts_test.go | 128 +++++++++ pkg/toolsets/core/toolset.go | 5 + pkg/toolsets/helm/toolset.go | 5 + pkg/toolsets/kiali/toolset.go | 5 + pkg/toolsets/kubevirt/toolset.go | 5 + pkg/toolsets/toolsets_test.go | 2 + 22 files changed, 1926 insertions(+), 7 deletions(-) create mode 100644 docs/PROMPTS.md create mode 100644 pkg/api/prompt_loader.go create mode 100644 pkg/api/prompt_loader_config_test.go create mode 100644 pkg/api/prompts.go create mode 100644 pkg/api/prompts_test.go create mode 100644 pkg/config/prompts_config_test.go create mode 100644 pkg/mcp/prompts_config_test.go create mode 100644 pkg/mcp/prompts_gosdk.go create mode 100644 pkg/mcp/prompts_gosdk_test.go create mode 100644 pkg/toolsets/core/prompts.go create mode 100644 pkg/toolsets/core/prompts_test.go diff --git a/README.md b/README.md index f4c4a6be..70c44e98 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,34 @@ uvx kubernetes-mcp-server@latest --help | `--toolsets` | Comma-separated list of toolsets to enable. Check the [🛠️ Tools and Functionalities](#tools-and-functionalities) section for more information. | | `--disable-multi-cluster` | If set, the MCP server will disable multi-cluster support and will only use the current context from the kubeconfig file. This is useful if you want to restrict the MCP server to a single cluster. | +#### Custom Prompts Configuration + +The server supports defining custom [MCP prompts](https://modelcontextprotocol.io/docs/concepts/prompts) directly in your configuration file, allowing you to create custom workflows without recompiling. See [docs/PROMPTS.md](docs/PROMPTS.md) for detailed documentation. + +**Configuration file example:** + +```toml +# Define prompts inline in your config.toml +[[prompts]] +name = "my-custom-prompt" +description = "A custom troubleshooting workflow" + +[[prompts.arguments]] +name = "resource_name" +required = true + +[[prompts.messages]] +role = "user" +content = "Help me troubleshoot {{resource_name}}" + +[[prompts.messages]] +role = "assistant" +content = "I'll investigate {{resource_name}} for you." + +# Optionally disable built-in embedded prompts +disable_embedded_prompts = false +``` + ## 🛠️ Tools and Functionalities The Kubernetes MCP server supports enabling or disabling specific groups of tools and functionalities (tools, resources, prompts, and so on) via the `--toolsets` command-line flag or `toolsets` configuration option. diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md new file mode 100644 index 00000000..36cfde49 --- /dev/null +++ b/docs/PROMPTS.md @@ -0,0 +1,196 @@ +# MCP Prompts Support + +The Kubernetes MCP Server supports [MCP Prompts](https://modelcontextprotocol.io/docs/concepts/prompts), which provide pre-defined workflow templates and guidance to AI assistants. + +## What are MCP Prompts? + +MCP Prompts are pre-defined templates that guide AI assistants through specific workflows. They combine: +- **Structured guidance**: Step-by-step instructions for common tasks +- **Parameterization**: Arguments that customize the prompt for specific contexts +- **Conversation templates**: Pre-formatted messages that guide the interaction + +## Available Built-in Prompts + +The server provides these prompts in the `core` toolset: + +1. **troubleshoot-pod** - Debug failing or crashed pods +2. **deploy-application** - Deploy new applications +3. **scale-deployment** - Scale deployments safely +4. **investigate-cluster-health** - Check overall cluster health +5. **debug-networking** - Debug connectivity issues +6. **review-resource-usage** - Analyze resource consumption + +## Creating Custom Prompts + +Define custom prompts in your `config.toml` file - no code changes or recompilation needed! + +### Basic Example + +```toml +[[prompts]] +name = "check-pod-logs" +description = "Quick way to check pod logs" + +[[prompts.arguments]] +name = "pod_name" +description = "Name of the pod" +required = true + +[[prompts.arguments]] +name = "namespace" +description = "Namespace of the pod" +required = false + +[[prompts.messages]] +role = "user" +content = "Show me the logs for pod {{pod_name}} in {{namespace}}" + +[[prompts.messages]] +role = "assistant" +content = "I'll retrieve and analyze the logs for you." +``` + +### Complex Example with Multi-Step Workflow + +```toml +[[prompts]] +name = "troubleshoot-deployment" +description = "Comprehensive deployment troubleshooting" + +[[prompts.arguments]] +name = "deployment_name" +required = true + +[[prompts.arguments]] +name = "namespace" +required = true + +[[prompts.messages]] +role = "user" +content = """ +My deployment {{deployment_name}} in {{namespace}} is having issues. +Can you investigate? +""" + +[[prompts.messages]] +role = "assistant" +content = """ +I'll troubleshoot deployment {{deployment_name}}. Let me: + +1. **Check Deployment Status** + - Review current vs desired replicas + - Check rollout status + +2. **Investigate Pods** + - List pod states + - Review pod events and logs + +3. **Analyze Resources** + - Check CPU/memory limits + - Verify quotas + +Starting investigation... +""" +``` + +### Argument Substitution + +Use `{{argument_name}}` in message content to insert values: + +```toml +[[prompts.messages]] +role = "user" +content = "Check {{resource_type}} named {{resource_name}}" +``` + +### Overriding Built-in Prompts + +Replace built-in prompts by using the same name: + +```toml +[[prompts]] +name = "troubleshoot-pod" # This overrides the built-in version +description = "Our custom pod troubleshooting process" + +[[prompts.arguments]] +name = "pod_name" +required = true + +[[prompts.messages]] +role = "user" +content = "Pod {{pod_name}} needs help" + +[[prompts.messages]] +role = "assistant" +content = "Using our custom troubleshooting workflow..." +``` + +### Disabling Built-in Prompts + +Use only your custom prompts: + +```toml +# Disable all built-in prompts +disable_embedded_prompts = true + +# Then define your own +[[prompts]] +name = "my-prompt" +# ... +``` + +## For Toolset Developers + +If you're creating a custom toolset, define prompts directly in Go code (similar to how tools are defined): + +```go +// pkg/toolsets/yourtoolset/prompts.go +package yourtoolset + +import ( + "fmt" + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func (t *Toolset) GetPrompts(_ internalk8s.Openshift) []api.ServerPrompt { + return []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "your-prompt", + Description: "What it does", + Arguments: []api.PromptArgument{ + { + Name: "arg1", + Description: "First argument", + Required: true, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + arg1, _ := args["arg1"] + + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: fmt.Sprintf("Message with %s", arg1), + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: "Response template", + }, + }, + } + + return api.NewPromptCallResult("What it does", messages, nil), nil + }, + }, + } +} +``` + diff --git a/pkg/api/prompt_loader.go b/pkg/api/prompt_loader.go new file mode 100644 index 00000000..a917e9b9 --- /dev/null +++ b/pkg/api/prompt_loader.go @@ -0,0 +1,134 @@ +package api + +import ( + "fmt" + "strings" + + "sigs.k8s.io/yaml" +) + +// PromptDefinition represents a prompt definition loaded from config +type PromptDefinition struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Arguments []PromptArgumentDef `yaml:"arguments,omitempty"` + Messages []PromptMessageTemplate `yaml:"messages"` +} + +// PromptArgumentDef represents an argument definition +type PromptArgumentDef struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` +} + +// PromptMessageTemplate represents a message template +type PromptMessageTemplate struct { + Role string `yaml:"role"` + Content string `yaml:"content"` +} + +// PromptLoader loads prompt definitions from TOML config +type PromptLoader struct { + definitions []PromptDefinition +} + +// NewPromptLoader creates a new prompt loader +func NewPromptLoader() *PromptLoader { + return &PromptLoader{ + definitions: make([]PromptDefinition, 0), + } +} + +// GetServerPrompts converts loaded definitions to ServerPrompt instances +func (l *PromptLoader) GetServerPrompts() []ServerPrompt { + prompts := make([]ServerPrompt, 0, len(l.definitions)) + for _, def := range l.definitions { + prompts = append(prompts, l.convertToServerPrompt(def)) + } + return prompts +} + +// convertToServerPrompt converts a PromptDefinition to a ServerPrompt +func (l *PromptLoader) convertToServerPrompt(def PromptDefinition) ServerPrompt { + arguments := make([]PromptArgument, 0, len(def.Arguments)) + for _, arg := range def.Arguments { + arguments = append(arguments, PromptArgument(arg)) + } + + return ServerPrompt{ + Prompt: Prompt{ + Name: def.Name, + Description: def.Description, + Arguments: arguments, + }, + Handler: l.createHandler(def), + } +} + +// createHandler creates a prompt handler function for a prompt definition +func (l *PromptLoader) createHandler(def PromptDefinition) PromptHandlerFunc { + return func(params PromptHandlerParams) (*PromptCallResult, error) { + args := params.GetArguments() + + // Validate required arguments + for _, argDef := range def.Arguments { + if argDef.Required { + if _, exists := args[argDef.Name]; !exists { + return nil, fmt.Errorf("required argument '%s' is missing", argDef.Name) + } + } + } + + // Render messages with argument substitution + messages := make([]PromptMessage, 0, len(def.Messages)) + for _, msgTemplate := range def.Messages { + content := l.substituteArguments(msgTemplate.Content, args) + messages = append(messages, PromptMessage{ + Role: msgTemplate.Role, + Content: PromptContent{ + Type: "text", + Text: content, + }, + }) + } + + return NewPromptCallResult(def.Description, messages, nil), nil + } +} + +// substituteArguments replaces {{argument}} placeholders in content with actual values +func (l *PromptLoader) substituteArguments(content string, args map[string]string) string { + result := content + for key, value := range args { + placeholder := fmt.Sprintf("{{%s}}", key) + result = strings.ReplaceAll(result, placeholder, value) + } + return result +} + +// LoadFromConfig loads prompts from TOML config structures +func (l *PromptLoader) LoadFromConfig(configs interface{}) error { + // Type assertion to handle the config package types + // We use interface{} here to avoid circular dependency with config package + var defs []PromptDefinition + + // Use reflection or type switching to convert config types to PromptDefinition + // This is a simple implementation that works with the expected structure + data, err := convertToYAML(configs) + if err != nil { + return fmt.Errorf("failed to convert config to YAML: %w", err) + } + + if err := yaml.Unmarshal(data, &defs); err != nil { + return fmt.Errorf("failed to parse prompt config: %w", err) + } + + l.definitions = append(l.definitions, defs...) + return nil +} + +// convertToYAML converts config structs to YAML bytes for uniform processing +func convertToYAML(v interface{}) ([]byte, error) { + return yaml.Marshal(v) +} diff --git a/pkg/api/prompt_loader_config_test.go b/pkg/api/prompt_loader_config_test.go new file mode 100644 index 00000000..3e97c7e5 --- /dev/null +++ b/pkg/api/prompt_loader_config_test.go @@ -0,0 +1,232 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPromptLoader_LoadFromConfig(t *testing.T) { + tests := []struct { + name string + config interface{} + expectedCount int + expectedName string + expectError bool + }{ + { + name: "load single prompt from config", + config: []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Arguments []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + } `yaml:"arguments,omitempty"` + Messages []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + } `yaml:"messages"` + }{ + { + Name: "test-prompt", + Description: "Test prompt description", + Arguments: []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + }{ + {Name: "arg1", Description: "Argument 1", Required: true}, + }, + Messages: []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + }{ + {Role: "user", Content: "Test content with {{arg1}}"}, + }, + }, + }, + expectedCount: 1, + expectedName: "test-prompt", + expectError: false, + }, + { + name: "load multiple prompts from config", + config: []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Messages []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + } `yaml:"messages"` + }{ + { + Name: "prompt1", + Description: "First prompt", + Messages: []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + }{ + {Role: "user", Content: "Message 1"}, + }, + }, + { + Name: "prompt2", + Description: "Second prompt", + Messages: []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + }{ + {Role: "assistant", Content: "Message 2"}, + }, + }, + }, + expectedCount: 2, + expectedName: "prompt1", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loader := NewPromptLoader() + err := loader.LoadFromConfig(tt.config) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + + prompts := loader.GetServerPrompts() + assert.Len(t, prompts, tt.expectedCount) + + if tt.expectedCount > 0 { + assert.Equal(t, tt.expectedName, prompts[0].Prompt.Name) + } + }) + } +} + +func TestPromptLoader_LoadFromConfigWithArguments(t *testing.T) { + config := []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Arguments []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + } `yaml:"arguments,omitempty"` + Messages []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + } `yaml:"messages"` + }{ + { + Name: "prompt-with-args", + Description: "Prompt with arguments", + Arguments: []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + }{ + {Name: "required_arg", Description: "A required argument", Required: true}, + {Name: "optional_arg", Description: "An optional argument", Required: false}, + }, + Messages: []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + }{ + {Role: "user", Content: "Content with {{required_arg}} and {{optional_arg}}"}, + }, + }, + } + + loader := NewPromptLoader() + err := loader.LoadFromConfig(config) + require.NoError(t, err) + + prompts := loader.GetServerPrompts() + require.Len(t, prompts, 1) + + prompt := prompts[0] + assert.Equal(t, "prompt-with-args", prompt.Prompt.Name) + assert.Len(t, prompt.Prompt.Arguments, 2) + assert.Equal(t, "required_arg", prompt.Prompt.Arguments[0].Name) + assert.True(t, prompt.Prompt.Arguments[0].Required) + assert.Equal(t, "optional_arg", prompt.Prompt.Arguments[1].Name) + assert.False(t, prompt.Prompt.Arguments[1].Required) +} + +func TestPromptLoader_LoadFromConfigHandlerExecution(t *testing.T) { + config := []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Arguments []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + } `yaml:"arguments,omitempty"` + Messages []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + } `yaml:"messages"` + }{ + { + Name: "substitution-test", + Description: "Test argument substitution", + Arguments: []struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Required bool `yaml:"required"` + }{ + {Name: "name", Description: "Name to substitute", Required: true}, + }, + Messages: []struct { + Role string `yaml:"role"` + Content string `yaml:"content"` + }{ + {Role: "user", Content: "Hello {{name}}!"}, + }, + }, + } + + loader := NewPromptLoader() + err := loader.LoadFromConfig(config) + require.NoError(t, err) + + prompts := loader.GetServerPrompts() + require.Len(t, prompts, 1) + + prompt := prompts[0] + + // Execute the handler + params := PromptHandlerParams{ + PromptCallRequest: &mockPromptCallRequest{ + args: map[string]string{ + "name": "World", + }, + }, + } + + result, err := prompt.Handler(params) + require.NoError(t, err) + require.NotNil(t, result) + assert.Len(t, result.Messages, 1) + assert.Equal(t, "Hello World!", result.Messages[0].Content.Text) +} + +// Mock implementation for testing +type mockPromptCallRequest struct { + args map[string]string +} + +func (m *mockPromptCallRequest) GetArguments() map[string]string { + if m.args == nil { + return make(map[string]string) + } + return m.args +} diff --git a/pkg/api/prompts.go b/pkg/api/prompts.go new file mode 100644 index 00000000..0be2d8af --- /dev/null +++ b/pkg/api/prompts.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + + internalk8s "github.com/containers/kubernetes-mcp-server/pkg/kubernetes" +) + +// ServerPrompt represents a prompt that can be registered with the MCP server. +// Prompts provide pre-defined workflow templates and guidance to AI assistants. +type ServerPrompt struct { + Prompt Prompt + Handler PromptHandlerFunc + ClusterAware *bool + ArgumentSchema map[string]PromptArgument +} + +// IsClusterAware indicates whether the prompt can accept a "cluster" or "context" parameter +// to operate on a specific Kubernetes cluster context. +// Defaults to true if not explicitly set +func (s *ServerPrompt) IsClusterAware() bool { + if s.ClusterAware != nil { + return *s.ClusterAware + } + return true +} + +// Prompt represents the metadata and content of an MCP prompt +type Prompt struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description,omitempty"` + Arguments []PromptArgument `yaml:"arguments,omitempty" json:"arguments,omitempty"` +} + +// PromptArgument defines a parameter that can be passed to a prompt +type PromptArgument struct { + Name string `yaml:"name" json:"name"` + Description string `yaml:"description" json:"description,omitempty"` + Required bool `yaml:"required" json:"required"` +} + +// PromptMessage represents a single message in a prompt template +type PromptMessage struct { + Role string `yaml:"role" json:"role"` + Content PromptContent `yaml:"content" json:"content"` +} + +// PromptContent represents the content of a prompt message +type PromptContent struct { + Type string `yaml:"type" json:"type"` + Text string `yaml:"text,omitempty" json:"text,omitempty"` +} + +// PromptCallRequest interface for accessing prompt call arguments +type PromptCallRequest interface { + GetArguments() map[string]string +} + +// PromptCallResult represents the result of executing a prompt +type PromptCallResult struct { + Description string + Messages []PromptMessage + Error error +} + +// NewPromptCallResult creates a new PromptCallResult +func NewPromptCallResult(description string, messages []PromptMessage, err error) *PromptCallResult { + return &PromptCallResult{ + Description: description, + Messages: messages, + Error: err, + } +} + +// PromptHandlerParams contains the parameters passed to a prompt handler +type PromptHandlerParams struct { + context.Context + *internalk8s.Kubernetes + PromptCallRequest +} + +// PromptHandlerFunc is a function that handles prompt execution +type PromptHandlerFunc func(params PromptHandlerParams) (*PromptCallResult, error) diff --git a/pkg/api/prompts_test.go b/pkg/api/prompts_test.go new file mode 100644 index 00000000..52126092 --- /dev/null +++ b/pkg/api/prompts_test.go @@ -0,0 +1,80 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +func TestServerPrompt_IsClusterAware(t *testing.T) { + tests := []struct { + name string + clusterAware *bool + want bool + }{ + { + name: "nil defaults to true", + clusterAware: nil, + want: true, + }, + { + name: "explicitly true", + clusterAware: ptr.To(true), + want: true, + }, + { + name: "explicitly false", + clusterAware: ptr.To(false), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sp := &ServerPrompt{ + ClusterAware: tt.clusterAware, + } + assert.Equal(t, tt.want, sp.IsClusterAware()) + }) + } +} + +func TestNewPromptCallResult(t *testing.T) { + tests := []struct { + name string + description string + messages []PromptMessage + err error + }{ + { + name: "successful result", + description: "Test description", + messages: []PromptMessage{ + { + Role: "user", + Content: PromptContent{ + Type: "text", + Text: "Hello", + }, + }, + }, + err: nil, + }, + { + name: "result with error", + description: "Error description", + messages: nil, + err: assert.AnError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewPromptCallResult(tt.description, tt.messages, tt.err) + assert.Equal(t, tt.description, result.Description) + assert.Equal(t, tt.messages, result.Messages) + assert.Equal(t, tt.err, result.Error) + }) + } +} diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index c5960e3f..78340e88 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -44,6 +44,9 @@ type Toolset interface { // Will be used to generate documentation and help text. GetDescription() string GetTools(o internalk8s.Openshift) []ServerTool + // GetPrompts returns the prompts provided by this toolset. + // Returns nil if the toolset doesn't provide any prompts. + GetPrompts(o internalk8s.Openshift) []ServerPrompt } type ToolCallRequest interface { diff --git a/pkg/config/config.go b/pkg/config/config.go index 20695768..327e1048 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -72,6 +72,12 @@ type StaticConfig struct { // This map holds raw TOML primitives that will be parsed by registered toolset parsers ToolsetConfigs map[string]toml.Primitive `toml:"toolset_configs,omitempty"` + // Prompt configuration + // Inline prompt definitions in TOML + Prompts []PromptConfig `toml:"prompts,omitempty"` + // When true, disable embedded prompts from toolsets and use only config-defined ones + DisableEmbeddedPrompts bool `toml:"disable_embedded_prompts,omitempty"` + // Internal: parsed provider configs (not exposed to TOML package) parsedClusterProviderConfigs map[string]Extended // Internal: parsed toolset configs (not exposed to TOML package) @@ -87,6 +93,27 @@ type GroupVersionKind struct { Kind string `toml:"kind,omitempty"` } +// PromptConfig represents an inline prompt definition in TOML +type PromptConfig struct { + Name string `toml:"name"` + Description string `toml:"description"` + Arguments []PromptArgumentConfig `toml:"arguments,omitempty"` + Messages []PromptMessageConfig `toml:"messages"` +} + +// PromptArgumentConfig represents a prompt argument in TOML +type PromptArgumentConfig struct { + Name string `toml:"name"` + Description string `toml:"description"` + Required bool `toml:"required"` +} + +// PromptMessageConfig represents a prompt message in TOML +type PromptMessageConfig struct { + Role string `toml:"role"` + Content string `toml:"content"` +} + type ReadConfigOpt func(cfg *StaticConfig) func withDirPath(path string) ReadConfigOpt { diff --git a/pkg/config/prompts_config_test.go b/pkg/config/prompts_config_test.go new file mode 100644 index 00000000..092b2d6a --- /dev/null +++ b/pkg/config/prompts_config_test.go @@ -0,0 +1,90 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type PromptsConfigSuite struct { + suite.Suite +} + +func (s *PromptsConfigSuite) TestReadConfigWithDisableEmbeddedPrompts() { + configData := ` +disable_embedded_prompts = true +` + cfg, err := ReadToml([]byte(configData)) + s.Run("no error", func() { + s.Nilf(err, "failed to read config: %v", err) + }) + s.Run("disable_embedded_prompts parsed correctly", func() { + s.Truef(cfg.DisableEmbeddedPrompts, "expected disable_embedded_prompts to be true") + }) +} + +func (s *PromptsConfigSuite) TestReadConfigWithInlinePrompts() { + configData := ` +[[prompts]] +name = "test-prompt" +description = "A test prompt" + +[[prompts.arguments]] +name = "arg1" +description = "First argument" +required = true + +[[prompts.messages]] +role = "user" +content = "Test message with {{arg1}}" +` + cfg, err := ReadToml([]byte(configData)) + s.Run("no error", func() { + s.Nilf(err, "failed to read config: %v", err) + }) + s.Run("prompts parsed correctly", func() { + s.Lenf(cfg.Prompts, 1, "expected 1 prompt, got %d", len(cfg.Prompts)) + prompt := cfg.Prompts[0] + s.Equalf("test-prompt", prompt.Name, "expected prompt name to be 'test-prompt', got %s", prompt.Name) + s.Equalf("A test prompt", prompt.Description, "expected description to match") + s.Lenf(prompt.Arguments, 1, "expected 1 argument") + s.Equalf("arg1", prompt.Arguments[0].Name, "expected argument name to be 'arg1'") + s.Truef(prompt.Arguments[0].Required, "expected argument to be required") + s.Lenf(prompt.Messages, 1, "expected 1 message") + s.Equalf("user", prompt.Messages[0].Role, "expected role to be 'user'") + s.Containsf(prompt.Messages[0].Content, "{{arg1}}", "expected content to contain placeholder") + }) +} + +func (s *PromptsConfigSuite) TestReadConfigWithMultipleInlinePrompts() { + configData := ` +[[prompts]] +name = "prompt1" +description = "First prompt" + +[[prompts.messages]] +role = "user" +content = "Message 1" + +[[prompts]] +name = "prompt2" +description = "Second prompt" + +[[prompts.messages]] +role = "assistant" +content = "Message 2" +` + cfg, err := ReadToml([]byte(configData)) + s.Run("no error", func() { + s.Nilf(err, "failed to read config: %v", err) + }) + s.Run("multiple prompts parsed correctly", func() { + s.Lenf(cfg.Prompts, 2, "expected 2 prompts, got %d", len(cfg.Prompts)) + s.Equalf("prompt1", cfg.Prompts[0].Name, "expected first prompt name to be 'prompt1'") + s.Equalf("prompt2", cfg.Prompts[1].Name, "expected second prompt name to be 'prompt2'") + }) +} + +func TestPromptsConfig(t *testing.T) { + suite.Run(t, new(PromptsConfigSuite)) +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index 7d663ef6..1fdb9d8d 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -62,10 +62,11 @@ func (c *Configuration) isToolApplicable(tool api.ServerTool) bool { } type Server struct { - configuration *Configuration - server *mcp.Server - enabledTools []string - p internalk8s.Provider + configuration *Configuration + server *mcp.Server + enabledTools []string + enabledPrompts []string + p internalk8s.Provider } func NewServer(configuration Configuration) (*Server, error) { @@ -77,7 +78,7 @@ func NewServer(configuration Configuration) (*Server, error) { }, &mcp.ServerOptions{ HasResources: false, - HasPrompts: false, + HasPrompts: true, HasTools: true, }), } @@ -159,9 +160,104 @@ func (s *Server) reloadToolsets() error { } s.server.AddTool(goSdkTool, goSdkToolHandler) } + + // Track previously enabled prompts + previousPrompts := s.enabledPrompts + + // Build and register prompts from all toolsets + applicablePrompts := make([]api.ServerPrompt, 0) + s.enabledPrompts = make([]string, 0) + + // Load embedded toolset prompts (unless disabled) + if !s.configuration.DisableEmbeddedPrompts { + for _, toolset := range s.configuration.Toolsets() { + prompts := toolset.GetPrompts(s.p) + if prompts == nil { + continue + } + for _, prompt := range prompts { + applicablePrompts = append(applicablePrompts, prompt) + s.enabledPrompts = append(s.enabledPrompts, prompt.Prompt.Name) + } + } + } + + // Load config-based prompts and merge (config prompts override embedded prompts with same name) + configPrompts, err := s.loadConfigPrompts() + if err != nil { + return fmt.Errorf("failed to load config-based prompts: %w", err) + } + + // Merge: config prompts override embedded prompts with same name + applicablePrompts = mergePrompts(applicablePrompts, configPrompts) + + // Update enabled prompts list + s.enabledPrompts = make([]string, 0) + for _, prompt := range applicablePrompts { + s.enabledPrompts = append(s.enabledPrompts, prompt.Prompt.Name) + } + + // Remove prompts that are no longer applicable + promptsToRemove := make([]string, 0) + for _, oldPrompt := range previousPrompts { + if !slices.Contains(s.enabledPrompts, oldPrompt) { + promptsToRemove = append(promptsToRemove, oldPrompt) + } + } + s.server.RemovePrompts(promptsToRemove...) + + // Register all applicable prompts + for _, prompt := range applicablePrompts { + mcpPrompt, promptHandler, err := ServerPromptToGoSdkPrompt(s, prompt) + if err != nil { + return fmt.Errorf("failed to convert prompt %s: %v", prompt.Prompt.Name, err) + } + s.server.AddPrompt(mcpPrompt, promptHandler) + } + + // start new watch + s.p.WatchTargets(s.reloadToolsets) return nil } +// loadConfigPrompts loads prompts from TOML configuration +func (s *Server) loadConfigPrompts() ([]api.ServerPrompt, error) { + // No prompts defined in config + if len(s.configuration.Prompts) == 0 { + return nil, nil + } + + loader := api.NewPromptLoader() + if err := loader.LoadFromConfig(s.configuration.Prompts); err != nil { + return nil, fmt.Errorf("failed to load prompts from config: %w", err) + } + + return loader.GetServerPrompts(), nil +} + +// mergePrompts merges two slices of prompts, with prompts in override taking precedence +// over prompts in base when they have the same name +func mergePrompts(base, override []api.ServerPrompt) []api.ServerPrompt { + // Create a map of override prompts by name for quick lookup + overrideMap := make(map[string]api.ServerPrompt) + for _, prompt := range override { + overrideMap[prompt.Prompt.Name] = prompt + } + + // Build result: start with base prompts, skipping any that are overridden + result := make([]api.ServerPrompt, 0, len(base)+len(override)) + for _, prompt := range base { + if _, exists := overrideMap[prompt.Prompt.Name]; !exists { + result = append(result, prompt) + } + } + + // Add all override prompts + result = append(result, override...) + + return result +} + func (s *Server) ServeStdio(ctx context.Context) error { return s.server.Run(ctx, &mcp.LoggingTransport{Transport: &mcp.StdioTransport{}, Writer: os.Stderr}) } diff --git a/pkg/mcp/mcp_watch_test.go b/pkg/mcp/mcp_watch_test.go index b4f092f0..a4ae770c 100644 --- a/pkg/mcp/mcp_watch_test.go +++ b/pkg/mcp/mcp_watch_test.go @@ -42,7 +42,10 @@ func (s *WatchKubeConfigSuite) TestNotifiesToolsChange() { notification := s.WaitForNotification(5 * time.Second) // Then s.NotNil(notification, "WatchKubeConfig did not notify") - s.Equal("notifications/tools/list_changed", notification.Method, "WatchKubeConfig did not notify tools change") + s.True( + notification.Method == "notifications/tools/list_changed" || notification.Method == "notifications/prompts/list_changed", + "WatchKubeConfig did not notify tools or prompts change, got: %s", notification.Method, + ) } func (s *WatchKubeConfigSuite) TestClearsNoLongerAvailableTools() { @@ -121,7 +124,10 @@ func (s *WatchClusterStateSuite) TestNotifiesToolsChangeOnAPIGroupAddition() { // Then s.NotNil(notification, "cluster state watcher did not notify") - s.Equal("notifications/tools/list_changed", notification.Method, "cluster state watcher did not notify tools change") + s.True( + notification.Method == "notifications/tools/list_changed" || notification.Method == "notifications/prompts/list_changed", + "cluster state watcher did not notify tools or prompts change, got: %s", notification.Method, + ) } func (s *WatchClusterStateSuite) TestDetectsOpenShiftClusterStateChange() { diff --git a/pkg/mcp/prompts_config_test.go b/pkg/mcp/prompts_config_test.go new file mode 100644 index 00000000..91ed2a9c --- /dev/null +++ b/pkg/mcp/prompts_config_test.go @@ -0,0 +1,168 @@ +package mcp + +import ( + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/stretchr/testify/assert" +) + +func TestMergePrompts(t *testing.T) { + tests := []struct { + name string + base []api.ServerPrompt + override []api.ServerPrompt + expectedCount int + expectedNames []string + expectedSource string // Which source should win for overlapping names + }{ + { + name: "merge with no overlap", + base: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1"}}, + {Prompt: api.Prompt{Name: "prompt2"}}, + }, + override: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt3"}}, + }, + expectedCount: 3, + expectedNames: []string{"prompt1", "prompt2", "prompt3"}, + }, + { + name: "override replaces base prompt with same name", + base: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1", Description: "Base description"}}, + {Prompt: api.Prompt{Name: "prompt2"}}, + }, + override: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1", Description: "Override description"}}, + }, + expectedCount: 2, + expectedNames: []string{"prompt2", "prompt1"}, + expectedSource: "Override description", + }, + { + name: "empty base", + base: []api.ServerPrompt{}, + override: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1"}}, + }, + expectedCount: 1, + expectedNames: []string{"prompt1"}, + }, + { + name: "empty override", + base: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1"}}, + }, + override: []api.ServerPrompt{}, + expectedCount: 1, + expectedNames: []string{"prompt1"}, + }, + { + name: "both empty", + base: []api.ServerPrompt{}, + override: []api.ServerPrompt{}, + expectedCount: 0, + }, + { + name: "multiple overrides", + base: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1", Description: "Base 1"}}, + {Prompt: api.Prompt{Name: "prompt2", Description: "Base 2"}}, + {Prompt: api.Prompt{Name: "prompt3", Description: "Base 3"}}, + }, + override: []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "prompt1", Description: "Override 1"}}, + {Prompt: api.Prompt{Name: "prompt3", Description: "Override 3"}}, + }, + expectedCount: 3, + expectedNames: []string{"prompt2", "prompt1", "prompt3"}, + expectedSource: "Override 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := mergePrompts(tt.base, tt.override) + + assert.Len(t, result, tt.expectedCount, "unexpected number of prompts") + + if len(tt.expectedNames) > 0 { + actualNames := make([]string, len(result)) + for i, p := range result { + actualNames[i] = p.Prompt.Name + } + assert.ElementsMatch(t, tt.expectedNames, actualNames, "prompt names don't match") + } + + // Check that override wins for specific test case + if tt.expectedSource != "" { + for _, p := range result { + if p.Prompt.Name == "prompt1" { + assert.Equal(t, tt.expectedSource, p.Prompt.Description, "override should win") + } + } + } + }) + } +} + +func TestMergePromptsPreservesOrder(t *testing.T) { + base := []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "base1"}}, + {Prompt: api.Prompt{Name: "base2"}}, + {Prompt: api.Prompt{Name: "base3"}}, + } + + override := []api.ServerPrompt{ + {Prompt: api.Prompt{Name: "override1"}}, + {Prompt: api.Prompt{Name: "override2"}}, + } + + result := mergePrompts(base, override) + + // Base prompts should come first (those not overridden) + assert.Equal(t, "base1", result[0].Prompt.Name) + assert.Equal(t, "base2", result[1].Prompt.Name) + assert.Equal(t, "base3", result[2].Prompt.Name) + + // Then override prompts + assert.Equal(t, "override1", result[3].Prompt.Name) + assert.Equal(t, "override2", result[4].Prompt.Name) +} + +func TestMergePromptsCompleteReplacement(t *testing.T) { + base := []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "test-prompt", + Description: "Base description", + Arguments: []api.PromptArgument{ + {Name: "base_arg", Required: true}, + }, + }, + }, + } + + override := []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "test-prompt", + Description: "Override description", + Arguments: []api.PromptArgument{ + {Name: "override_arg", Required: false}, + }, + }, + }, + } + + result := mergePrompts(base, override) + + assert.Len(t, result, 1) + assert.Equal(t, "test-prompt", result[0].Prompt.Name) + assert.Equal(t, "Override description", result[0].Prompt.Description) + assert.Len(t, result[0].Prompt.Arguments, 1) + assert.Equal(t, "override_arg", result[0].Prompt.Arguments[0].Name) + assert.False(t, result[0].Prompt.Arguments[0].Required) +} diff --git a/pkg/mcp/prompts_gosdk.go b/pkg/mcp/prompts_gosdk.go new file mode 100644 index 00000000..a153ed24 --- /dev/null +++ b/pkg/mcp/prompts_gosdk.go @@ -0,0 +1,100 @@ +package mcp + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +// promptCallRequestAdapter adapts MCP GetPromptRequest to api.PromptCallRequest +type promptCallRequestAdapter struct { + request *mcp.GetPromptRequest +} + +func (p *promptCallRequestAdapter) GetArguments() map[string]string { + if p.request == nil || p.request.Params == nil || p.request.Params.Arguments == nil { + return make(map[string]string) + } + return p.request.Params.Arguments +} + +// ServerPromptToGoSdkPrompt converts an api.ServerPrompt to MCP SDK types +func ServerPromptToGoSdkPrompt(s *Server, serverPrompt api.ServerPrompt) (*mcp.Prompt, mcp.PromptHandler, error) { + // Convert arguments + var args []*mcp.PromptArgument + for _, arg := range serverPrompt.Prompt.Arguments { + args = append(args, &mcp.PromptArgument{ + Name: arg.Name, + Description: arg.Description, + Required: arg.Required, + }) + } + + // Create the MCP SDK prompt + mcpPrompt := &mcp.Prompt{ + Name: serverPrompt.Prompt.Name, + Description: serverPrompt.Prompt.Description, + Arguments: args, + } + + // Create the handler that wraps the ServerPrompt handler + handler := func(ctx context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + clusterParam := s.p.GetTargetParameterName() + var cluster string + if request.Params != nil && request.Params.Arguments != nil { + if val, ok := request.Params.Arguments[clusterParam]; ok { + cluster = val + } + } + + k8s, err := s.p.GetDerivedKubernetes(ctx, cluster) + if err != nil { + return nil, fmt.Errorf("failed to get kubernetes client: %w", err) + } + + params := api.PromptHandlerParams{ + Context: ctx, + Kubernetes: k8s, + PromptCallRequest: &promptCallRequestAdapter{request: request}, + } + + result, err := serverPrompt.Handler(params) + if err != nil { + return nil, err + } + + if result.Error != nil { + return nil, result.Error + } + + var messages []*mcp.PromptMessage + for _, msg := range result.Messages { + mcpMsg := &mcp.PromptMessage{ + Role: mcp.Role(msg.Role), + } + + switch msg.Content.Type { + case "text": + mcpMsg.Content = &mcp.TextContent{ + Text: msg.Content.Text, + } + default: + mcpMsg.Content = &mcp.TextContent{ + Text: msg.Content.Text, + } + } + + messages = append(messages, mcpMsg) + } + + return &mcp.GetPromptResult{ + Description: result.Description, + Messages: messages, + }, nil + } + + return mcpPrompt, handler, nil +} diff --git a/pkg/mcp/prompts_gosdk_test.go b/pkg/mcp/prompts_gosdk_test.go new file mode 100644 index 00000000..8093cf53 --- /dev/null +++ b/pkg/mcp/prompts_gosdk_test.go @@ -0,0 +1,140 @@ +package mcp + +import ( + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func TestPromptCallRequestAdapter_GetArguments(t *testing.T) { + tests := []struct { + name string + request *mcp.GetPromptRequest + want map[string]string + }{ + { + name: "nil request", + request: nil, + want: map[string]string{}, + }, + { + name: "nil params", + request: &mcp.GetPromptRequest{ + Params: nil, + }, + want: map[string]string{}, + }, + { + name: "nil arguments", + request: &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: nil, + }, + }, + want: map[string]string{}, + }, + { + name: "with arguments", + request: &mcp.GetPromptRequest{ + Params: &mcp.GetPromptParams{ + Arguments: map[string]string{ + "namespace": "default", + "pod_name": "test-pod", + }, + }, + }, + want: map[string]string{ + "namespace": "default", + "pod_name": "test-pod", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + adapter := &promptCallRequestAdapter{request: tt.request} + got := adapter.GetArguments() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestServerPromptToGoSdkPrompt_Conversion(t *testing.T) { + serverPrompt := api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "test-prompt", + Description: "Test description", + Arguments: []api.PromptArgument{ + { + Name: "arg1", + Description: "First argument", + Required: true, + }, + { + Name: "arg2", + Description: "Second argument", + Required: false, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Test result", []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: "Test message", + }, + }, + }, nil), nil + }, + } + + mockServer := &Server{} + + mcpPrompt, handler, err := ServerPromptToGoSdkPrompt(mockServer, serverPrompt) + + require.NoError(t, err) + require.NotNil(t, mcpPrompt) + require.NotNil(t, handler) + + assert.Equal(t, "test-prompt", mcpPrompt.Name) + assert.Equal(t, "Test description", mcpPrompt.Description) + require.Len(t, mcpPrompt.Arguments, 2) + + assert.Equal(t, "arg1", mcpPrompt.Arguments[0].Name) + assert.Equal(t, "First argument", mcpPrompt.Arguments[0].Description) + assert.True(t, mcpPrompt.Arguments[0].Required) + + assert.Equal(t, "arg2", mcpPrompt.Arguments[1].Name) + assert.Equal(t, "Second argument", mcpPrompt.Arguments[1].Description) + assert.False(t, mcpPrompt.Arguments[1].Required) +} + +func TestServerPromptToGoSdkPrompt_EmptyArguments(t *testing.T) { + serverPrompt := api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "no-args-prompt", + Description: "Prompt with no arguments", + Arguments: []api.PromptArgument{}, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Result", []api.PromptMessage{}, nil), nil + }, + } + + mockServer := &Server{} + + mcpPrompt, handler, err := ServerPromptToGoSdkPrompt(mockServer, serverPrompt) + + require.NoError(t, err) + require.NotNil(t, mcpPrompt) + require.NotNil(t, handler) + + assert.Equal(t, "no-args-prompt", mcpPrompt.Name) + assert.Len(t, mcpPrompt.Arguments, 0) +} diff --git a/pkg/toolsets/config/toolset.go b/pkg/toolsets/config/toolset.go index 5d641fe5..7809c761 100644 --- a/pkg/toolsets/config/toolset.go +++ b/pkg/toolsets/config/toolset.go @@ -26,6 +26,11 @@ func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts(_ internalk8s.Openshift) []api.ServerPrompt { + // Config toolset does not provide prompts + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/core/prompts.go b/pkg/toolsets/core/prompts.go new file mode 100644 index 00000000..c83ee151 --- /dev/null +++ b/pkg/toolsets/core/prompts.go @@ -0,0 +1,381 @@ +package core + +import ( + "fmt" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func initPrompts() []api.ServerPrompt { + return []api.ServerPrompt{ + troubleshootPodPrompt(), + deployApplicationPrompt(), + scaleDeploymentPrompt(), + investigateClusterHealthPrompt(), + debugNetworkingPrompt(), + reviewResourceUsagePrompt(), + } +} + +func troubleshootPodPrompt() api.ServerPrompt { + return api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "troubleshoot-pod", + Description: "Guide for troubleshooting a failing or crashed pod in Kubernetes", + Arguments: []api.PromptArgument{ + { + Name: "namespace", + Description: "The namespace where the pod is located", + Required: true, + }, + { + Name: "pod_name", + Description: "The name of the pod to troubleshoot", + Required: true, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + namespace := args["namespace"] + podName := args["pod_name"] + + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: fmt.Sprintf(`I need help troubleshooting a pod in Kubernetes. + +Namespace: %s +Pod name: %s + +Please help me investigate why this pod is failing or not working as expected.`, namespace, podName), + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: fmt.Sprintf(`I'll help you troubleshoot the pod %s in namespace %s. Let me investigate systematically: + +1. First, I'll check the pod status and recent events +2. Then examine the pod's logs for error messages +3. Check resource constraints and limits +4. Verify the pod's configuration and health checks + +Let me start by gathering information about the pod.`, podName, namespace), + }, + }, + } + + return api.NewPromptCallResult("Guide for troubleshooting a failing or crashed pod in Kubernetes", messages, nil), nil + }, + } +} + +func deployApplicationPrompt() api.ServerPrompt { + return api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "deploy-application", + Description: "Workflow for deploying a new application to Kubernetes", + Arguments: []api.PromptArgument{ + { + Name: "app_name", + Description: "The name of the application to deploy", + Required: true, + }, + { + Name: "namespace", + Description: "The namespace to deploy to (optional, defaults to 'default')", + Required: false, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + appName := args["app_name"] + namespace, hasNs := args["namespace"] + + userContent := fmt.Sprintf(`I want to deploy a new application to Kubernetes. + +Application name: %s`, appName) + if hasNs && namespace != "" { + userContent += fmt.Sprintf("\nNamespace: %s", namespace) + } + userContent += "\n\nPlease guide me through the deployment process." + + assistantContent := fmt.Sprintf(`I'll help you deploy %s to Kubernetes. Here's the recommended workflow: + +1. Create/verify the namespace`, appName) + if hasNs && namespace != "" { + assistantContent += fmt.Sprintf(" (%s)", namespace) + } + assistantContent += ` +2. Review or create deployment manifests +3. Apply the deployment configuration +4. Verify the deployment status +5. Check that pods are running correctly +6. Set up services and ingress if needed + +Let's start by checking the current state of the cluster and namespace.` + + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: userContent, + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: assistantContent, + }, + }, + } + + return api.NewPromptCallResult("Workflow for deploying a new application to Kubernetes", messages, nil), nil + }, + } +} + +func scaleDeploymentPrompt() api.ServerPrompt { + return api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "scale-deployment", + Description: "Guide for scaling a deployment up or down", + Arguments: []api.PromptArgument{ + { + Name: "deployment_name", + Description: "The name of the deployment to scale", + Required: true, + }, + { + Name: "namespace", + Description: "The namespace of the deployment", + Required: true, + }, + { + Name: "replicas", + Description: "The desired number of replicas", + Required: true, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + deploymentName := args["deployment_name"] + namespace := args["namespace"] + replicas := args["replicas"] + + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: fmt.Sprintf(`I need to scale a deployment in Kubernetes. + +Deployment: %s +Namespace: %s +Desired replicas: %s + +Please help me scale this deployment safely.`, deploymentName, namespace, replicas), + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: fmt.Sprintf(`I'll help you scale the deployment %s to %s replicas in namespace %s. + +Before scaling, let me: +1. Check the current deployment status and replica count +2. Verify the deployment is healthy +3. Scale the deployment to %s replicas +4. Monitor the scaling process +5. Verify all new pods are running correctly + +Let's begin by checking the current state of the deployment.`, deploymentName, replicas, namespace, replicas), + }, + }, + } + + return api.NewPromptCallResult("Guide for scaling a deployment up or down", messages, nil), nil + }, + } +} + +func investigateClusterHealthPrompt() api.ServerPrompt { + return api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "investigate-cluster-health", + Description: "Comprehensive workflow for investigating overall cluster health", + Arguments: []api.PromptArgument{}, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: `I want to investigate the overall health and status of my Kubernetes cluster. +Please help me understand the cluster's current state.`, + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: `I'll perform a comprehensive health check of your Kubernetes cluster. Here's what I'll investigate: + +1. **Node Health**: Check status of all nodes, resource usage, and any issues +2. **Critical System Pods**: Verify all system pods are running correctly +3. **Recent Events**: Review cluster-wide events for warnings or errors +4. **Resource Usage**: Check overall resource consumption across the cluster +5. **Namespace Overview**: List all namespaces and their status + +Let me start by gathering information about your cluster.`, + }, + }, + } + + return api.NewPromptCallResult("Comprehensive workflow for investigating overall cluster health", messages, nil), nil + }, + } +} + +func debugNetworkingPrompt() api.ServerPrompt { + return api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "debug-networking", + Description: "Workflow for debugging networking issues between pods or services", + Arguments: []api.PromptArgument{ + { + Name: "source_pod", + Description: "The source pod name", + Required: false, + }, + { + Name: "source_namespace", + Description: "The source pod namespace", + Required: false, + }, + { + Name: "target_service", + Description: "The target service name", + Required: false, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + sourcePod, hasSrcPod := args["source_pod"] + sourceNs, hasSrcNs := args["source_namespace"] + targetSvc, hasTgtSvc := args["target_service"] + + userContent := "I'm experiencing networking issues in my Kubernetes cluster." + if hasSrcPod && sourcePod != "" { + userContent += fmt.Sprintf("\nSource pod: %s", sourcePod) + } + if hasSrcNs && sourceNs != "" { + userContent += fmt.Sprintf("\nSource namespace: %s", sourceNs) + } + if hasTgtSvc && targetSvc != "" { + userContent += fmt.Sprintf("\nTarget service: %s", targetSvc) + } + userContent += "\n\nPlease help me debug the networking problem." + + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: userContent, + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: `I'll help you debug the networking issue. Let me investigate systematically: + +1. **Pod Network Status**: Check if pods have valid IPs and are in Running state +2. **Service Configuration**: Verify service endpoints and selectors +3. **Network Policies**: Check for any network policies that might block traffic +4. **DNS Resolution**: Test if DNS is working correctly +5. **Connectivity Tests**: Perform network tests between pods + +Let's start gathering diagnostic information.`, + }, + }, + } + + return api.NewPromptCallResult("Workflow for debugging networking issues between pods or services", messages, nil), nil + }, + } +} + +func reviewResourceUsagePrompt() api.ServerPrompt { + return api.ServerPrompt{ + Prompt: api.Prompt{ + Name: "review-resource-usage", + Description: "Analyze resource usage across the cluster or specific namespace", + Arguments: []api.PromptArgument{ + { + Name: "namespace", + Description: "Optional namespace to focus on (leave empty for cluster-wide analysis)", + Required: false, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + namespace, hasNs := args["namespace"] + + userContent := "I want to review resource usage in my Kubernetes cluster." + if hasNs && namespace != "" { + userContent += fmt.Sprintf("\nFocus on namespace: %s", namespace) + } + userContent += "\n\nPlease analyze CPU and memory usage." + + assistantContent := "I'll analyze resource usage " + if hasNs && namespace != "" { + assistantContent += fmt.Sprintf("for namespace %s", namespace) + } else { + assistantContent += "across your entire cluster" + } + assistantContent += `. + +Here's what I'll check: +1. **Node Resources**: CPU and memory capacity vs usage on nodes +2. **Pod Resources**: Resource requests and limits for pods +3. **Top Consumers**: Identify pods with highest resource usage +4. **Resource Quota**: Check if namespace quotas are defined and their usage +5. **Recommendations**: Suggest optimizations if needed + +Let me gather the resource metrics.` + + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: userContent, + }, + }, + { + Role: "assistant", + Content: api.PromptContent{ + Type: "text", + Text: assistantContent, + }, + }, + } + + return api.NewPromptCallResult("Analyze resource usage across the cluster or specific namespace", messages, nil), nil + }, + } +} diff --git a/pkg/toolsets/core/prompts_test.go b/pkg/toolsets/core/prompts_test.go new file mode 100644 index 00000000..99242015 --- /dev/null +++ b/pkg/toolsets/core/prompts_test.go @@ -0,0 +1,128 @@ +package core + +import ( + "testing" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInitPrompts(t *testing.T) { + prompts := initPrompts() + + require.NotNil(t, prompts, "prompts should not be nil") + assert.Greater(t, len(prompts), 0, "should have at least one prompt") + + promptNames := make(map[string]bool) + for _, p := range prompts { + assert.NotEmpty(t, p.Prompt.Name, "prompt name should not be empty") + assert.NotEmpty(t, p.Prompt.Description, "prompt description should not be empty") + assert.NotNil(t, p.Handler, "prompt handler should not be nil") + + promptNames[p.Prompt.Name] = true + } + + expectedPrompts := []string{ + "troubleshoot-pod", + "deploy-application", + "scale-deployment", + "investigate-cluster-health", + "debug-networking", + "review-resource-usage", + } + + for _, expected := range expectedPrompts { + assert.True(t, promptNames[expected], "should contain prompt: %s", expected) + } +} + +func TestCoreToolset_GetPrompts(t *testing.T) { + toolset := &Toolset{} + prompts := toolset.GetPrompts(nil) + + require.NotNil(t, prompts) + assert.Greater(t, len(prompts), 0) + + for _, p := range prompts { + assert.NotEmpty(t, p.Prompt.Name) + assert.NotNil(t, p.Handler) + } +} + +func TestPromptArgumentDefinitions(t *testing.T) { + prompts := initPrompts() + require.NotNil(t, prompts) + + tests := []struct { + promptName string + expectedArgs int + requiredArgs []string + optionalArgs []string + }{ + { + promptName: "troubleshoot-pod", + expectedArgs: 2, + requiredArgs: []string{"namespace", "pod_name"}, + }, + { + promptName: "deploy-application", + expectedArgs: 2, + requiredArgs: []string{"app_name"}, + optionalArgs: []string{"namespace"}, + }, + { + promptName: "scale-deployment", + expectedArgs: 3, + requiredArgs: []string{"deployment_name", "namespace", "replicas"}, + }, + { + promptName: "investigate-cluster-health", + expectedArgs: 0, + }, + { + promptName: "debug-networking", + expectedArgs: 3, + optionalArgs: []string{"source_pod", "source_namespace", "target_service"}, + }, + { + promptName: "review-resource-usage", + expectedArgs: 1, + optionalArgs: []string{"namespace"}, + }, + } + + promptMap := make(map[string]*api.ServerPrompt) + for i := range prompts { + promptMap[prompts[i].Prompt.Name] = &prompts[i] + } + + for _, tt := range tests { + t.Run(tt.promptName, func(t *testing.T) { + prompt, exists := promptMap[tt.promptName] + require.True(t, exists, "prompt %s should exist", tt.promptName) + + assert.Len(t, prompt.Prompt.Arguments, tt.expectedArgs) + + argMap := make(map[string]bool) + requiredMap := make(map[string]bool) + + for _, arg := range prompt.Prompt.Arguments { + argMap[arg.Name] = true + if arg.Required { + requiredMap[arg.Name] = true + } + } + + for _, reqArg := range tt.requiredArgs { + assert.True(t, argMap[reqArg], "should have argument: %s", reqArg) + assert.True(t, requiredMap[reqArg], "argument should be required: %s", reqArg) + } + + for _, optArg := range tt.optionalArgs { + assert.True(t, argMap[optArg], "should have argument: %s", optArg) + assert.False(t, requiredMap[optArg], "argument should be optional: %s", optArg) + } + }) + } +} diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go index dfd61f42..b225e48a 100644 --- a/pkg/toolsets/core/toolset.go +++ b/pkg/toolsets/core/toolset.go @@ -30,6 +30,11 @@ func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts(_ internalk8s.Openshift) []api.ServerPrompt { + // Core toolset prompts will be loaded from embedded YAML files + return initPrompts() +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/helm/toolset.go b/pkg/toolsets/helm/toolset.go index dbe75c1e..7b931987 100644 --- a/pkg/toolsets/helm/toolset.go +++ b/pkg/toolsets/helm/toolset.go @@ -26,6 +26,11 @@ func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts(_ internalk8s.Openshift) []api.ServerPrompt { + // Helm toolset does not provide prompts + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go index 183ed98b..ce2834c3 100644 --- a/pkg/toolsets/kiali/toolset.go +++ b/pkg/toolsets/kiali/toolset.go @@ -31,6 +31,11 @@ func (t *Toolset) GetTools(_ internalk8s.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts(_ internalk8s.Openshift) []api.ServerPrompt { + // Kiali toolset does not provide prompts + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/kubevirt/toolset.go b/pkg/toolsets/kubevirt/toolset.go index bec5fd20..16ae591d 100644 --- a/pkg/toolsets/kubevirt/toolset.go +++ b/pkg/toolsets/kubevirt/toolset.go @@ -27,6 +27,11 @@ func (t *Toolset) GetTools(o internalk8s.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts(_ internalk8s.Openshift) []api.ServerPrompt { + // KubeVirt toolset does not provide prompts + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index 05af11a8..06ee0801 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -35,6 +35,8 @@ func (t *TestToolset) GetDescription() string { return t.description } func (t *TestToolset) GetTools(_ kubernetes.Openshift) []api.ServerTool { return nil } +func (t *TestToolset) GetPrompts(_ kubernetes.Openshift) []api.ServerPrompt { return nil } + var _ api.Toolset = (*TestToolset)(nil) func (s *ToolsetsSuite) TestToolsetNames() {