Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions internal/test/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
127 changes: 127 additions & 0 deletions pkg/api/resources.go
Original file line number Diff line number Diff line change
@@ -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
}
160 changes: 160 additions & 0 deletions pkg/api/resources_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
6 changes: 6 additions & 0 deletions pkg/api/toolsets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
70 changes: 70 additions & 0 deletions pkg/mcp/crd_openapi_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
Loading
Loading