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
1 change: 1 addition & 0 deletions internal/tools/update-readme/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/gitops"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali"
Expand Down
4 changes: 2 additions & 2 deletions pkg/kubernetes/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ func (m *Manager) Derived(ctx context.Context) (*Kubernetes, error) {
userAgent = ua
}
derivedCfg := &rest.Config{
Host: m.kubernetes.RESTConfig().Host,
APIPath: m.kubernetes.RESTConfig().APIPath,
Host: m.kubernetes.RESTConfig().Host,
APIPath: m.kubernetes.RESTConfig().APIPath,
// Copy only server verification TLS settings (CA bundle and server name)
TLSClientConfig: rest.TLSClientConfig{
Insecure: m.kubernetes.RESTConfig().Insecure,
Expand Down
3 changes: 3 additions & 0 deletions pkg/mcp/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ func TestMain(m *testing.M) {
// OpenShift
CRD("project.openshift.io", "v1", "projects", "Project", "project", false),
CRD("route.openshift.io", "v1", "routes", "Route", "route", true),
// ArgoCD / GitOps
CRD("argoproj.io", "v1alpha1", "applications", "Application", "application", true),
CRD("argoproj.io", "v1alpha1", "appprojects", "AppProject", "appproject", true),
// Kubevirt
CRD("kubevirt.io", "v1", "virtualmachines", "VirtualMachine", "virtualmachine", true),
CRD("clone.kubevirt.io", "v1beta1", "virtualmachineclones", "VirtualMachineClone", "virtualmachineclone", true),
Expand Down
229 changes: 229 additions & 0 deletions pkg/mcp/gitops_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
package mcp

import (
"strings"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/suite"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"

"github.com/containers/kubernetes-mcp-server/pkg/toolsets/gitops"
)

type GitOpsSuite struct {
BaseMcpSuite
}

func (s *GitOpsSuite) SetupTest() {
s.BaseMcpSuite.SetupTest()
ctx := s.T().Context()
s.Require().NoError(EnvTestEnableCRD(ctx, "argoproj.io", "v1alpha1", "applications"))
s.Require().NoError(EnvTestEnableCRD(ctx, "argoproj.io", "v1alpha1", "appprojects"))

client := kubernetes.NewForConfigOrDie(envTestRestConfig)
_, _ = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{Name: "argocd"},
}, metav1.CreateOptions{})

dynClient := dynamic.NewForConfigOrDie(envTestRestConfig)
_, _ = dynClient.Resource(gitops.ApplicationGVR()).Namespace("argocd").Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"metadata": map[string]any{
"name": "test-app",
"namespace": "argocd",
},
"spec": map[string]any{
"project": "default",
"source": map[string]any{
"repoURL": "https://github.com/example/repo.git",
"path": "manifests",
"targetRevision": "HEAD",
},
"destination": map[string]any{
"server": "https://kubernetes.default.svc",
"namespace": "default",
},
},
"status": map[string]any{
"sync": map[string]any{
"status": "Synced",
},
"health": map[string]any{
"status": "Healthy",
},
},
},
}, metav1.CreateOptions{})

_, _ = dynClient.Resource(gitops.ApplicationGVR()).Namespace("argocd").Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "Application",
"metadata": map[string]any{
"name": "test-app-2",
"namespace": "argocd",
},
"spec": map[string]any{
"project": "team-a",
"source": map[string]any{
"repoURL": "https://github.com/example/other-repo.git",
"path": "k8s",
"targetRevision": "main",
},
"destination": map[string]any{
"server": "https://kubernetes.default.svc",
"namespace": "staging",
},
},
},
}, metav1.CreateOptions{})

_, _ = dynClient.Resource(gitops.AppProjectGVR()).Namespace("argocd").Create(ctx, &unstructured.Unstructured{
Object: map[string]any{
"apiVersion": "argoproj.io/v1alpha1",
"kind": "AppProject",
"metadata": map[string]any{
"name": "default",
"namespace": "argocd",
},
"spec": map[string]any{
"sourceRepos": []any{"*"},
"destinations": []any{
map[string]any{
"server": "*",
"namespace": "*",
},
},
},
},
}, metav1.CreateOptions{})
}

