diff --git a/internal/test/mcp.go b/internal/test/mcp.go index 1a0ccaabb..a71c3e764 100644 --- a/internal/test/mcp.go +++ b/internal/test/mcp.go @@ -63,6 +63,13 @@ func (m *McpClient) CallTool(name string, args map[string]interface{}) (*mcp.Cal return m.Client.CallTool(m.ctx, callToolRequest) } +// ReadResource helper function to read a resource by URI +func (m *McpClient) ReadResource(uri string) (*mcp.ReadResourceResult, error) { + readResourceRequest := mcp.ReadResourceRequest{} + readResourceRequest.Params.URI = uri + return m.Client.ReadResource(m.ctx, readResourceRequest) +} + // NotificationCapture captures MCP notifications for testing. // Use StartCapturingNotifications to begin capturing, then RequireNotification to retrieve. type NotificationCapture struct { diff --git a/pkg/api/resources.go b/pkg/api/resources.go new file mode 100644 index 000000000..cd89bc677 --- /dev/null +++ b/pkg/api/resources.go @@ -0,0 +1,127 @@ +package api + +import "context" + +type ServerResource struct { + Resource Resource + Handler ResourceHandlerFunc + ClusterAware *bool +} + +func (sr *ServerResource) IsClusterAware() bool { + if sr.ClusterAware != nil { + return *sr.ClusterAware + } + return true +} + +type ServerResourceTemplate struct { + ResourceTemplate ResourceTemplate + Handler ResourceHandlerFunc + ClusterAware *bool +} + +func (srt *ServerResourceTemplate) IsClusterAware() bool { + if srt.ClusterAware != nil { + return *srt.ClusterAware + } + return true +} + +type ResourceHandlerFunc func(params ResourceHandlerParams) (*ResourceCallResult, error) + +type ResourceHandlerParams struct { + context.Context + ExtendedConfigProvider + KubernetesClient + URI string +} + +type ResourceCallResult struct { + Contents []*ResourceContents +} + +type Resource struct { + // Optional annotations for the client + Annotations *ResourceAnnotations + // A description of what this resource represents. + // + // This can be used by clients to improve the LLM's understanding of available + // resources. + Description string + // The MIME type of this resource, if known + MIMEType string + // Name of the resource + Name string + // The size of the raw resource content, in bytes, if known + Size int64 + // Human readable title, if not provided the name will be used to display to users + Title string + // The URI of this resource + URI string +} + +type ResourceTemplate struct { + // Optional annotations for the client + Annotations *ResourceAnnotations + // A description of what this resource represents. + // + // This can be used by clients to improve the LLM's understanding of available + // resources. + Description string + // The MIME type of this resource, if known + MIMEType string + // Name of the resource + Name string + // The size of the raw resource content, in bytes, if known + Size int64 + // Human readable title, if not provided the name will be used to display to users + Title string + // A URI template (according to RFC 6570) that can be used to construct resource URIs + URITemplate string +} + +func NewResourceTextResult(uri, mimeType, text string) *ResourceCallResult { + return &ResourceCallResult{ + Contents: []*ResourceContents{{ + URI: uri, + MIMEType: mimeType, + Text: text, + }}, + } +} + +func NewResourceBinaryResult(uri, mimeType string, blob []byte) *ResourceCallResult { + return &ResourceCallResult{ + Contents: []*ResourceContents{{ + URI: uri, + MIMEType: mimeType, + Blob: blob, + }}, + } +} + +type ResourceAnnotations struct { + // Described who the intended customer of this object or data is + + // It can include multiple entries to indicate content useful for multiple + // audiences, (e.g. []string{"user", "assistant"}). + Audience []string `json:"audience,omitempty"` + // The moment the resource was last modified, as an ISO 8601 formatted string. + // + // Examples: last activity timestamp in an open file + LastModified string `json:"lastModified,omitempty"` + // Describes how important this data is for operating the server. + // + // A value of 1 means "most important", and indicates that the data is + // effectively required, while 0 means "least important", and indicates + // that the data is entirely optional. + Priority float64 `json:"priority,omitempty"` +} + +type ResourceContents struct { + URI string + MIMEType string + Text string + Blob []byte +} diff --git a/pkg/api/resources_test.go b/pkg/api/resources_test.go new file mode 100644 index 000000000..842eed576 --- /dev/null +++ b/pkg/api/resources_test.go @@ -0,0 +1,160 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" +) + +func TestServerResource_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) { + sr := &ServerResource{ + ClusterAware: tt.clusterAware, + } + assert.Equal(t, tt.want, sr.IsClusterAware()) + }) + } +} + +func TestServerResourceTemplate_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) { + srt := &ServerResourceTemplate{ + ClusterAware: tt.clusterAware, + } + assert.Equal(t, tt.want, srt.IsClusterAware()) + }) + } +} + +func TestNewResourceTextResult(t *testing.T) { + tests := []struct { + name string + uri string + mimeType string + text string + }{ + { + name: "simple text resource", + uri: "file:///test.txt", + mimeType: "text/plain", + text: "Hello, World!", + }, + { + name: "json resource", + uri: "k8s://pods/default/my-pod", + mimeType: "application/json", + text: `{"kind":"Pod","metadata":{"name":"my-pod"}}`, + }, + { + name: "empty text", + uri: "file:///empty.txt", + mimeType: "text/plain", + text: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewResourceTextResult(tt.uri, tt.mimeType, tt.text) + assert.NotNil(t, result) + assert.Len(t, result.Contents, 1) + assert.Equal(t, tt.uri, result.Contents[0].URI) + assert.Equal(t, tt.mimeType, result.Contents[0].MIMEType) + assert.Equal(t, tt.text, result.Contents[0].Text) + assert.Nil(t, result.Contents[0].Blob) + }) + } +} + +func TestNewResourceBinaryResult(t *testing.T) { + tests := []struct { + name string + uri string + mimeType string + blob []byte + }{ + { + name: "binary image", + uri: "file:///image.png", + mimeType: "image/png", + blob: []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}, + }, + { + name: "binary data", + uri: "k8s://secrets/default/my-secret", + mimeType: "application/octet-stream", + blob: []byte{0x01, 0x02, 0x03, 0x04}, + }, + { + name: "empty blob", + uri: "file:///empty.bin", + mimeType: "application/octet-stream", + blob: []byte{}, + }, + { + name: "nil blob", + uri: "file:///nil.bin", + mimeType: "application/octet-stream", + blob: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NewResourceBinaryResult(tt.uri, tt.mimeType, tt.blob) + assert.NotNil(t, result) + assert.Len(t, result.Contents, 1) + assert.Equal(t, tt.uri, result.Contents[0].URI) + assert.Equal(t, tt.mimeType, result.Contents[0].MIMEType) + assert.Equal(t, tt.blob, result.Contents[0].Blob) + assert.Empty(t, result.Contents[0].Text) + }) + } +} diff --git a/pkg/api/toolsets.go b/pkg/api/toolsets.go index 59b1f3c70..4d8612d0b 100644 --- a/pkg/api/toolsets.go +++ b/pkg/api/toolsets.go @@ -46,6 +46,12 @@ type Toolset interface { // GetPrompts returns the prompts provided by this toolset. // Returns nil if the toolset doesn't provide any prompts. GetPrompts() []ServerPrompt + // GetResources returns the resources provided by this toolset. + // Returns nil if the toolset doesn't provide any resources. + GetResources() []ServerResource + // GetResourceTemplates returns the resource templates provided by this toolset. + // Returns nil if the toolset doesn't provide any resources templates. + GetResourceTemplates() []ServerResourceTemplate } type ToolCallRequest interface { diff --git a/pkg/mcp/crd_openapi_test.go b/pkg/mcp/crd_openapi_test.go new file mode 100644 index 000000000..cbd0aa419 --- /dev/null +++ b/pkg/mcp/crd_openapi_test.go @@ -0,0 +1,70 @@ +package mcp + +import ( + "encoding/json" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/suite" +) + +type CRDOpenAPISuite struct { + BaseMcpSuite +} + +func (s *CRDOpenAPISuite) TestCRDOpenAPISpecResourceTemplate() { + s.Require().NoError(EnvTestEnableCRD(s.T().Context(), "kubevirt.io", "v1", "virtualmachines")) + s.T().Cleanup(func() { + s.Require().NoError(EnvTestDisableCRD(s.T().Context(), "kubevirt.io", "v1", "virtualmachines")) + }) + s.InitMcpClient() + + s.Run("returns OpenAPI spec for existing CRD", func() { + result, err := s.ReadResource("k8s://crds/virtualmachines.kubevirt.io/openapi") + s.Require().NoError(err, "reading resource should not fail") + s.Require().NotNil(result, "result should not be nil") + s.Require().Len(result.Contents, 1, "expected exactly one content") + + textContent, ok := mcp.AsTextResourceContents(result.Contents[0]) + s.Require().True(ok, "expected text resource contents") + s.Equal("k8s://crds/virtualmachines.kubevirt.io/openapi", textContent.URI) + s.Equal("application/json", textContent.MIMEType) + + // Parse and verify the JSON structure + var response struct { + Group string `json:"group"` + Kind string `json:"kind"` + Versions []struct { + Name string `json:"name"` + Served bool `json:"served"` + Storage bool `json:"storage"` + OpenAPISchema interface{} `json:"openAPIV3Schema,omitempty"` + } `json:"versions"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + s.Require().NoError(err, "response should be valid JSON") + s.Equal("kubevirt.io", response.Group) + s.Equal("VirtualMachine", response.Kind) + s.NotEmpty(response.Versions, "expected at least one version") + + // Check the version details + foundV1 := false + for _, v := range response.Versions { + if v.Name == "v1" { + foundV1 = true + s.NotNil(v.OpenAPISchema, "v1 should have an OpenAPI schema") + } + } + s.True(foundV1, "expected to find v1 version") + }) + + s.Run("returns error for nonexistent CRD", func() { + _, err := s.ReadResource("k8s://crds/nonexistent.example.com/openapi") + s.Error(err, "should return error for nonexistent CRD") + s.Contains(err.Error(), "not found", "error should indicate CRD not found") + }) +} + +func TestCRDOpenAPI(t *testing.T) { + suite.Run(t, new(CRDOpenAPISuite)) +} diff --git a/pkg/mcp/mcp.go b/pkg/mcp/mcp.go index a4f319ce0..4e30f231b 100644 --- a/pkg/mcp/mcp.go +++ b/pkg/mcp/mcp.go @@ -61,12 +61,14 @@ func (c *Configuration) isToolApplicable(tool api.ServerTool) bool { } type Server struct { - configuration *Configuration - server *mcp.Server - enabledTools []string - enabledPrompts []string - p internalk8s.Provider - metrics *metrics.Metrics // Metrics collection system + configuration *Configuration + server *mcp.Server + enabledTools []string + enabledPrompts []string + enabledResources []string + enabledResourceTemplates []string + p internalk8s.Provider + metrics *metrics.Metrics // Metrics collection system } func NewServer(configuration Configuration, targetProvider internalk8s.Provider) (*Server, error) { @@ -81,7 +83,7 @@ func NewServer(configuration Configuration, targetProvider internalk8s.Provider) }, &mcp.ServerOptions{ Capabilities: &mcp.ServerCapabilities{ - Resources: nil, + Resources: &mcp.ResourceCapabilities{ListChanged: !configuration.Stateless}, Prompts: &mcp.PromptCapabilities{ListChanged: !configuration.Stateless}, Tools: &mcp.ToolCapabilities{ListChanged: !configuration.Stateless}, Logging: &mcp.LoggingCapabilities{}, @@ -129,96 +131,188 @@ func (s *Server) reloadToolsets() error { return err } - filter := CompositeFilter( - s.configuration.isToolApplicable, - ShouldIncludeTargetListTool(s.p.GetTargetParameterName(), targets), + // Collect applicable items (each with its own preprocessing logic) + applicableTools := s.collectApplicableTools(targets) + applicablePrompts := s.collectApplicablePrompts() + applicableResources := s.collectApplicableResources() + applicableResourceTemplates := s.collectApplicableResourceTemplates() + + // Reload tools + s.enabledTools, err = reloadItems( + s.enabledTools, + applicableTools, + func(t api.ServerTool) string { return t.Tool.Name }, + s.server.RemoveTools, + s.registerTool, ) + if err != nil { + return err + } - mutator := ComposeMutators( - WithTargetParameter(s.p.GetDefaultTarget(), s.p.GetTargetParameterName(), targets), - WithTargetListTool(s.p.GetDefaultTarget(), s.p.GetTargetParameterName(), targets), + // Reload prompts + s.enabledPrompts, err = reloadItems( + s.enabledPrompts, + applicablePrompts, + func(p api.ServerPrompt) string { return p.Prompt.Name }, + s.server.RemovePrompts, + s.registerPrompt, ) + if err != nil { + return err + } - // TODO: No option to perform a full replacement of tools. - // s.server.SetTools(m3labsServerTools...) + // Reload resources + s.enabledResources, err = reloadItems( + s.enabledResources, + applicableResources, + func(r api.ServerResource) string { return r.Resource.Name }, + s.server.RemoveResources, + s.registerResource, + ) + if err != nil { + return err + } - // Track previously enabled tools - previousTools := s.enabledTools + // Reload resource templates + s.enabledResourceTemplates, err = reloadItems( + s.enabledResourceTemplates, + applicableResourceTemplates, + func(t api.ServerResourceTemplate) string { return t.ResourceTemplate.Name }, + s.server.RemoveResourceTemplates, + s.registerResourceTemplate, + ) + if err != nil { + return err + } - // Build new list of applicable tools - applicableTools := make([]api.ServerTool, 0) - s.enabledTools = make([]string, 0) - for _, toolset := range s.configuration.Toolsets() { - for _, tool := range toolset.GetTools(s.p) { - tool := mutator(tool) - if !filter(tool) { - continue - } + // Start new watch + s.p.WatchTargets(s.reloadToolsets) + return nil +} - applicableTools = append(applicableTools, tool) - s.enabledTools = append(s.enabledTools, tool.Tool.Name) - } +// reloadItems handles the common pattern of reloading MCP server items. +// It removes items that are no longer applicable, registers new items, +// and returns the updated list of enabled item names. +func reloadItems[T any]( + previous []string, + items []T, + getName func(T) string, + remove func(...string), + register func(T) error, +) ([]string, error) { + // Build new enabled list + enabled := make([]string, 0, len(items)) + for _, item := range items { + enabled = append(enabled, getName(item)) } - // TODO: No option to perform a full replacement of tools. - // Remove tools that are no longer applicable - toolsToRemove := make([]string, 0) - for _, oldTool := range previousTools { - if !slices.Contains(s.enabledTools, oldTool) { - toolsToRemove = append(toolsToRemove, oldTool) + // Remove items that are no longer applicable + toRemove := make([]string, 0) + for _, old := range previous { + if !slices.Contains(enabled, old) { + toRemove = append(toRemove, old) } } - s.server.RemoveTools(toolsToRemove...) + remove(toRemove...) - for _, tool := range applicableTools { - goSdkTool, goSdkToolHandler, err := ServerToolToGoSdkTool(s, tool) - if err != nil { - return fmt.Errorf("failed to convert tool %s: %w", tool.Tool.Name, err) + // Register all items + for _, item := range items { + if err := register(item); err != nil { + return nil, err } - s.server.AddTool(goSdkTool, goSdkToolHandler) } - // Track previously enabled prompts - previousPrompts := s.enabledPrompts + return enabled, nil +} - // Build and register prompts from all toolsets +// collectApplicableTools returns tools after applying filtering and mutation +func (s *Server) collectApplicableTools(targets []string) []api.ServerTool { + filter := CompositeFilter( + s.configuration.isToolApplicable, + ShouldIncludeTargetListTool(s.p.GetTargetParameterName(), targets), + ) + mutator := ComposeMutators( + WithTargetParameter(s.p.GetDefaultTarget(), s.p.GetTargetParameterName(), targets), + WithTargetListTool(s.p.GetDefaultTarget(), s.p.GetTargetParameterName(), targets), + ) + + tools := make([]api.ServerTool, 0) + for _, toolset := range s.configuration.Toolsets() { + for _, tool := range toolset.GetTools(s.p) { + tool = mutator(tool) + if filter(tool) { + tools = append(tools, tool) + } + } + } + return tools +} + +// collectApplicablePrompts returns prompts after merging toolset and config prompts +func (s *Server) collectApplicablePrompts() []api.ServerPrompt { toolsetPrompts := make([]api.ServerPrompt, 0) - // Load embedded toolset prompts for _, toolset := range s.configuration.Toolsets() { toolsetPrompts = append(toolsetPrompts, toolset.GetPrompts()...) } - configPrompts := prompts.ToServerPrompts(s.configuration.Prompts) + return prompts.MergePrompts(toolsetPrompts, configPrompts) +} - // Merge: config prompts override embedded prompts with same name - applicablePrompts := prompts.MergePrompts(toolsetPrompts, configPrompts) +// collectApplicableResources returns resources from all enabled toolsets +func (s *Server) collectApplicableResources() []api.ServerResource { + resources := make([]api.ServerResource, 0) + for _, toolset := range s.configuration.Toolsets() { + resources = append(resources, toolset.GetResources()...) + } + return resources +} - // Update enabled prompts list - s.enabledPrompts = make([]string, 0) - for _, prompt := range applicablePrompts { - s.enabledPrompts = append(s.enabledPrompts, prompt.Prompt.Name) +// collectApplicableResourceTemplates returns resource templates from all enabled toolsets +func (s *Server) collectApplicableResourceTemplates() []api.ServerResourceTemplate { + templates := make([]api.ServerResourceTemplate, 0) + for _, toolset := range s.configuration.Toolsets() { + templates = append(templates, toolset.GetResourceTemplates()...) } + return templates +} - // 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) - } +// registerTool converts and registers a tool with the MCP server +func (s *Server) registerTool(tool api.ServerTool) error { + goSdkTool, goSdkToolHandler, err := ServerToolToGoSdkTool(s, tool) + if err != nil { + return fmt.Errorf("failed to convert tool %s: %w", tool.Tool.Name, err) } - s.server.RemovePrompts(promptsToRemove...) + s.server.AddTool(goSdkTool, goSdkToolHandler) + return nil +} - // 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: %w", prompt.Prompt.Name, err) - } - s.server.AddPrompt(mcpPrompt, promptHandler) +// registerPrompt converts and registers a prompt with the MCP server +func (s *Server) registerPrompt(prompt api.ServerPrompt) error { + mcpPrompt, promptHandler, err := ServerPromptToGoSdkPrompt(s, prompt) + if err != nil { + return fmt.Errorf("failed to convert prompt %s: %w", prompt.Prompt.Name, err) } + s.server.AddPrompt(mcpPrompt, promptHandler) + return nil +} - // start new watch - s.p.WatchTargets(s.reloadToolsets) +// registerResource converts and registers a resource with the MCP server +func (s *Server) registerResource(resource api.ServerResource) error { + mcpResource, resourceHandler, err := ServerResourceToGoSdkResource(s, resource) + if err != nil { + return fmt.Errorf("failed to convert resource %s: %w", resource.Resource.Name, err) + } + s.server.AddResource(mcpResource, resourceHandler) + return nil +} + +// registerResourceTemplate converts and registers a resource template with the MCP server +func (s *Server) registerResourceTemplate(template api.ServerResourceTemplate) error { + mcpTemplate, templateHandler, err := ServerResourceTemplateToGoSdkResourceTemplate(s, template) + if err != nil { + return fmt.Errorf("failed to convert resource template %s: %w", template.ResourceTemplate.Name, err) + } + s.server.AddResourceTemplate(mcpTemplate, templateHandler) return nil } @@ -294,6 +388,16 @@ func (s *Server) GetEnabledPrompts() []string { return s.enabledPrompts } +// GetEnabledResources returns the names of the currently enabled resources +func (s *Server) GetEnabledResources() []string { + return s.enabledResources +} + +// GetEnabledResourceTemplates returns the names of the currently enabled resource templates +func (s *Server) GetEnabledResourceTemplates() []string { + return s.enabledResourceTemplates +} + // ReloadConfiguration reloads the configuration and reinitializes the server. // This is intended to be called by the server lifecycle manager when // configuration changes are detected. diff --git a/pkg/mcp/mcp_toolset_prompts_test.go b/pkg/mcp/mcp_toolset_prompts_test.go index 3256f882f..26326b0a8 100644 --- a/pkg/mcp/mcp_toolset_prompts_test.go +++ b/pkg/mcp/mcp_toolset_prompts_test.go @@ -386,6 +386,14 @@ func (m *mockToolsetWithPrompts) GetPrompts() []api.ServerPrompt { return m.prompts } +func (m *mockToolsetWithPrompts) GetResources() []api.ServerResource { + return nil +} + +func (m *mockToolsetWithPrompts) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + func TestMcpToolsetPromptsSuite(t *testing.T) { suite.Run(t, new(McpToolsetPromptsSuite)) } diff --git a/pkg/mcp/resources_gosdk.go b/pkg/mcp/resources_gosdk.go new file mode 100644 index 000000000..9f08b39a5 --- /dev/null +++ b/pkg/mcp/resources_gosdk.go @@ -0,0 +1,108 @@ +package mcp + +import ( + "context" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ServerResourceToGoSdkResource converts an api.ServerResource to MCP SDK types +func ServerResourceToGoSdkResource(s *Server, resource api.ServerResource) (*mcp.Resource, mcp.ResourceHandler, error) { + goSdkResource := &mcp.Resource{ + Name: resource.Resource.Name, + Description: resource.Resource.Description, + Title: resource.Resource.Title, + URI: resource.Resource.URI, + MIMEType: resource.Resource.MIMEType, + Size: resource.Resource.Size, + Annotations: toMcpAnnotations(resource.Resource.Annotations), + } + + return goSdkResource, newResourceHandler(s, resource.Handler), nil +} + +// ServerResourceTemplateToGoSdkResourceTemplate converts an api.ServerResourceTemplate to MCP SDK types +func ServerResourceTemplateToGoSdkResourceTemplate(s *Server, template api.ServerResourceTemplate) (*mcp.ResourceTemplate, mcp.ResourceHandler, error) { + goSdkTemplate := &mcp.ResourceTemplate{ + Name: template.ResourceTemplate.Name, + Description: template.ResourceTemplate.Description, + Title: template.ResourceTemplate.Title, + URITemplate: template.ResourceTemplate.URITemplate, + MIMEType: template.ResourceTemplate.MIMEType, + Annotations: toMcpAnnotations(template.ResourceTemplate.Annotations), + } + + return goSdkTemplate, newResourceHandler(s, template.Handler), nil +} + +// newResourceHandler creates a common resource handler for both resources and resource templates +func newResourceHandler(s *Server, handler api.ResourceHandlerFunc) mcp.ResourceHandler { + return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + uri := "" + if request.Params != nil { + uri = request.Params.URI + } + + // Get the Kubernetes client using the default target + // Resources don't carry cluster information in the URI + // TODO: revisit this, as things may differ cluster to cluster + k, err := s.p.GetDerivedKubernetes(ctx, s.p.GetDefaultTarget()) + if err != nil { + return nil, err + } + + result, err := handler(api.ResourceHandlerParams{ + Context: ctx, + ExtendedConfigProvider: s.configuration, + KubernetesClient: k, + URI: uri, + }) + if err != nil { + return nil, err + } + + return toMcpReadResourceResult(result), nil + } +} + +// toMcpReadResourceResult converts an api.ResourceCallResult to MCP SDK ReadResourceResult +func toMcpReadResourceResult(result *api.ResourceCallResult) *mcp.ReadResourceResult { + if result == nil { + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{}, + } + } + + contents := make([]*mcp.ResourceContents, 0, len(result.Contents)) + for _, c := range result.Contents { + contents = append(contents, &mcp.ResourceContents{ + URI: c.URI, + MIMEType: c.MIMEType, + Text: c.Text, + Blob: c.Blob, + }) + } + + return &mcp.ReadResourceResult{ + Contents: contents, + } +} + +// toMcpAnnotations converts api.ResourceAnnotations to MCP SDK Annotations +func toMcpAnnotations(annotations *api.ResourceAnnotations) *mcp.Annotations { + if annotations == nil { + return nil + } + + var roles []mcp.Role + for _, a := range annotations.Audience { + roles = append(roles, mcp.Role(a)) + } + + return &mcp.Annotations{ + Audience: roles, + LastModified: annotations.LastModified, + Priority: annotations.Priority, + } +} diff --git a/pkg/mcp/resources_gosdk_test.go b/pkg/mcp/resources_gosdk_test.go new file mode 100644 index 000000000..b64e375c1 --- /dev/null +++ b/pkg/mcp/resources_gosdk_test.go @@ -0,0 +1,281 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/containers/kubernetes-mcp-server/pkg/api" +) + +func TestToMcpAnnotations(t *testing.T) { + tests := []struct { + name string + annotations *api.ResourceAnnotations + wantNil bool + }{ + { + name: "nil annotations", + annotations: nil, + wantNil: true, + }, + { + name: "with all fields", + annotations: &api.ResourceAnnotations{ + Audience: []string{"user", "assistant"}, + LastModified: "2025-01-15T10:00:00Z", + Priority: 0.8, + }, + wantNil: false, + }, + { + name: "with empty audience", + annotations: &api.ResourceAnnotations{ + Audience: []string{}, + LastModified: "2025-01-15T10:00:00Z", + Priority: 0.5, + }, + wantNil: false, + }, + { + name: "with nil audience", + annotations: &api.ResourceAnnotations{ + Audience: nil, + LastModified: "", + Priority: 0, + }, + wantNil: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toMcpAnnotations(tt.annotations) + if tt.wantNil { + assert.Nil(t, got) + return + } + + require.NotNil(t, got) + assert.Equal(t, tt.annotations.LastModified, got.LastModified) + assert.Equal(t, tt.annotations.Priority, got.Priority) + assert.Len(t, got.Audience, len(tt.annotations.Audience)) + }) + } +} + +func TestToMcpReadResourceResult(t *testing.T) { + tests := []struct { + name string + result *api.ResourceCallResult + want int // expected number of contents + }{ + { + name: "nil result", + result: nil, + want: 0, + }, + { + name: "empty contents", + result: &api.ResourceCallResult{ + Contents: []*api.ResourceContents{}, + }, + want: 0, + }, + { + name: "single text content", + result: &api.ResourceCallResult{ + Contents: []*api.ResourceContents{ + { + URI: "k8s://pods/default/my-pod", + MIMEType: "application/json", + Text: `{"kind":"Pod"}`, + }, + }, + }, + want: 1, + }, + { + name: "single binary content", + result: &api.ResourceCallResult{ + Contents: []*api.ResourceContents{ + { + URI: "k8s://secrets/default/my-secret", + MIMEType: "application/octet-stream", + Blob: []byte{0x01, 0x02, 0x03}, + }, + }, + }, + want: 1, + }, + { + name: "multiple contents", + result: &api.ResourceCallResult{ + Contents: []*api.ResourceContents{ + { + URI: "k8s://pods/default/pod1", + MIMEType: "application/json", + Text: `{"name":"pod1"}`, + }, + { + URI: "k8s://pods/default/pod2", + MIMEType: "application/json", + Text: `{"name":"pod2"}`, + }, + }, + }, + want: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := toMcpReadResourceResult(tt.result) + + require.NotNil(t, got) + assert.Len(t, got.Contents, tt.want) + + if tt.result != nil && len(tt.result.Contents) > 0 { + for i, content := range tt.result.Contents { + assert.Equal(t, content.URI, got.Contents[i].URI) + assert.Equal(t, content.MIMEType, got.Contents[i].MIMEType) + assert.Equal(t, content.Text, got.Contents[i].Text) + assert.Equal(t, content.Blob, got.Contents[i].Blob) + } + } + }) + } +} + +func TestServerResourceToGoSdkResource_Conversion(t *testing.T) { + serverResource := api.ServerResource{ + Resource: api.Resource{ + Name: "test-resource", + Description: "Test resource description", + Title: "Test Resource", + URI: "k8s://pods/default/test-pod", + MIMEType: "application/json", + Size: 1024, + Annotations: &api.ResourceAnnotations{ + Audience: []string{"user"}, + LastModified: "2025-01-15T10:00:00Z", + Priority: 0.9, + }, + }, + Handler: func(params api.ResourceHandlerParams) (*api.ResourceCallResult, error) { + return api.NewResourceTextResult(params.URI, "application/json", `{"kind":"Pod"}`), nil + }, + } + + mockServer := &Server{} + + mcpResource, handler, err := ServerResourceToGoSdkResource(mockServer, serverResource) + + require.NoError(t, err) + require.NotNil(t, mcpResource) + require.NotNil(t, handler) + + assert.Equal(t, "test-resource", mcpResource.Name) + assert.Equal(t, "Test resource description", mcpResource.Description) + assert.Equal(t, "Test Resource", mcpResource.Title) + assert.Equal(t, "k8s://pods/default/test-pod", mcpResource.URI) + assert.Equal(t, "application/json", mcpResource.MIMEType) + assert.Equal(t, int64(1024), mcpResource.Size) + + require.NotNil(t, mcpResource.Annotations) + assert.Len(t, mcpResource.Annotations.Audience, 1) + assert.Equal(t, "2025-01-15T10:00:00Z", mcpResource.Annotations.LastModified) + assert.Equal(t, 0.9, mcpResource.Annotations.Priority) +} + +func TestServerResourceToGoSdkResource_NilAnnotations(t *testing.T) { + serverResource := api.ServerResource{ + Resource: api.Resource{ + Name: "simple-resource", + Description: "Resource without annotations", + URI: "k8s://configmaps/default/config", + MIMEType: "application/json", + Annotations: nil, + }, + Handler: func(params api.ResourceHandlerParams) (*api.ResourceCallResult, error) { + return api.NewResourceTextResult(params.URI, "application/json", `{}`), nil + }, + } + + mockServer := &Server{} + + mcpResource, handler, err := ServerResourceToGoSdkResource(mockServer, serverResource) + + require.NoError(t, err) + require.NotNil(t, mcpResource) + require.NotNil(t, handler) + + assert.Equal(t, "simple-resource", mcpResource.Name) + assert.Nil(t, mcpResource.Annotations) +} + +func TestServerResourceTemplateToGoSdkResourceTemplate_Conversion(t *testing.T) { + serverTemplate := api.ServerResourceTemplate{ + ResourceTemplate: api.ResourceTemplate{ + Name: "test-template", + Description: "Test template description", + Title: "Test Template", + URITemplate: "k8s://pods/{namespace}/{name}", + MIMEType: "application/json", + Annotations: &api.ResourceAnnotations{ + Audience: []string{"user", "assistant"}, + LastModified: "2025-01-15T12:00:00Z", + Priority: 0.7, + }, + }, + Handler: func(params api.ResourceHandlerParams) (*api.ResourceCallResult, error) { + return api.NewResourceTextResult(params.URI, "application/json", `{"kind":"Pod"}`), nil + }, + } + + mockServer := &Server{} + + mcpTemplate, handler, err := ServerResourceTemplateToGoSdkResourceTemplate(mockServer, serverTemplate) + + require.NoError(t, err) + require.NotNil(t, mcpTemplate) + require.NotNil(t, handler) + + assert.Equal(t, "test-template", mcpTemplate.Name) + assert.Equal(t, "Test template description", mcpTemplate.Description) + assert.Equal(t, "Test Template", mcpTemplate.Title) + assert.Equal(t, "k8s://pods/{namespace}/{name}", mcpTemplate.URITemplate) + assert.Equal(t, "application/json", mcpTemplate.MIMEType) + + require.NotNil(t, mcpTemplate.Annotations) + assert.Len(t, mcpTemplate.Annotations.Audience, 2) + assert.Equal(t, "2025-01-15T12:00:00Z", mcpTemplate.Annotations.LastModified) + assert.Equal(t, 0.7, mcpTemplate.Annotations.Priority) +} + +func TestServerResourceTemplateToGoSdkResourceTemplate_NilAnnotations(t *testing.T) { + serverTemplate := api.ServerResourceTemplate{ + ResourceTemplate: api.ResourceTemplate{ + Name: "simple-template", + Description: "Template without annotations", + URITemplate: "k8s://services/{namespace}/{name}", + MIMEType: "application/json", + Annotations: nil, + }, + Handler: func(params api.ResourceHandlerParams) (*api.ResourceCallResult, error) { + return api.NewResourceTextResult(params.URI, "application/json", `{}`), nil + }, + } + + mockServer := &Server{} + + mcpTemplate, handler, err := ServerResourceTemplateToGoSdkResourceTemplate(mockServer, serverTemplate) + + require.NoError(t, err) + require.NotNil(t, mcpTemplate) + require.NotNil(t, handler) + + assert.Equal(t, "simple-template", mcpTemplate.Name) + assert.Nil(t, mcpTemplate.Annotations) +} diff --git a/pkg/toolsets/config/toolset.go b/pkg/toolsets/config/toolset.go index 3d08fb597..aa6f695c5 100644 --- a/pkg/toolsets/config/toolset.go +++ b/pkg/toolsets/config/toolset.go @@ -30,6 +30,14 @@ func (t *Toolset) GetPrompts() []api.ServerPrompt { return nil } +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/core/resource_templates.go b/pkg/toolsets/core/resource_templates.go new file mode 100644 index 000000000..cb2335fa4 --- /dev/null +++ b/pkg/toolsets/core/resource_templates.go @@ -0,0 +1,128 @@ +package core + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + apiextensionsv1spec "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func initResourceTemplates() []api.ServerResourceTemplate { + return []api.ServerResourceTemplate{ + crdOpenAPISpecResourceTemplate(), + } +} + +// crdOpenAPISpecResourceTemplate returns a resource template that provides the OpenAPI spec for a CRD. +// The URI format is: k8s://crds/{name}/openapi +// Where {name} is the full CRD name (e.g., "virtualmachines.kubevirt.io") +func crdOpenAPISpecResourceTemplate() api.ServerResourceTemplate { + return api.ServerResourceTemplate{ + ResourceTemplate: api.ResourceTemplate{ + Name: "crd-openapi-spec", + Title: "CRD OpenAPI Specification", + Description: "Returns the OpenAPI v3 schema for a Custom Resource Definition (CRD). The schema describes the structure and validation rules for custom resources of this type. Use this to figure out how to structure resource tool calls when you are unsure of the schema", + URITemplate: "k8s://crds/{name}/openapi", + MIMEType: "application/json", + Annotations: &api.ResourceAnnotations{ + Audience: []string{"assistant"}, + Priority: 0.5, + }, + }, + Handler: handleCRDOpenAPISpec, + } +} + +func handleCRDOpenAPISpec(params api.ResourceHandlerParams) (*api.ResourceCallResult, error) { + // Expected format: k8s://crds/{name}/openapi + crdName, err := parseCRDNameFromURI(params.URI) + if err != nil { + return nil, err + } + + apiExtClient, err := apiextensionsv1.NewForConfig(params.RESTConfig()) + if err != nil { + return nil, fmt.Errorf("failed to create apiextensions client: %w", err) + } + + crd, err := apiExtClient.CustomResourceDefinitions().Get(params.Context, crdName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get CRD %q: %w", crdName, err) + } + + response := buildCRDOpenAPIResponse(crd.Spec.Group, crd.Spec.Names.Kind, crd.Spec.Versions) + + jsonBytes, err := json.Marshal(response) + if err != nil { + return nil, fmt.Errorf("failed to marshal OpenAPI spec: %w", err) + } + + return api.NewResourceTextResult(params.URI, "application/json", string(jsonBytes)), nil +} + +// parseCRDNameFromURI extracts the CRD name from a URI of the form k8s://crds/{name}/openapi +func parseCRDNameFromURI(uri string) (string, error) { + // Remove the scheme prefix + const prefix = "k8s://crds/" + const suffix = "/openapi" + + if !strings.HasPrefix(uri, prefix) { + return "", fmt.Errorf("invalid URI format: expected prefix %q, got %q", prefix, uri) + } + + rest := strings.TrimPrefix(uri, prefix) + + if !strings.HasSuffix(rest, suffix) { + return "", fmt.Errorf("invalid URI format: expected suffix %q in %q", suffix, uri) + } + + name := strings.TrimSuffix(rest, suffix) + if name == "" { + return "", fmt.Errorf("CRD name cannot be empty") + } + + return name, nil +} + +// CRDOpenAPIResponse represents the OpenAPI spec response for a CRD +type CRDOpenAPIResponse struct { + Group string `json:"group"` + Kind string `json:"kind"` + Versions []CRDVersionOpenAPISpec `json:"versions"` +} + +// CRDVersionOpenAPISpec represents the OpenAPI spec for a specific CRD version +type CRDVersionOpenAPISpec struct { + Name string `json:"name"` + Served bool `json:"served"` + Storage bool `json:"storage"` + OpenAPISchema any `json:"openAPIV3Schema,omitempty"` +} + +func buildCRDOpenAPIResponse(group, kind string, versions []apiextensionsv1spec.CustomResourceDefinitionVersion) *CRDOpenAPIResponse { + response := &CRDOpenAPIResponse{ + Group: group, + Kind: kind, + Versions: make([]CRDVersionOpenAPISpec, 0, len(versions)), + } + + for _, v := range versions { + versionSpec := CRDVersionOpenAPISpec{ + Name: v.Name, + Served: v.Served, + Storage: v.Storage, + } + + if v.Schema != nil && v.Schema.OpenAPIV3Schema != nil { + versionSpec.OpenAPISchema = v.Schema.OpenAPIV3Schema + } + + response.Versions = append(response.Versions, versionSpec) + } + + return response +} diff --git a/pkg/toolsets/core/resource_templates_test.go b/pkg/toolsets/core/resource_templates_test.go new file mode 100644 index 000000000..95dfc705d --- /dev/null +++ b/pkg/toolsets/core/resource_templates_test.go @@ -0,0 +1,63 @@ +package core + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type ResourceTemplatesSuite struct { + suite.Suite +} + +func (s *ResourceTemplatesSuite) TestParseCRDNameFromURI() { + s.Run("valid URIs", func() { + testCases := []struct { + uri string + expected string + }{ + {"k8s://crds/virtualmachines.kubevirt.io/openapi", "virtualmachines.kubevirt.io"}, + {"k8s://crds/pods.core.kubernetes.io/openapi", "pods.core.kubernetes.io"}, + {"k8s://crds/simple/openapi", "simple"}, + } + for _, tc := range testCases { + s.Run(tc.uri, func() { + name, err := parseCRDNameFromURI(tc.uri) + s.NoError(err) + s.Equal(tc.expected, name) + }) + } + }) + + s.Run("invalid URIs", func() { + testCases := []struct { + uri string + errContains string + }{ + {"k8s://pods/default/mypod", "expected prefix"}, + {"k8s://crds//openapi", "CRD name cannot be empty"}, + {"k8s://crds/myresource/other", "expected suffix"}, + {"https://example.com/crd", "expected prefix"}, + } + for _, tc := range testCases { + s.Run(tc.uri, func() { + _, err := parseCRDNameFromURI(tc.uri) + s.Error(err) + s.Contains(err.Error(), tc.errContains) + }) + } + }) +} + +func (s *ResourceTemplatesSuite) TestBuildCRDOpenAPIResponse() { + s.Run("builds response with multiple versions", func() { + response := buildCRDOpenAPIResponse("example.com", "Example", nil) + s.Equal("example.com", response.Group) + s.Equal("Example", response.Kind) + s.Empty(response.Versions) + }) +} + +func TestResourceTemplates(t *testing.T) { + suite.Run(t, new(ResourceTemplatesSuite)) +} diff --git a/pkg/toolsets/core/toolset.go b/pkg/toolsets/core/toolset.go index 536b9428c..03c6ec2c0 100644 --- a/pkg/toolsets/core/toolset.go +++ b/pkg/toolsets/core/toolset.go @@ -35,6 +35,14 @@ func (t *Toolset) GetPrompts() []api.ServerPrompt { ) } +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return initResourceTemplates() +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/helm/toolset.go b/pkg/toolsets/helm/toolset.go index 6bdbfd419..e228d2f3e 100644 --- a/pkg/toolsets/helm/toolset.go +++ b/pkg/toolsets/helm/toolset.go @@ -30,6 +30,14 @@ func (t *Toolset) GetPrompts() []api.ServerPrompt { return nil } +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/kcp/toolset.go b/pkg/toolsets/kcp/toolset.go index 1a600d1a3..cf26c656a 100644 --- a/pkg/toolsets/kcp/toolset.go +++ b/pkg/toolsets/kcp/toolset.go @@ -29,6 +29,14 @@ func (t *Toolset) GetPrompts() []api.ServerPrompt { return nil } +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/kiali/toolset.go b/pkg/toolsets/kiali/toolset.go index da5fded30..64f2a238d 100644 --- a/pkg/toolsets/kiali/toolset.go +++ b/pkg/toolsets/kiali/toolset.go @@ -38,6 +38,14 @@ func (t *Toolset) GetPrompts() []api.ServerPrompt { return nil } +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/kubevirt/toolset.go b/pkg/toolsets/kubevirt/toolset.go index 9c87ddafd..4ae4aa68f 100644 --- a/pkg/toolsets/kubevirt/toolset.go +++ b/pkg/toolsets/kubevirt/toolset.go @@ -33,6 +33,14 @@ func (t *Toolset) GetPrompts() []api.ServerPrompt { return nil } +func (t *Toolset) GetResources() []api.ServerResource { + return nil +} + +func (t *Toolset) GetResourceTemplates() []api.ServerResourceTemplate { + return nil +} + func init() { toolsets.Register(&Toolset{}) } diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index c2e869814..390bbe81e 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -36,6 +36,10 @@ func (t *TestToolset) GetTools(_ api.Openshift) []api.ServerTool { return nil } func (t *TestToolset) GetPrompts() []api.ServerPrompt { return nil } +func (t *TestToolset) GetResources() []api.ServerResource { return nil } + +func (t *TestToolset) GetResourceTemplates() []api.ServerResourceTemplate { return nil } + var _ api.Toolset = (*TestToolset)(nil) func (s *ToolsetsSuite) TestToolsetNames() {