diff --git a/internal/tools/update-readme/main.go b/internal/tools/update-readme/main.go index 4163318a3..f4d0a3648 100644 --- a/internal/tools/update-readme/main.go +++ b/internal/tools/update-readme/main.go @@ -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" diff --git a/pkg/kubernetes/manager.go b/pkg/kubernetes/manager.go index a65708cd9..13d10c5ae 100644 --- a/pkg/kubernetes/manager.go +++ b/pkg/kubernetes/manager.go @@ -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, diff --git a/pkg/mcp/common_test.go b/pkg/mcp/common_test.go index 928349333..f27326d56 100644 --- a/pkg/mcp/common_test.go +++ b/pkg/mcp/common_test.go @@ -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), diff --git a/pkg/mcp/gitops_test.go b/pkg/mcp/gitops_test.go new file mode 100644 index 000000000..4a4c32112 --- /dev/null +++ b/pkg/mcp/gitops_test.go @@ -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)) +} diff --git a/pkg/mcp/modules.go b/pkg/mcp/modules.go index b0204a6d5..20a3220a5 100644 --- a/pkg/mcp/modules.go +++ b/pkg/mcp/modules.go @@ -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" diff --git a/pkg/toolsets/gitops/applications.go b/pkg/toolsets/gitops/applications.go new file mode 100644 index 000000000..9656a7632 --- /dev/null +++ b/pkg/toolsets/gitops/applications.go @@ -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 +} diff --git a/pkg/toolsets/gitops/client.go b/pkg/toolsets/gitops/client.go new file mode 100644 index 000000000..f486987a4 --- /dev/null +++ b/pkg/toolsets/gitops/client.go @@ -0,0 +1,80 @@ +package gitops + +import ( + "context" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + applicationGVR = schema.GroupVersionResource{ + Group: "argoproj.io", + Version: "v1alpha1", + Resource: "applications", + } + appProjectGVR = schema.GroupVersionResource{ + Group: "argoproj.io", + Version: "v1alpha1", + Resource: "appprojects", + } +) + +// ApplicationGVR returns the GroupVersionResource for ArgoCD Applications. +func ApplicationGVR() schema.GroupVersionResource { + return applicationGVR +} + +// AppProjectGVR returns the GroupVersionResource for ArgoCD AppProjects. +func AppProjectGVR() schema.GroupVersionResource { + return appProjectGVR +} + +type gitOpsClient struct { + api.KubernetesClient +} + +func newGitOpsClient(client api.KubernetesClient) *gitOpsClient { + return &gitOpsClient{KubernetesClient: client} +} + +func (g *gitOpsClient) applicationsList(ctx context.Context, namespace string, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + ns := g.resolveNamespace(ctx, namespace) + return g.DynamicClient().Resource(applicationGVR).Namespace(ns).List(ctx, opts) +} + +func (g *gitOpsClient) applicationGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { + ns := g.resolveNamespace(ctx, namespace) + return g.DynamicClient().Resource(applicationGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) +} + +func (g *gitOpsClient) appProjectsList(ctx context.Context, namespace string, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + ns := g.resolveNamespace(ctx, namespace) + return g.DynamicClient().Resource(appProjectGVR).Namespace(ns).List(ctx, opts) +} + +func (g *gitOpsClient) appProjectGet(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) { + ns := g.resolveNamespace(ctx, namespace) + return g.DynamicClient().Resource(appProjectGVR).Namespace(ns).Get(ctx, name, metav1.GetOptions{}) +} + +// resolveNamespace returns the provided namespace, or auto-detects the ArgoCD namespace. +// Checks openshift-gitops first, then argocd, then falls back to the configured default. +func (g *gitOpsClient) resolveNamespace(ctx context.Context, namespace string) string { + if namespace != "" { + return namespace + } + for _, candidate := range []string{"openshift-gitops", "argocd"} { + if g.namespaceExists(ctx, candidate) { + return candidate + } + } + return g.NamespaceOrDefault("") +} + +func (g *gitOpsClient) namespaceExists(ctx context.Context, name string) bool { + _, err := g.CoreV1().Namespaces().Get(ctx, name, metav1.GetOptions{}) + return err == nil +} diff --git a/pkg/toolsets/gitops/projects.go b/pkg/toolsets/gitops/projects.go new file mode 100644 index 000000000..9a1bb838c --- /dev/null +++ b/pkg/toolsets/gitops/projects.go @@ -0,0 +1,104 @@ +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 initProjects() []api.ServerTool { + return []api.ServerTool{ + { + Tool: api.Tool{ + Name: "gitops_projects_list", + Description: "List ArgoCD AppProjects with their allowed source repositories and " + + "destination clusters/namespaces.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "namespace": { + Type: "string", + Description: "Namespace where ArgoCD projects are defined (auto-detected if not provided)", + }, + }, + }, + Annotations: api.ToolAnnotations{ + Title: "GitOps: List Projects", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: projectsList, + }, + { + Tool: api.Tool{ + Name: "gitops_project_get", + Description: "Get detailed information about an ArgoCD AppProject including allowed source repos, " + + "destinations, cluster resource whitelist, and roles.", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Name of the ArgoCD AppProject", + }, + "namespace": { + Type: "string", + Description: "Namespace where the ArgoCD project is defined (auto-detected if not provided)", + }, + }, + Required: []string{"name"}, + }, + Annotations: api.ToolAnnotations{ + Title: "GitOps: Get Project", + ReadOnlyHint: ptr.To(true), + DestructiveHint: ptr.To(false), + OpenWorldHint: ptr.To(true), + }, + }, + Handler: projectGet, + }, + } +} + +func projectsList(params api.ToolHandlerParams) (*api.ToolCallResult, error) { + namespace := api.OptionalString(params, "namespace", "") + + client := newGitOpsClient(params) + projects, err := client.appProjectsList(params, namespace, metav1.ListOptions{}) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to list ArgoCD AppProjects: %w", err)), nil + } + + yamlOut, err := output.MarshalYaml(projects) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal AppProjects: %w", err)), nil + } + return api.NewToolCallResult(yamlOut, nil), nil +} + +func projectGet(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) + project, err := client.appProjectGet(params, namespace, name) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to get ArgoCD AppProject %q: %w", name, err)), nil + } + + yamlOut, err := output.MarshalYaml(project) + if err != nil { + return api.NewToolCallResult("", fmt.Errorf("failed to marshal AppProject: %w", err)), nil + } + return api.NewToolCallResult(yamlOut, nil), nil +} diff --git a/pkg/toolsets/gitops/toolset.go b/pkg/toolsets/gitops/toolset.go new file mode 100644 index 000000000..717936e96 --- /dev/null +++ b/pkg/toolsets/gitops/toolset.go @@ -0,0 +1,35 @@ +package gitops + +import ( + "slices" + + "github.com/containers/kubernetes-mcp-server/pkg/api" + "github.com/containers/kubernetes-mcp-server/pkg/toolsets" +) + +type Toolset struct{} + +var _ api.Toolset = (*Toolset)(nil) + +func (t *Toolset) GetName() string { + return "gitops" +} + +func (t *Toolset) GetDescription() string { + return "ArgoCD and OpenShift GitOps application management" +} + +func (t *Toolset) GetTools(_ api.Openshift) []api.ServerTool { + return slices.Concat( + initApplications(), + initProjects(), + ) +} + +func (t *Toolset) GetPrompts() []api.ServerPrompt { + return nil +} + +func init() { + toolsets.Register(&Toolset{}) +}