func (s *GitOpsSuite) TearDownTest() {
ctx := s.T().Context()
dynClient := dynamic.NewForConfigOrDie(envTestRestConfig)
_ = dynClient.Resource(gitops.ApplicationGVR()).Namespace("argocd").DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})
_ = dynClient.Resource(gitops.AppProjectGVR()).Namespace("argocd").DeleteCollection(ctx, metav1.DeleteOptions{}, metav1.ListOptions{})
s.BaseMcpSuite.TearDownTest()
}

func (s *GitOpsSuite) TestApplicationsList() {
s.Cfg.Toolsets = []string{"gitops"}
s.InitMcpClient()
s.Run("lists all applications", func() {
toolResult, err := s.CallTool("gitops_applications_list", map[string]any{
"namespace": "argocd",
})
s.Require().NoError(err)
s.Require().False(toolResult.IsError, "call tool should succeed")
text := toolResult.Content[0].(*mcp.TextContent).Text
s.Contains(text, "test-app")
s.Contains(text, "test-app-2")
})
s.Run("filters by project", func() {
toolResult, err := s.CallTool("gitops_applications_list", map[string]any{
"namespace": "argocd",
"project": "team-a",
})
s.Require().NoError(err)
s.Require().False(toolResult.IsError, "call tool should succeed")
text := toolResult.Content[0].(*mcp.TextContent).Text
s.Contains(text, "test-app-2")
s.Contains(text, "team-a")
s.NotContains(text, "name: test-app\n")
})
}

func (s *GitOpsSuite) TestApplicationGet() {
s.Cfg.Toolsets = []string{"gitops"}
s.InitMcpClient()
s.Run("gets application by name", func() {
toolResult, err := s.CallTool("gitops_application_get", map[string]any{
"name": "test-app",
"namespace": "argocd",
})
s.Require().NoError(err)
s.Require().False(toolResult.IsError, "call tool should succeed")
text := toolResult.Content[0].(*mcp.TextContent).Text
s.Contains(text, "test-app")
s.Contains(text, "https://github.com/example/repo.git")
})
s.Run("returns error for missing application", func() {
toolResult, err := s.CallTool("gitops_application_get", map[string]any{
"name": "nonexistent",
"namespace": "argocd",
})
s.Require().NoError(err)
s.True(toolResult.IsError, "call tool should fail for missing app")
})
s.Run("requires name parameter", func() {
toolResult, err := s.CallTool("gitops_application_get", map[string]any{
"namespace": "argocd",
})
s.Require().NoError(err)
s.True(toolResult.IsError, "call tool should fail without name")
s.Contains(toolResult.Content[0].(*mcp.TextContent).Text, "name parameter required")
})
}

func (s *GitOpsSuite) TestProjectsList() {
s.Cfg.Toolsets = []string{"gitops"}
s.InitMcpClient()
s.Run("lists projects", func() {
toolResult, err := s.CallTool("gitops_projects_list", map[string]any{
"namespace": "argocd",
})
s.Require().NoError(err)
s.Require().False(toolResult.IsError, "call tool should succeed")
text := toolResult.Content[0].(*mcp.TextContent).Text
s.Contains(text, "default")
})
}

func (s *GitOpsSuite) TestProjectGet() {
s.Cfg.Toolsets = []string{"gitops"}
s.InitMcpClient()
s.Run("gets project by name", func() {
toolResult, err := s.CallTool("gitops_project_get", map[string]any{
"name": "default",
"namespace": "argocd",
})
s.Require().NoError(err)
s.Require().False(toolResult.IsError, "call tool should succeed")
text := toolResult.Content[0].(*mcp.TextContent).Text
s.Contains(text, "default")
s.Contains(text, "sourceRepos")
})
s.Run("returns error for missing project", func() {
toolResult, err := s.CallTool("gitops_project_get", map[string]any{
"name": "nonexistent",
"namespace": "argocd",
})
s.Require().NoError(err)
s.True(toolResult.IsError, "call tool should fail for missing project")
})
}

func (s *GitOpsSuite) TestApplicationsListAutoDetectNamespace() {
s.Cfg.Toolsets = []string{"gitops"}
s.InitMcpClient()
s.Run("auto-detects argocd namespace", func() {
toolResult, err := s.CallTool("gitops_applications_list", map[string]any{})
s.Require().NoError(err)
s.Require().False(toolResult.IsError, "call tool should succeed")
text := toolResult.Content[0].(*mcp.TextContent).Text
s.True(strings.Contains(text, "test-app") || text == "[]",
"should either find apps in argocd namespace or return empty list")
})
}

