mirror of
https://github.com/openshift/openshift-mcp-server.git
synced 2025-10-17 14:27:48 +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>
171 lines
8.1 KiB
Go
171 lines
8.1 KiB
Go
package mcp
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/suite"
|
|
"k8s.io/client-go/rest"
|
|
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
|
|
v1 "k8s.io/client-go/tools/clientcmd/api/v1"
|
|
"sigs.k8s.io/yaml"
|
|
|
|
"github.com/containers/kubernetes-mcp-server/internal/test"
|
|
"github.com/containers/kubernetes-mcp-server/pkg/kubernetes"
|
|
)
|
|
|
|
type ConfigurationSuite struct {
|
|
BaseMcpSuite
|
|
}
|
|
|
|
func (s *ConfigurationSuite) SetupTest() {
|
|
s.BaseMcpSuite.SetupTest()
|
|
// Use mock server for predictable kubeconfig content
|
|
mockServer := test.NewMockServer()
|
|
s.T().Cleanup(mockServer.Close)
|
|
kubeconfig := mockServer.Kubeconfig()
|
|
for i := 0; i < 10; i++ {
|
|
// Add multiple fake contexts to force configuration_contexts_list tool to appear
|
|
// and test minification in configuration_view tool
|
|
name := fmt.Sprintf("cluster-%d", i)
|
|
kubeconfig.Contexts[name] = clientcmdapi.NewContext()
|
|
kubeconfig.Clusters[name+"-cluster"] = clientcmdapi.NewCluster()
|
|
kubeconfig.AuthInfos[name+"-auth"] = clientcmdapi.NewAuthInfo()
|
|
kubeconfig.Contexts[name].Cluster = name + "-cluster"
|
|
kubeconfig.Contexts[name].AuthInfo = name + "-auth"
|
|
}
|
|
s.Cfg.KubeConfig = test.KubeconfigFile(s.T(), kubeconfig)
|
|
}
|
|
|
|
func (s *ConfigurationSuite) TestContextsList() {
|
|
s.InitMcpClient()
|
|
s.Run("configuration_contexts_list", func() {
|
|
toolResult, err := s.CallTool("configuration_contexts_list", map[string]interface{}{})
|
|
s.Run("returns contexts", func() {
|
|
s.Nilf(err, "call tool failed %v", err)
|
|
})
|
|
s.Require().NotNil(toolResult, "Expected tool result from call")
|
|
s.Lenf(toolResult.Content, 1, "invalid tool result content length %v", len(toolResult.Content))
|
|
s.Run("contains context count", func() {
|
|
s.Regexpf(`^Available Kubernetes contexts \(11 total`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool count result content %v", toolResult.Content[0].(mcp.TextContent).Text)
|
|
})
|
|
s.Run("contains default context name", func() {
|
|
s.Regexpf(`^Available Kubernetes contexts \(\d+ total, default: fake-context\)`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
|
|
s.Regexpf(`(?m)^\*fake-context -> http:\/\/127\.0\.0\.1:\d*$`, toolResult.Content[0].(mcp.TextContent).Text, "invalid tool context default result content %v", toolResult.Content[0].(mcp.TextContent).Text)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (s *ConfigurationSuite) TestConfigurationView() {
|
|
s.InitMcpClient()
|
|
s.Run("configuration_view", func() {
|
|
toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
|
|
s.Run("returns configuration", func() {
|
|
s.Nilf(err, "call tool failed %v", err)
|
|
})
|
|
s.Require().NotNil(toolResult, "Expected tool result from call")
|
|
var decoded *v1.Config
|
|
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
|
|
s.Run("has yaml content", func() {
|
|
s.Nilf(err, "invalid tool result content %v", err)
|
|
})
|
|
s.Run("returns current-context", func() {
|
|
s.Equalf("fake-context", decoded.CurrentContext, "fake-context not found: %v", decoded.CurrentContext)
|
|
})
|
|
s.Run("returns context info", func() {
|
|
s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
|
|
s.Equalf("fake-context", decoded.Contexts[0].Name, "fake-context not found: %v", decoded.Contexts)
|
|
s.Equalf("fake", decoded.Contexts[0].Context.Cluster, "fake-cluster not found: %v", decoded.Contexts)
|
|
s.Equalf("fake", decoded.Contexts[0].Context.AuthInfo, "fake-auth not found: %v", decoded.Contexts)
|
|
})
|
|
s.Run("returns cluster info", func() {
|
|
s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
|
|
s.Equalf("fake", decoded.Clusters[0].Name, "fake-cluster not found: %v", decoded.Clusters)
|
|
s.Regexpf(`^https?://(127\.0\.0\.1|localhost):\d{1,5}$`, decoded.Clusters[0].Cluster.Server, "fake-server not found: %v", decoded.Clusters)
|
|
})
|
|
s.Run("returns auth info", func() {
|
|
s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
|
|
s.Equalf("fake", decoded.AuthInfos[0].Name, "fake-auth not found: %v", decoded.AuthInfos)
|
|
})
|
|
})
|
|
s.Run("configuration_view(minified=false)", func() {
|
|
toolResult, err := s.CallTool("configuration_view", map[string]interface{}{
|
|
"minified": false,
|
|
})
|
|
s.Run("returns configuration", func() {
|
|
s.Nilf(err, "call tool failed %v", err)
|
|
})
|
|
var decoded *v1.Config
|
|
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
|
|
s.Run("has yaml content", func() {
|
|
s.Nilf(err, "invalid tool result content %v", err)
|
|
})
|
|
s.Run("returns additional context info", func() {
|
|
s.Lenf(decoded.Contexts, 11, "invalid context count, expected 12, got %v", len(decoded.Contexts))
|
|
s.Equalf("cluster-0", decoded.Contexts[0].Name, "cluster-0 not found: %v", decoded.Contexts)
|
|
s.Equalf("cluster-0-cluster", decoded.Contexts[0].Context.Cluster, "cluster-0-cluster not found: %v", decoded.Contexts)
|
|
s.Equalf("cluster-0-auth", decoded.Contexts[0].Context.AuthInfo, "cluster-0-auth not found: %v", decoded.Contexts)
|
|
s.Equalf("fake", decoded.Contexts[10].Context.Cluster, "fake not found: %v", decoded.Contexts)
|
|
s.Equalf("fake", decoded.Contexts[10].Context.AuthInfo, "fake not found: %v", decoded.Contexts)
|
|
s.Equalf("fake-context", decoded.Contexts[10].Name, "fake-context not found: %v", decoded.Contexts)
|
|
})
|
|
s.Run("returns cluster info", func() {
|
|
s.Lenf(decoded.Clusters, 11, "invalid cluster count, expected 2, got %v", len(decoded.Clusters))
|
|
s.Equalf("cluster-0-cluster", decoded.Clusters[0].Name, "cluster-0-cluster not found: %v", decoded.Clusters)
|
|
s.Equalf("fake", decoded.Clusters[10].Name, "fake not found: %v", decoded.Clusters)
|
|
})
|
|
s.Run("configuration_view with minified=false returns auth info", func() {
|
|
s.Lenf(decoded.AuthInfos, 11, "invalid auth info count, expected 2, got %v", len(decoded.AuthInfos))
|
|
s.Equalf("cluster-0-auth", decoded.AuthInfos[0].Name, "cluster-0-auth not found: %v", decoded.AuthInfos)
|
|
s.Equalf("fake", decoded.AuthInfos[10].Name, "fake not found: %v", decoded.AuthInfos)
|
|
})
|
|
})
|
|
}
|
|
|
|
func (s *ConfigurationSuite) TestConfigurationViewInCluster() {
|
|
s.Cfg.KubeConfig = "" // Force in-cluster
|
|
kubernetes.InClusterConfig = func() (*rest.Config, error) {
|
|
return &rest.Config{
|
|
Host: "https://kubernetes.default.svc",
|
|
BearerToken: "fake-token",
|
|
}, nil
|
|
}
|
|
s.T().Cleanup(func() { kubernetes.InClusterConfig = rest.InClusterConfig })
|
|
s.InitMcpClient()
|
|
s.Run("configuration_view", func() {
|
|
toolResult, err := s.CallTool("configuration_view", map[string]interface{}{})
|
|
s.Run("returns configuration", func() {
|
|
s.Nilf(err, "call tool failed %v", err)
|
|
})
|
|
s.Require().NotNil(toolResult, "Expected tool result from call")
|
|
var decoded *v1.Config
|
|
err = yaml.Unmarshal([]byte(toolResult.Content[0].(mcp.TextContent).Text), &decoded)
|
|
s.Run("has yaml content", func() {
|
|
s.Nilf(err, "invalid tool result content %v", err)
|
|
})
|
|
s.Run("returns current-context", func() {
|
|
s.Equalf("in-cluster", decoded.CurrentContext, "context not found: %v", decoded.CurrentContext)
|
|
})
|
|
s.Run("returns context info", func() {
|
|
s.Lenf(decoded.Contexts, 1, "invalid context count, expected 1, got %v", len(decoded.Contexts))
|
|
s.Equalf("in-cluster", decoded.Contexts[0].Name, "context not found: %v", decoded.Contexts)
|
|
s.Equalf("cluster", decoded.Contexts[0].Context.Cluster, "cluster not found: %v", decoded.Contexts)
|
|
s.Equalf("user", decoded.Contexts[0].Context.AuthInfo, "user not found: %v", decoded.Contexts)
|
|
})
|
|
s.Run("returns cluster info", func() {
|
|
s.Lenf(decoded.Clusters, 1, "invalid cluster count, expected 1, got %v", len(decoded.Clusters))
|
|
s.Equalf("cluster", decoded.Clusters[0].Name, "cluster not found: %v", decoded.Clusters)
|
|
s.Equalf("https://kubernetes.default.svc", decoded.Clusters[0].Cluster.Server, "server not found: %v", decoded.Clusters)
|
|
})
|
|
s.Run("returns auth info", func() {
|
|
s.Lenf(decoded.AuthInfos, 1, "invalid auth info count, expected 1, got %v", len(decoded.AuthInfos))
|
|
s.Equalf("user", decoded.AuthInfos[0].Name, "user not found: %v", decoded.AuthInfos)
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestConfiguration(t *testing.T) {
|
|
suite.Run(t, new(ConfigurationSuite))
|
|
}
|