mirror of
https://github.com/containers/kubernetes-mcp-server.git
synced 2025-10-23 01:22:57 +03:00
* feat: add cluster provider for kubeconfig Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: move server to use ClusterProvider interface Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: authentication middleware works with cluster provider Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: unit tests work after cluster provider changes Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: add tool mutator to add cluster parameter Signed-off-by: Calum Murray <cmurray@redhat.com> * test: handle cluster parameter Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: handle lazy init correctly Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: move to using multi-strategy ManagerProvider Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: add contexts_list tool Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: make tool mutator generic between cluster/context naming Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: introduce tool filter Signed-off-by: Calum Murray <cmurray@redhat.com> * refactor: use new ManagerProvider/mutator/filter within mcp server Signed-off-by: Calum Murray <cmurray@redhat.com> * fix(test): tests expect context parameter in tool defs Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: auth handles multi-cluster case correctly Signed-off-by: Calum Murray <cmurray@redhat.com> * fix: small changes from local testing Signed-off-by: Calum Murray <cmurray@redhat.com> * chore: fix enum test Signed-off-by: Calum Murray <cmurray@redhat.com> * review: Multi Cluster support (#1) * nit: rename contexts_list to configuration_contexts_list Besides the conventional naming, it helps LLMs understand the context of the tool by providing a certain level of hierarchy. Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix(mcp): ToolMutator doesn't rely on magic strings Signed-off-by: Marc Nuri <marc@marcnuri.com> * refactor(api): don't expose ManagerProvider to toolsets Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(mcp): configuration_contexts_list basic tests Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(toolsets): revert edge-case test This test should not be touched. Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(toolsets): add specific metadata tests for multi-cluster Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix(mcp): ToolFilter doesn't rely on magic strings (partially) Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(api): IsClusterAware and IsTargetListProvider default values Signed-off-by: Marc Nuri <marc@marcnuri.com> * test(mcp): revert unneeded changes in mcp_tools_test.go Signed-off-by: Marc Nuri <marc@marcnuri.com> --------- Signed-off-by: Marc Nuri <marc@marcnuri.com> * fix: always include configuration_contexts_list if contexts > 1 Signed-off-by: Calum Murray <cmurray@redhat.com> * feat: include server urls in configuration_contexts_list Signed-off-by: Calum Murray <cmurray@redhat.com> --------- Signed-off-by: Calum Murray <cmurray@redhat.com> Signed-off-by: Marc Nuri <marc@marcnuri.com> Co-authored-by: Marc Nuri <marc@marcnuri.com>
348 lines
11 KiB
Go
348 lines
11 KiB
Go
package mcp
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/containers/kubernetes-mcp-server/pkg/api"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"k8s.io/utils/ptr"
|
|
)
|
|
|
|
// createTestTool creates a basic ServerTool for testing
|
|
func createTestTool(name string) api.ServerTool {
|
|
return api.ServerTool{
|
|
Tool: api.Tool{
|
|
Name: name,
|
|
Description: "A test tool",
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: make(map[string]*jsonschema.Schema),
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// createTestToolWithNilSchema creates a ServerTool with nil InputSchema for testing
|
|
func createTestToolWithNilSchema(name string) api.ServerTool {
|
|
return api.ServerTool{
|
|
Tool: api.Tool{
|
|
Name: name,
|
|
Description: "A test tool",
|
|
InputSchema: nil,
|
|
},
|
|
}
|
|
}
|
|
|
|
// createTestToolWithNilProperties creates a ServerTool with nil Properties for testing
|
|
func createTestToolWithNilProperties(name string) api.ServerTool {
|
|
return api.ServerTool{
|
|
Tool: api.Tool{
|
|
Name: name,
|
|
Description: "A test tool",
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: nil,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// createTestToolWithExistingProperties creates a ServerTool with existing properties for testing
|
|
func createTestToolWithExistingProperties(name string) api.ServerTool {
|
|
return api.ServerTool{
|
|
Tool: api.Tool{
|
|
Name: name,
|
|
Description: "A test tool",
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"existing-prop": {Type: "string"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestWithClusterParameter(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
defaultCluster string
|
|
targetParameterName string
|
|
clusters []string
|
|
toolName string
|
|
toolFactory func(string) api.ServerTool
|
|
expectCluster bool
|
|
expectEnum bool
|
|
enumCount int
|
|
}{
|
|
{
|
|
name: "adds cluster parameter when multiple clusters provided",
|
|
defaultCluster: "default-cluster",
|
|
clusters: []string{"cluster1", "cluster2", "cluster3"},
|
|
toolName: "test-tool",
|
|
toolFactory: createTestTool,
|
|
expectCluster: true,
|
|
expectEnum: true,
|
|
enumCount: 3,
|
|
},
|
|
{
|
|
name: "does not add cluster parameter when single cluster provided",
|
|
defaultCluster: "default-cluster",
|
|
clusters: []string{"single-cluster"},
|
|
toolName: "test-tool",
|
|
toolFactory: createTestTool,
|
|
expectCluster: false,
|
|
expectEnum: false,
|
|
enumCount: 0,
|
|
},
|
|
{
|
|
name: "creates InputSchema when nil",
|
|
defaultCluster: "default-cluster",
|
|
clusters: []string{"cluster1", "cluster2"},
|
|
toolName: "test-tool",
|
|
toolFactory: createTestToolWithNilSchema,
|
|
expectCluster: true,
|
|
expectEnum: true,
|
|
enumCount: 2,
|
|
},
|
|
{
|
|
name: "creates Properties map when nil",
|
|
defaultCluster: "default-cluster",
|
|
clusters: []string{"cluster1", "cluster2"},
|
|
toolName: "test-tool",
|
|
toolFactory: createTestToolWithNilProperties,
|
|
expectCluster: true,
|
|
expectEnum: true,
|
|
enumCount: 2,
|
|
},
|
|
{
|
|
name: "preserves existing properties",
|
|
defaultCluster: "default-cluster",
|
|
clusters: []string{"cluster1", "cluster2"},
|
|
toolName: "test-tool",
|
|
toolFactory: createTestToolWithExistingProperties,
|
|
expectCluster: true,
|
|
expectEnum: true,
|
|
enumCount: 2,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.targetParameterName == "" {
|
|
tt.targetParameterName = "cluster"
|
|
}
|
|
mutator := WithTargetParameter(tt.defaultCluster, tt.targetParameterName, tt.clusters)
|
|
tool := tt.toolFactory(tt.toolName)
|
|
originalTool := tool // Keep reference to check if tool was unchanged
|
|
|
|
result := mutator(tool)
|
|
|
|
if !tt.expectCluster {
|
|
if tt.toolName == "skip-this-tool" {
|
|
// For skipped tools, the entire tool should be unchanged
|
|
assert.Equal(t, originalTool, result)
|
|
} else {
|
|
// For single cluster, schema should exist but no cluster property
|
|
require.NotNil(t, result.Tool.InputSchema)
|
|
require.NotNil(t, result.Tool.InputSchema.Properties)
|
|
_, exists := result.Tool.InputSchema.Properties["cluster"]
|
|
assert.False(t, exists, "cluster property should not exist")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Common assertions for cases where cluster parameter should be added
|
|
require.NotNil(t, result.Tool.InputSchema)
|
|
assert.Equal(t, "object", result.Tool.InputSchema.Type)
|
|
require.NotNil(t, result.Tool.InputSchema.Properties)
|
|
|
|
clusterProperty, exists := result.Tool.InputSchema.Properties["cluster"]
|
|
assert.True(t, exists, "cluster property should exist")
|
|
assert.NotNil(t, clusterProperty)
|
|
assert.Equal(t, "string", clusterProperty.Type)
|
|
assert.Contains(t, clusterProperty.Description, tt.defaultCluster)
|
|
|
|
if tt.expectEnum {
|
|
assert.NotNil(t, clusterProperty.Enum)
|
|
assert.Equal(t, tt.enumCount, len(clusterProperty.Enum))
|
|
for _, cluster := range tt.clusters {
|
|
assert.Contains(t, clusterProperty.Enum, cluster)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCreateClusterProperty(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
defaultCluster string
|
|
targetName string
|
|
clusters []string
|
|
expectEnum bool
|
|
expectedCount int
|
|
}{
|
|
{
|
|
name: "creates property with enum when clusters <= maxClustersInEnum",
|
|
defaultCluster: "default",
|
|
targetName: "cluster",
|
|
clusters: []string{"cluster1", "cluster2", "cluster3"},
|
|
expectEnum: true,
|
|
expectedCount: 3,
|
|
},
|
|
{
|
|
name: "creates property without enum when clusters > maxClustersInEnum",
|
|
defaultCluster: "default",
|
|
targetName: "cluster",
|
|
clusters: make([]string, maxTargetsInEnum+5), // 20 clusters
|
|
expectEnum: false,
|
|
expectedCount: 0,
|
|
},
|
|
{
|
|
name: "creates property with exact maxClustersInEnum clusters",
|
|
defaultCluster: "default",
|
|
targetName: "cluster",
|
|
clusters: make([]string, maxTargetsInEnum),
|
|
expectEnum: true,
|
|
expectedCount: maxTargetsInEnum,
|
|
},
|
|
{
|
|
name: "handles single cluster",
|
|
defaultCluster: "default",
|
|
targetName: "cluster",
|
|
clusters: []string{"single-cluster"},
|
|
expectEnum: true,
|
|
expectedCount: 1,
|
|
},
|
|
{
|
|
name: "handles empty clusters list",
|
|
defaultCluster: "default",
|
|
targetName: "cluster",
|
|
clusters: []string{},
|
|
expectEnum: true,
|
|
expectedCount: 0,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Initialize clusters with names if they were created with make()
|
|
if len(tt.clusters) > 3 && tt.clusters[0] == "" {
|
|
for i := range tt.clusters {
|
|
tt.clusters[i] = "cluster" + string(rune('A'+i))
|
|
}
|
|
}
|
|
|
|
property := createTargetProperty(tt.defaultCluster, tt.targetName, tt.clusters)
|
|
|
|
assert.Equal(t, "string", property.Type)
|
|
assert.Contains(t, property.Description, tt.defaultCluster)
|
|
assert.Contains(t, property.Description, "Defaults to "+tt.defaultCluster+" if not set")
|
|
|
|
if tt.expectEnum {
|
|
assert.NotNil(t, property.Enum, "enum should be created")
|
|
assert.Equal(t, tt.expectedCount, len(property.Enum))
|
|
if tt.expectedCount > 0 && tt.expectedCount <= 3 {
|
|
// Only check specific values for small, predefined lists
|
|
for _, cluster := range tt.clusters {
|
|
assert.Contains(t, property.Enum, cluster)
|
|
}
|
|
}
|
|
} else {
|
|
assert.Nil(t, property.Enum, "enum should not be created for too many clusters")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestToolMutatorType(t *testing.T) {
|
|
t.Run("ToolMutator type can be used as function", func(t *testing.T) {
|
|
var mutator ToolMutator = func(tool api.ServerTool) api.ServerTool {
|
|
tool.Tool.Name = "modified-" + tool.Tool.Name
|
|
return tool
|
|
}
|
|
|
|
originalTool := createTestTool("original")
|
|
result := mutator(originalTool)
|
|
assert.Equal(t, "modified-original", result.Tool.Name)
|
|
})
|
|
}
|
|
|
|
func TestMaxClustersInEnumConstant(t *testing.T) {
|
|
t.Run("maxClustersInEnum has expected value", func(t *testing.T) {
|
|
assert.Equal(t, 5, maxTargetsInEnum, "maxClustersInEnum should be 5")
|
|
})
|
|
}
|
|
|
|
type TargetParameterToolMutatorSuite struct {
|
|
suite.Suite
|
|
}
|
|
|
|
func (s *TargetParameterToolMutatorSuite) TestClusterAwareTool() {
|
|
tm := WithTargetParameter("default-cluster", "cluster", []string{"cluster-1", "cluster-2", "cluster-3"})
|
|
tool := createTestTool("cluster-aware-tool")
|
|
// Tools are cluster-aware by default
|
|
tm(tool)
|
|
s.Require().NotNil(tool.Tool.InputSchema.Properties)
|
|
s.Run("adds cluster parameter", func() {
|
|
s.NotNil(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
|
|
})
|
|
s.Run("adds correct description", func() {
|
|
desc := tool.Tool.InputSchema.Properties["cluster"].Description
|
|
s.Contains(desc, "Optional parameter selecting which cluster to run the tool in", "Expected description to mention cluster selection")
|
|
s.Contains(desc, "Defaults to default-cluster if not set", "Expected description to mention default cluster")
|
|
})
|
|
s.Run("adds enum with clusters", func() {
|
|
s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
|
|
enum := tool.Tool.InputSchema.Properties["cluster"].Enum
|
|
s.NotNilf(enum, "Expected enum to be set")
|
|
s.Equal(3, len(enum), "Expected enum to have 3 entries")
|
|
s.Contains(enum, "cluster-1", "Expected enum to contain cluster-1")
|
|
s.Contains(enum, "cluster-2", "Expected enum to contain cluster-2")
|
|
s.Contains(enum, "cluster-3", "Expected enum to contain cluster-3")
|
|
})
|
|
}
|
|
|
|
func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolSingleCluster() {
|
|
tm := WithTargetParameter("default", "cluster", []string{"only-cluster"})
|
|
tool := createTestTool("cluster-aware-tool-single-cluster")
|
|
// Tools are cluster-aware by default
|
|
tm(tool)
|
|
s.Run("does not add cluster parameter for single cluster", func() {
|
|
s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added for single cluster")
|
|
})
|
|
}
|
|
|
|
func (s *TargetParameterToolMutatorSuite) TestClusterAwareToolMultipleClusters() {
|
|
tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2", "cluster-3", "cluster-4", "cluster-5", "cluster-6"})
|
|
tool := createTestTool("cluster-aware-tool-multiple-clusters")
|
|
// Tools are cluster-aware by default
|
|
tm(tool)
|
|
s.Run("adds cluster parameter", func() {
|
|
s.NotNilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to be added")
|
|
})
|
|
s.Run("does not add enum when list of clusters is > 5", func() {
|
|
s.Require().NotNil(tool.Tool.InputSchema.Properties["cluster"])
|
|
enum := tool.Tool.InputSchema.Properties["cluster"].Enum
|
|
s.Nilf(enum, "Expected enum to not be set for too many clusters")
|
|
})
|
|
}
|
|
|
|
func (s *TargetParameterToolMutatorSuite) TestNonClusterAwareTool() {
|
|
tm := WithTargetParameter("default", "cluster", []string{"cluster-1", "cluster-2"})
|
|
tool := createTestTool("non-cluster-aware-tool")
|
|
tool.ClusterAware = ptr.To(false)
|
|
tm(tool)
|
|
s.Run("does not add cluster parameter", func() {
|
|
s.Nilf(tool.Tool.InputSchema.Properties["cluster"], "Expected cluster property to not be added")
|
|
})
|
|
}
|
|
|
|
func TestTargetParameterToolMutator(t *testing.T) {
|
|
suite.Run(t, new(TargetParameterToolMutatorSuite))
|
|
}
|