diff --git a/README.md b/README.md index d7a8d444..644cb424 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ pkill -HUP kubernetes-mcp-server ### MCP Prompts -The server supports MCP prompts for workflow templates. Define custom prompts in `config.toml`: +1. The server supports MCP prompts for workflow templates. Define custom prompts in `config.toml`: ```toml [[prompts]] @@ -311,6 +311,8 @@ role = "user" content = "Help me with {{resource_name}}" ``` +2. Toolset prompts implemented by toolset developers + See docs/PROMPTS.md for detailed documentation. ## 🛠️ Tools and Functionalities diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md index fe6f6d65..11c45589 100644 --- a/docs/PROMPTS.md +++ b/docs/PROMPTS.md @@ -59,4 +59,54 @@ Use `{{argument_name}}` placeholders in message content. The template engine rep ## Configuration File Location -Place your prompts in the `config.toml` file used by the MCP server. Specify the config file path using the `--config` flag when starting the server. \ No newline at end of file +Place your prompts in the `config.toml` file used by the MCP server. Specify the config file path using the `--config` flag when starting the server. + +## Toolset Prompts + +Toolsets can provide built-in prompts by implementing the `GetPrompts()` method. This allows toolset developers to ship workflow templates alongside their tools. + +### Implementing Toolset Prompts + +```go +func (t *MyToolset) GetPrompts() []api.ServerPrompt { + return []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "my-workflow", + Description: "Custom workflow for my toolset", + Arguments: []api.PromptArgument{ + { + Name: "namespace", + Description: "Target namespace", + Required: true, + }, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + namespace := args["namespace"] + + // Build messages dynamically based on arguments + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: fmt.Sprintf("Help me with namespace: %s", namespace), + }, + }, + } + + return api.NewPromptCallResult("Workflow description", messages, nil), nil + }, + }, + } +} +``` + +### Prompt Merging + +When both toolset and config prompts exist: +- Config-defined prompts **override** toolset prompts with the same name +- This allows administrators to customize built-in workflows +- Prompts with unique names from both sources are available diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index 3084f295..59b1f3c7 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -43,6 +43,9 @@ type Toolset interface { // Will be used to generate documentation and help text. GetDescription() string GetTools(o Openshift) []ServerTool + // GetPrompts returns the prompts provided by this toolset. + // Returns nil if the toolset doesn't provide any prompts. + GetPrompts() []ServerPrompt } type ToolCallRequest interface { diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index bd61836f..3c990ccb 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -167,6 +167,22 @@ func (s *Server) reloadToolsets() error { // 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 + for _, toolset := range s.configuration.Toolsets() { + prompts := toolset.GetPrompts() + if prompts == nil { + continue + } + for _, prompt := range prompts { + applicablePrompts = append(applicablePrompts, prompt) + s.enabledPrompts = append(s.enabledPrompts, prompt.Prompt.Name) + } + } + // Load config prompts into registry prompts.Clear() if s.configuration.HasPrompts() { @@ -180,9 +196,12 @@ func (s *Server) reloadToolsets() error { // Get prompts from registry configPrompts := prompts.ConfigPrompts() + // 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 configPrompts { + for _, prompt := range applicablePrompts { s.enabledPrompts = append(s.enabledPrompts, prompt.Prompt.Name) } @@ -195,8 +214,8 @@ func (s *Server) reloadToolsets() error { } s.server.RemovePrompts(promptsToRemove...) - // Register all config prompts - for _, prompt := range configPrompts { + // 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) diff --git a/pkg/mcp/mcp_toolset_prompts_test.go b/pkg/mcp/mcp_toolset_prompts_test.go new file mode 100644 index 00000000..3256f882 --- /dev/null +++ b/pkg/mcp/mcp_toolset_prompts_test.go @@ -0,0 +1,391 @@ +package mcp + +import ( + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/suite" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/config" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +// McpToolsetPromptsSuite tests toolset prompts integration +type McpToolsetPromptsSuite struct { + BaseMcpSuite + originalToolsets []api.Toolset +} + +func (s *McpToolsetPromptsSuite) SetupTest() { + s.BaseMcpSuite.SetupTest() + s.originalToolsets = toolsets.Toolsets() +} + +func (s *McpToolsetPromptsSuite) TearDownTest() { + s.BaseMcpSuite.TearDownTest() + // Restore original toolsets + toolsets.Clear() + for _, toolset := range s.originalToolsets { + toolsets.Register(toolset) + } +} + +func (s *McpToolsetPromptsSuite) TestToolsetReturningPrompts() { + testToolset := &mockToolsetWithPrompts{ + name: "test-toolset", + description: "Test toolset with prompts", + prompts: []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "toolset-prompt", + Description: "A prompt from a toolset", + Arguments: []api.PromptArgument{ + {Name: "arg1", Description: "Test argument", Required: true}, + }, + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + args := params.GetArguments() + messages := []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: "Toolset prompt with " + args["arg1"], + }, + }, + } + return api.NewPromptCallResult("Toolset prompt result", messages, nil), nil + }, + }, + }, + } + + toolsets.Clear() + toolsets.Register(testToolset) + s.Cfg.Toolsets = []string{"test-toolset"} + + s.InitMcpClient() + + prompts, err := s.ListPrompts(s.T().Context(), mcp.ListPromptsRequest{}) + + s.Run("ListPrompts returns toolset prompts", func() { + s.NoError(err) + s.NotNil(prompts) + }) + + s.Run("toolset prompt is available", func() { + s.Require().NotNil(prompts) + var found bool + for _, prompt := range prompts.Prompts { + if prompt.Name == "toolset-prompt" { + found = true + s.Equal("A prompt from a toolset", prompt.Description) + s.Require().Len(prompt.Arguments, 1) + s.Equal("arg1", prompt.Arguments[0].Name) + s.True(prompt.Arguments[0].Required) + break + } + } + s.True(found, "expected toolset prompt to be available") + }) + + s.Run("toolset prompt handler executes correctly", func() { + result, err := s.GetPrompt(s.T().Context(), mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{ + Name: "toolset-prompt", + Arguments: map[string]string{ + "arg1": "test-value", + }, + }, + }) + + s.NoError(err) + s.Require().NotNil(result) + s.Equal("Toolset prompt result", result.Description) + s.Require().Len(result.Messages, 1) + s.Equal("user", string(result.Messages[0].Role)) + + textContent, ok := result.Messages[0].Content.(mcp.TextContent) + s.Require().True(ok, "expected TextContent") + s.Equal("Toolset prompt with test-value", textContent.Text) + }) +} + +func (s *McpToolsetPromptsSuite) TestToolsetReturningNilPrompts() { + testToolset := &mockToolsetWithPrompts{ + name: "empty-toolset", + description: "Toolset with no prompts", + prompts: nil, + } + + toolsets.Clear() + toolsets.Register(testToolset) + s.Cfg.Toolsets = []string{"empty-toolset"} + + s.InitMcpClient() + + prompts, err := s.ListPrompts(s.T().Context(), mcp.ListPromptsRequest{}) + + s.Run("ListPrompts succeeds with nil toolset prompts", func() { + s.NoError(err) + s.NotNil(prompts) + }) + + s.Run("no prompts returned from nil toolset", func() { + s.Require().NotNil(prompts) + s.Empty(prompts.Prompts) + }) +} + +func (s *McpToolsetPromptsSuite) TestToolsetReturningEmptyPrompts() { + testToolset := &mockToolsetWithPrompts{ + name: "empty-slice-toolset", + description: "Toolset with empty prompts slice", + prompts: []api.ServerPrompt{}, + } + + toolsets.Clear() + toolsets.Register(testToolset) + s.Cfg.Toolsets = []string{"empty-slice-toolset"} + + s.InitMcpClient() + + prompts, err := s.ListPrompts(s.T().Context(), mcp.ListPromptsRequest{}) + + s.Run("ListPrompts succeeds with empty toolset prompts", func() { + s.NoError(err) + s.NotNil(prompts) + }) + + s.Run("no prompts returned from empty slice toolset", func() { + s.Require().NotNil(prompts) + s.Empty(prompts.Prompts) + }) +} + +func (s *McpToolsetPromptsSuite) TestMultipleToolsetsPromptCollection() { + toolset1 := &mockToolsetWithPrompts{ + name: "toolset1", + description: "First toolset", + prompts: []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "prompt1", + Description: "Prompt from toolset1", + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Prompt1", []api.PromptMessage{}, nil), nil + }, + }, + }, + } + + toolset2 := &mockToolsetWithPrompts{ + name: "toolset2", + description: "Second toolset", + prompts: []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "prompt2", + Description: "Prompt from toolset2", + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Prompt2", []api.PromptMessage{}, nil), nil + }, + }, + }, + } + + toolsets.Clear() + toolsets.Register(toolset1) + toolsets.Register(toolset2) + s.Cfg.Toolsets = []string{"toolset1", "toolset2"} + + s.InitMcpClient() + + prompts, err := s.ListPrompts(s.T().Context(), mcp.ListPromptsRequest{}) + + s.Run("ListPrompts collects from multiple toolsets", func() { + s.NoError(err) + s.Require().NotNil(prompts) + s.Require().Len(prompts.Prompts, 2) + }) + + s.Run("prompts from both toolsets are available", func() { + s.Require().NotNil(prompts) + promptNames := make(map[string]bool) + for _, prompt := range prompts.Prompts { + promptNames[prompt.Name] = true + } + s.True(promptNames["prompt1"], "expected prompt1 from toolset1") + s.True(promptNames["prompt2"], "expected prompt2 from toolset2") + }) +} + +func (s *McpToolsetPromptsSuite) TestConfigPromptsOverrideToolsetPrompts() { + testToolset := &mockToolsetWithPrompts{ + name: "test-toolset", + description: "Test toolset", + prompts: []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "shared-prompt", + Description: "Toolset version", + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Toolset", []api.PromptMessage{ + { + Role: "user", + Content: api.PromptContent{ + Type: "text", + Text: "From toolset", + }, + }, + }, nil), nil + }, + }, + }, + } + + toolsets.Clear() + toolsets.Register(testToolset) + + // Add config prompt with same name + cfg, err := config.ReadToml([]byte(` +toolsets = ["test-toolset"] + +[[prompts]] +name = "shared-prompt" +description = "Config version" + +[[prompts.messages]] +role = "user" +content = "From config" + `)) + s.Require().NoError(err) + // Preserve kubeconfig from SetupTest + cfg.KubeConfig = s.Cfg.KubeConfig + s.Cfg = cfg + + s.InitMcpClient() + + prompts, err := s.ListPrompts(s.T().Context(), mcp.ListPromptsRequest{}) + + s.Run("ListPrompts returns prompts", func() { + s.NoError(err) + s.Require().NotNil(prompts) + s.Require().Len(prompts.Prompts, 1) + }) + + s.Run("config prompt overrides toolset prompt", func() { + s.Require().NotNil(prompts) + s.Equal("shared-prompt", prompts.Prompts[0].Name) + s.Equal("Config version", prompts.Prompts[0].Description) + }) + + s.Run("config prompt handler is used", func() { + result, err := s.GetPrompt(s.T().Context(), mcp.GetPromptRequest{ + Params: mcp.GetPromptParams{ + Name: "shared-prompt", + }, + }) + + s.NoError(err) + s.Require().NotNil(result) + s.Require().Len(result.Messages, 1) + + textContent, ok := result.Messages[0].Content.(mcp.TextContent) + s.Require().True(ok) + s.Equal("From config", textContent.Text) + }) +} + +func (s *McpToolsetPromptsSuite) TestPromptsNotExposedWhenToolsetDisabled() { + enabledToolset := &mockToolsetWithPrompts{ + name: "enabled-toolset", + description: "Enabled toolset", + prompts: []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "enabled-prompt", + Description: "From enabled toolset", + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Enabled", []api.PromptMessage{}, nil), nil + }, + }, + }, + } + + disabledToolset := &mockToolsetWithPrompts{ + name: "disabled-toolset", + description: "Disabled toolset", + prompts: []api.ServerPrompt{ + { + Prompt: api.Prompt{ + Name: "disabled-prompt", + Description: "From disabled toolset", + }, + Handler: func(params api.PromptHandlerParams) (*api.PromptCallResult, error) { + return api.NewPromptCallResult("Disabled", []api.PromptMessage{}, nil), nil + }, + }, + }, + } + + toolsets.Clear() + toolsets.Register(enabledToolset) + toolsets.Register(disabledToolset) + // Only enable one toolset + s.Cfg.Toolsets = []string{"enabled-toolset"} + + s.InitMcpClient() + + prompts, err := s.ListPrompts(s.T().Context(), mcp.ListPromptsRequest{}) + + s.Run("ListPrompts returns prompts", func() { + s.NoError(err) + s.Require().NotNil(prompts) + }) + + s.Run("only enabled toolset prompts are available", func() { + s.Require().NotNil(prompts) + s.Require().Len(prompts.Prompts, 1) + s.Equal("enabled-prompt", prompts.Prompts[0].Name) + }) + + s.Run("disabled toolset prompts are not available", func() { + s.Require().NotNil(prompts) + for _, prompt := range prompts.Prompts { + s.NotEqual("disabled-prompt", prompt.Name) + } + }) +} + +// Mock toolset for testing +type mockToolsetWithPrompts struct { + name string + description string + prompts []api.ServerPrompt +} + +func (m *mockToolsetWithPrompts) GetName() string { + return m.name +} + +func (m *mockToolsetWithPrompts) GetDescription() string { + return m.description +} + +func (m *mockToolsetWithPrompts) GetTools(_ api.Openshift) []api.ServerTool { + return nil +} + +func (m *mockToolsetWithPrompts) GetPrompts() []api.ServerPrompt { + return m.prompts +} + +func TestMcpToolsetPromptsSuite(t *testing.T) { + suite.Run(t, new(McpToolsetPromptsSuite)) +} diff --git a/pkg/toolsets/config/toolset.go b/pkg/toolsets/config/toolset.go index d995b26c..3d08fb59 100644 --- a/pkg/toolsets/config/toolset.go +++ b/pkg/toolsets/config/toolset.go @@ -25,6 +25,11 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts() []api.ServerPrompt { + // Config toolset does not provide prompts + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go index 5a1a8e88..c371f161 100644 --- a/pkg/toolsets/core/toolset.go +++ b/pkg/toolsets/core/toolset.go @@ -29,6 +29,11 @@ func (t *Toolset) GetTools(o api.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts() []api.ServerPrompt { + // Core toolset prompts will be added in Feature 3 + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/helm/toolset.go b/pkg/toolsets/helm/toolset.go index 36a0ba28..6bdbfd41 100644 --- a/pkg/toolsets/helm/toolset.go +++ b/pkg/toolsets/helm/toolset.go @@ -25,6 +25,11 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts() []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 70db0e8c..6c36800b 100644 --- a/pkg/toolsets/kiali/toolset.go +++ b/pkg/toolsets/kiali/toolset.go @@ -32,6 +32,11 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts() []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 221f8254..9c87ddaf 100644 --- a/pkg/toolsets/kubevirt/toolset.go +++ b/pkg/toolsets/kubevirt/toolset.go @@ -28,6 +28,11 @@ func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { ) } +func (t *Toolset) GetPrompts() []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 971964d7..c2e86981 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -34,6 +34,8 @@ func (t *TestToolset) GetDescription() string { return t.description } func (t *TestToolset) GetTools(_ api.Openshift) []api.ServerTool { return nil } +func (t *TestToolset) GetPrompts() []api.ServerPrompt { return nil } + var _ api.Toolset = (*TestToolset)(nil) func (s *ToolsetsSuite) TestToolsetNames() {