func TestGitOps(t *testing.T) {
suite.Run(t, new(GitOpsSuite))
}
1 change: 1 addition & 0 deletions pkg/mcp/modules.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mcp
import (
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/config"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/core"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/gitops"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/helm"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kcp"
_ "github.com/containers/kubernetes-mcp-server/pkg/toolsets/kiali"
Expand Down
131 changes: 131 additions & 0 deletions pkg/toolsets/gitops/applications.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package gitops

import (
"fmt"

"github.com/google/jsonschema-go/jsonschema"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"

"github.com/containers/kubernetes-mcp-server/pkg/api"
"github.com/containers/kubernetes-mcp-server/pkg/output"
)

func initApplications() []api.ServerTool {
return []api.ServerTool{
{
Tool: api.Tool{
Name: "gitops_applications_list",
Description: "List ArgoCD/OpenShift GitOps applications with sync and health status overview. " +
"Returns application names, sync status (Synced/OutOfSync/Unknown), health status " +
"(Healthy/Degraded/Progressing/Missing/Suspended/Unknown), source repository, and destination.",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"namespace": {
Type: "string",
Description: "Namespace where ArgoCD applications are defined (auto-detected if not provided: 'openshift-gitops' for OpenShift GitOps, 'argocd' for generic ArgoCD)",
},
"project": {
Type: "string",
Description: "ArgoCD project name to filter applications by (Optional)",
},
"labelSelector": {
Type: "string",
Description: "Kubernetes label selector to filter applications (e.g. 'app.kubernetes.io/instance=my-app') (Optional)",
},
},
},
Annotations: api.ToolAnnotations{
Title: "GitOps: List Applications",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
},
Handler: applicationsList,
},
{
Tool: api.Tool{
Name: "gitops_application_get",
Description: "Get detailed information about a specific ArgoCD application including its full spec " +
"(source, destination, project) and status (sync, health, conditions, operation state).",
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"name": {
Type: "string",
Description: "Name of the ArgoCD application",
},
"namespace": {
Type: "string",
Description: "Namespace where the ArgoCD application is defined (auto-detected if not provided)",
},
},
Required: []string{"name"},
},
Annotations: api.ToolAnnotations{
Title: "GitOps: Get Application",
ReadOnlyHint: ptr.To(true),
DestructiveHint: ptr.To(false),
OpenWorldHint: ptr.To(true),
},
},
Handler: applicationGet,
},
}
}

func applicationsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
namespace := api.OptionalString(params, "namespace", "")
project := api.OptionalString(params, "project", "")
labelSelector := api.OptionalString(params, "labelSelector", "")

opts := metav1.ListOptions{
LabelSelector: labelSelector,
}

client := newGitOpsClient(params)
apps, err := client.applicationsList(params, namespace, opts)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to list ArgoCD applications: %w", err)), nil
}

if project != "" {
filtered := apps.Items[:0]
for i := range apps.Items {
if spec, ok := apps.Items[i].UnstructuredContent()["spec"].(map[string]any); ok {
if p, ok := spec["project"].(string); ok && p == project {
filtered = append(filtered, apps.Items[i])
}
}
}
apps.Items = filtered
}

yamlOut, err := output.MarshalYaml(apps)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to marshal applications: %w", err)), nil
}
return api.NewToolCallResult(yamlOut, nil), nil
}

func applicationGet(params api.ToolHandlerParams) (*api.ToolCallResult, error) {
name, err := api.RequiredString(params, "name")
if err != nil {
return api.NewToolCallResult("", err), nil
}
namespace := api.OptionalString(params, "namespace", "")

client := newGitOpsClient(params)
app, err := client.applicationGet(params, namespace, name)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to get ArgoCD application %q: %w", name, err)), nil
}

yamlOut, err := output.MarshalYaml(app)
if err != nil {
return api.NewToolCallResult("", fmt.Errorf("failed to marshal application: %w", err)), nil
}
return api.NewToolCallResult(yamlOut, nil), nil
}
Loading